|
| 1 | +--- |
| 2 | +status: draft |
| 3 | +author: @toddbaert |
| 4 | +created: 2025-06-06 |
| 5 | +updated: 2025-06-06 |
| 6 | +--- |
| 7 | + |
| 8 | +# Fractional Operator |
| 9 | + |
| 10 | +The fractional operator enables deterministic, fractional feature flag distribution. |
| 11 | + |
| 12 | +## Background |
| 13 | + |
| 14 | +Nearly all feature flag systems require pseudorandom assignment support to facilitate key use cases, including experimentation and fractional progressive rollouts. |
| 15 | +Since flagd seeks to implement a full feature flag evaluation engine, such a feature is required. |
| 16 | + |
| 17 | +## Requirements |
| 18 | + |
| 19 | +- **Deterministic**: must be consistent given the same input (so users aren't re-assigned with each page view, for example) |
| 20 | +- **Performant**: must be quick; we want "predictable randomness", but with a relatively low performance cost |
| 21 | +- **Ease of use**: must be easy to use and understand for basic use-cases |
| 22 | +- **Customization**: must support customization, such as specifying a particular context attribute to "bucket" on |
| 23 | +- **Stability**: adding new variants should result in new assignments for as small a section of the audience as possible |
| 24 | +- **Strong avalanche effect**: slight input changes should result in relatively high chance of differential bucket assignment |
| 25 | + |
| 26 | +## Considered Options |
| 27 | + |
| 28 | +- We considered various "more common" hash algos, such as `sha1` and `md5`, but they were frequently slower than `Murmur3`, and didn't offer better performance for our purposes |
| 29 | +- Initially we required weights to sum to 100, but we've since revoked that requirement |
| 30 | + |
| 31 | +## Proposal |
| 32 | + |
| 33 | +### MurmurHash3 + numeric weights + optional targeting-key-based bucketing value |
| 34 | + |
| 35 | +#### The fractional operator mechanism |
| 36 | + |
| 37 | +The fractional operator facilitates **deterministic A/B testing and gradual rollouts** through a custom JSONLogic extension introduced in flagd version 0.6.4+. |
| 38 | +This operator splits feature flag variants into "buckets", based the `targetingKey` (or another optionally specified key), ensuring users consistently receive the same variant across sessions through sticky evaluation. |
| 39 | + |
| 40 | +The core algorithm involves four steps: extracting a bucketing property from the evaluation context, hashing this value using MurmurHash3, mapping the hash to a [0, 100] range, and selecting variants based on cumulative weight thresholds. |
| 41 | +This approach guarantees that identical inputs always produce identical outputs (excepting the case of rules involving the `$flag.timestamp`), which is crucial for maintaining a consistent user experience. |
| 42 | + |
| 43 | +#### MurmurHash3: The chosen algorithm |
| 44 | + |
| 45 | +flagd specifically employs **MurmurHash3 (32-bit variant)** for its fractional operator, prioritizing performance and distribution quality over cryptographic security. |
| 46 | +This non-cryptographic hash function provides excellent performance and good avalanche properties (small input changes produce dramatically different outputs) while maintaining deterministic behavior essential for sticky evaluations. |
| 47 | +Its wide language implementation ensures identical results across different flagd providers, no matter the language in question. |
| 48 | + |
| 49 | +#### Bucketing value |
| 50 | + |
| 51 | +The bucking value is an optional first value to the operator (it may be a JSONLogic expression, other than an array). |
| 52 | +This allows enables targeting based on arbitrary attributes (individual users, companies/tenants, etc). |
| 53 | +If not specified, the bucketing value is a JSONLogic expression concatenating the `$flagd.flagKey` and the extracted [targeting key](https://openfeature.dev/specification/glossary/#targeting-key) (`targetingKey`) from the context (the inclusion of the flag key prevents users from landing in the same "bucket index" for all flags with the same number of buckets). |
| 54 | +If the bucking value does not resolve to a string, or the `targeting key` is undefined, the evaluation is considered erroneous. |
| 55 | + |
| 56 | +```json |
| 57 | +// Default bucketing value |
| 58 | +{ |
| 59 | + "cat": [ |
| 60 | + {"var": "$flagd.flagKey"}, |
| 61 | + {"var": "targetingKey"} |
| 62 | + ] |
| 63 | +} |
| 64 | +``` |
| 65 | + |
| 66 | +#### Bucketing strategy implementation |
| 67 | + |
| 68 | +After retrieving the bucketing value, and hashing it to a [0, 99] range, the algorithm iterates through variants, accumulating their relative weights until finding the bucket containing the hash value. |
| 69 | + |
| 70 | +```go |
| 71 | +// Simplified implementation structure |
| 72 | +hashValue := murmur3Hash(bucketingValue) % 100 |
| 73 | +currentWeight := 0 |
| 74 | +for _, distribution := range variants { |
| 75 | + currentWeight += (distribution.weight * 100) / sumOfWeights |
| 76 | + if hashValue < currentWeight { |
| 77 | + return distribution.variant |
| 78 | + } |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +This approach supports flexible weight ratios; weights of [25, 50, 25] translate to 25%, 50%, and 25% distribution respectively as do [1, 2, 1]. |
| 83 | +It's worth noting that the maximum bucket resolution is 1/100, meaning that the maximum ratio between variant distributions is 1:99 (ie: a weight distribution of [1, 100000] behaves the same as [1, 100]). |
| 84 | + |
| 85 | +#### Format flexibility: Shorthand vs longhand |
| 86 | + |
| 87 | +flagd provides two syntactic options for defining fractional distributions, balancing simplicity with precision. **Shorthand format** enables equal distribution by specifying variants as single-element arrays (in this case, an equal weight of 1 is automatically assumed): |
| 88 | + |
| 89 | +```json |
| 90 | +{ |
| 91 | + "fractional": [ |
| 92 | + ["red"], |
| 93 | + ["blue"], |
| 94 | + ["green"] |
| 95 | + ] |
| 96 | +} |
| 97 | +``` |
| 98 | + |
| 99 | +**Longhand format** allows precise weight control through two-element arrays: |
| 100 | + |
| 101 | +Note that in this example, we've also specified a custom bucketing value. |
| 102 | + |
| 103 | +```json |
| 104 | +{ |
| 105 | + "fractional": [ |
| 106 | + { "var": "email" }, |
| 107 | + ["red", 50], |
| 108 | + ["blue", 20], |
| 109 | + ["green", 30] |
| 110 | + ] |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +### Consequences |
| 115 | + |
| 116 | +- Good, because Murmur3 is fast, has good avalanche properties, and we don't need "cryptographic" randomness |
| 117 | +- Good, because we have flexibility but also simple shorthand |
| 118 | +- Good, because our bucketing algorithm is relatively stable when new variants are added |
| 119 | +- Bad, because we only support string bucketing values |
| 120 | +- Bad, because we don't have bucket resolution finer than 1:99 |
| 121 | +- Bad, because we don't support JSONLogic expressions within bucket definitions |
0 commit comments