@@ -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,116 @@ 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 || weight < 0 {
157+ return nil , fmt .Errorf ("flag %q: weight %d exceeds maximum allowed value %d" , flagKey , weight , math .MaxInt32 )
158+ }
159+
160+ totalWeightInt64 += weight
124161 feDistributions .weightedVariants [i ] = fractionalEvaluationVariant {
125162 variant : variant ,
126- weight : int (weight ),
163+ weight : int32 (weight ),
127164 }
128165 }
129166
167+ // validate total weight doesn't exceed MaxInt32
168+ if totalWeightInt64 > int64 (maxWeightSum ) {
169+ return nil , fmt .Errorf ("flag %q: sum of all weights (%d) exceeds maximum allowed value (%d)" , flagKey , totalWeightInt64 , maxWeightSum )
170+ }
171+
172+ feDistributions .totalWeight = int32 (totalWeightInt64 )
130173 return feDistributions , nil
131174}
132175
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]
176+ // distributeValue accepts a pre-computed 32-bit hash value and distributes it to a variant using high-precision integer arithmetic.
177+ // It maps a 32-bit hash to the range [0, totalWeight) and finds the variant bucket that contains that value.
178+ func distributeValue (hashValue uint32 , feDistribution * fractionalEvaluationDistribution ) any {
179+ if feDistribution .totalWeight == 0 {
180+ return ""
181+ }
182+
183+ bucket := (uint64 (hashValue ) * uint64 (feDistribution .totalWeight )) >> 32
138184
139- rangeEnd := float64 ( 0 )
140- for _ , weightedVariant := range feDistribution .weightedVariants {
141- rangeEnd += weightedVariant . getPercentage ( feDistribution . totalWeight )
185+ var rangeEnd uint64 = 0
186+ for _ , variant := range feDistribution .weightedVariants {
187+ rangeEnd += uint64 ( variant . weight )
142188 if bucket < rangeEnd {
143- return weightedVariant .variant
189+ return variant .variant
144190 }
145191 }
146192
193+ // unreachable given validation
147194 return ""
148195}
0 commit comments