@@ -9,23 +9,27 @@ import (
99 "github.com/twmb/murmur3"
1010)
1111
12+ const maxWeightSum = math .MaxInt32 // 2,147,483,647
13+
1214const FractionEvaluationName = "fractional"
1315
1416type Fractional struct {
1517 Logger * logger.Logger
1618}
1719
1820type fractionalEvaluationDistribution struct {
19- totalWeight int
21+ totalWeight int32
2022 weightedVariants []fractionalEvaluationVariant
23+ data any
24+ logger * logger.Logger
2125}
2226
2327type fractionalEvaluationVariant struct {
24- variant string
25- weight int
28+ variant any // string, bool, number or nil
29+ weight int32
2630}
2731
28- func (v fractionalEvaluationVariant ) getPercentage (totalWeight int ) float64 {
32+ func (v fractionalEvaluationVariant ) getPercentage (totalWeight int32 ) float64 {
2933 if totalWeight == 0 {
3034 return 0
3135 }
@@ -38,16 +42,17 @@ func NewFractional(logger *logger.Logger) *Fractional {
3842}
3943
4044func (fe * Fractional ) Evaluate (values , data any ) any {
41- valueToDistribute , feDistributions , err := parseFractionalEvaluationData (values , data )
45+ valueToDistribute , feDistributions , err := parseFractionalEvaluationData (values , data , fe . Logger )
4246 if err != nil {
4347 fe .Logger .Warn (fmt .Sprintf ("parse fractional evaluation data: %v" , err ))
4448 return nil
4549 }
4650
47- return distributeValue (valueToDistribute , feDistributions )
51+ hashValue := uint32 (murmur3 .StringSum32 (valueToDistribute ))
52+ return distributeValue (hashValue , feDistributions )
4853}
4954
50- func parseFractionalEvaluationData (values , data any ) (string , * fractionalEvaluationDistribution , error ) {
55+ func parseFractionalEvaluationData (values , data any , logger * logger. Logger ) (string , * fractionalEvaluationDistribution , error ) {
5156 valuesArray , ok := values .([]any )
5257 if ! ok {
5358 return "" , nil , errors .New ("fractional evaluation data is not an array" )
@@ -61,9 +66,8 @@ func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluat
6166 return "" , nil , errors .New ("data isn't of type map[string]any" )
6267 }
6368
64- // Ignore the error as we can't really do anything if the properties are
65- // somehow missing.
6669 properties , _ := getFlagdProperties (dataMap )
70+ flagKey := properties .FlagKey
6771
6872 bucketBy , ok := valuesArray [0 ].(string )
6973 if ok {
@@ -76,73 +80,123 @@ func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluat
7680
7781 targetingKey , ok := dataMap [targetingKeyKey ].(string )
7882 if ! ok {
79- return "" , nil , errors . New ( " bucketing value not supplied and no targetingKey in context" )
83+ return "" , nil , fmt . Errorf ( "flag %q: bucketing value not supplied and no targetingKey in context", flagKey )
8084 }
8185
8286 bucketBy = fmt .Sprintf ("%s%s" , properties .FlagKey , targetingKey )
8387 }
8488
85- feDistributions , err := parseFractionalEvaluationDistributions (valuesArray )
89+ feDistributions , err := parseFractionalEvaluationDistributions (valuesArray , data , logger , flagKey )
8690 if err != nil {
8791 return "" , nil , err
8892 }
8993
9094 return bucketBy , feDistributions , nil
9195}
9296
93- func parseFractionalEvaluationDistributions (values []any ) (* fractionalEvaluationDistribution , error ) {
97+ func parseFractionalEvaluationDistributions (values []any , data any , logger * logger. Logger , flagKey string ) (* fractionalEvaluationDistribution , error ) {
9498 feDistributions := & fractionalEvaluationDistribution {
9599 totalWeight : 0 ,
96100 weightedVariants : make ([]fractionalEvaluationVariant , len (values )),
101+ data : data ,
102+ logger : logger ,
97103 }
104+
105+ // parse all weights first to validate the sum
106+ var totalWeightInt64 int64 = 0
107+
98108 for i := 0 ; i < len (values ); i ++ {
99109 distributionArray , ok := values [i ].([]any )
100110 if ! ok {
101- return nil , errors . New ( " distribution elements aren't of type []any. " +
102- "please check your rule in flag definition" )
111+ return nil , fmt . Errorf ( "flag %q: distribution elements aren't of type []any. "+
112+ "please check your rule in flag definition" , flagKey )
103113 }
104114
105115 if len (distributionArray ) == 0 {
106- return nil , errors . New ( " distribution element needs at least one element" )
116+ return nil , fmt . Errorf ( "flag %q: distribution element needs at least one element", flagKey )
107117 }
108118
109- variant , ok := distributionArray [0 ].(string )
110- if ! ok {
111- return nil , errors .New ("first element of distribution element isn't string" )
119+ // JSONLogic pre-evaluates all arguments before they reach fractional.
120+ // Pre-evaluated operators become primitive values (strings, numbers, etc.), never map[string]any nodes.
121+ var variant any
122+ switch v := distributionArray [0 ].(type ) {
123+ case string :
124+ variant = v
125+ case bool :
126+ variant = v
127+ case float64 :
128+ variant = v
129+ case nil :
130+ variant = nil
131+ default :
132+ return nil , fmt .Errorf ("flag %q: first element of distribution element must be a string, bool, number, or nil" , flagKey )
112133 }
113134
114- weight := 1.0
135+ weight := int64 ( 1 )
115136 if len (distributionArray ) >= 2 {
137+ // parse as float64 first since that's what JSON gives us
116138 distributionWeight , ok := distributionArray [1 ].(float64 )
139+ if ! ok && distributionArray [1 ] != nil {
140+ return nil , fmt .Errorf ("flag %q: weight must be a number" , flagKey )
141+ }
117142 if ok {
118- // default the weight to 1 if not specified explicitly
119- weight = distributionWeight
143+ weight = int64 (distributionWeight )
144+ }
145+ }
146+
147+ // validate weight is a whole number
148+ if len (distributionArray ) >= 2 {
149+ distributionWeight , ok := distributionArray [1 ].(float64 )
150+ if ok && distributionWeight != float64 (int64 (distributionWeight )) {
151+ return nil , fmt .Errorf ("flag %q: weights must be integers" , flagKey )
120152 }
121153 }
122154
123- feDistributions .totalWeight += int (weight )
155+ // validate individual weight doesn't exceed int32
156+ if weight > math .MaxInt32 {
157+ return nil , fmt .Errorf ("flag %q: weight %d exceeds maximum allowed value %d" , flagKey , weight , math .MaxInt32 )
158+ }
159+
160+ // clamp negative weights to 0
161+ if weight < 0 {
162+ // negative weights can be the result of rollout calculations, so we log and clamp to 0 rather than returning an error
163+ logger .Debug (fmt .Sprintf ("flag %q: negative weight %d clamped to 0" , flagKey , weight ))
164+ weight = 0
165+ }
166+
167+ totalWeightInt64 += weight
124168 feDistributions .weightedVariants [i ] = fractionalEvaluationVariant {
125169 variant : variant ,
126- weight : int (weight ),
170+ weight : int32 (weight ),
127171 }
128172 }
129173
174+ // validate total weight doesn't exceed MaxInt32
175+ if totalWeightInt64 > int64 (maxWeightSum ) {
176+ return nil , fmt .Errorf ("flag %q: sum of all weights (%d) exceeds maximum allowed value (%d)" , flagKey , totalWeightInt64 , maxWeightSum )
177+ }
178+
179+ feDistributions .totalWeight = int32 (totalWeightInt64 )
130180 return feDistributions , nil
131181}
132182
133- // distributeValue calculate hash for given hash key and find the bucket distributions belongs to
134- func distributeValue (value string , feDistribution * fractionalEvaluationDistribution ) string {
135- hashValue := int32 (murmur3 .StringSum32 (value ))
136- hashRatio := math .Abs (float64 (hashValue )) / math .MaxInt32
137- bucket := hashRatio * 100 // in range [0, 100]
183+ // distributeValue accepts a pre-computed 32-bit hash value and distributes it to a variant using high-precision integer arithmetic.
184+ // It maps a 32-bit hash to the range [0, totalWeight) and finds the variant bucket that contains that value.
185+ func distributeValue (hashValue uint32 , feDistribution * fractionalEvaluationDistribution ) any {
186+ if feDistribution .totalWeight == 0 {
187+ return ""
188+ }
189+
190+ bucket := (uint64 (hashValue ) * uint64 (feDistribution .totalWeight )) >> 32
138191
139- rangeEnd := float64 ( 0 )
140- for _ , weightedVariant := range feDistribution .weightedVariants {
141- rangeEnd += weightedVariant . getPercentage ( feDistribution . totalWeight )
192+ var rangeEnd uint64 = 0
193+ for _ , variant := range feDistribution .weightedVariants {
194+ rangeEnd += uint64 ( variant . weight )
142195 if bucket < rangeEnd {
143- return weightedVariant .variant
196+ return variant .variant
144197 }
145198 }
146199
200+ // unreachable given validation
147201 return ""
148202}
0 commit comments