Skip to content

Commit f4ea487

Browse files
committed
feat: high precision and nested fractional
* fractional now supports up to max-int32 total weight, and 1/max-int32 resolution * fractional now supports computed (nested JSONLogic) variants and weights Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
1 parent ad51d4e commit f4ea487

File tree

11 files changed

+847
-750
lines changed

11 files changed

+847
-750
lines changed

.github/workflows/build.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ jobs:
9595
tags: flagd-local:test
9696

9797
- name: Run Trivy vulnerability scanner
98-
uses: aquasecurity/trivy-action@0.28.0
98+
uses: aquasecurity/trivy-action@0.35.0
9999
with:
100100
input: ${{ github.workspace }}/flagd-local.tar
101101
format: "sarif"

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ flagd-benchmark-test:
5252
flagd-integration-test-harness:
5353
# target used to start a locally built flagd with the e2e flags
5454
cd flagd; go run main.go start -f file:../test-harness/flags/testing-flags.json -f file:../test-harness/flags/custom-ops.json -f file:../test-harness/flags/evaluator-refs.json -f file:../test-harness/flags/zero-flags.json -f file:../test-harness/flags/edge-case-flags.json
55-
flagd-integration-test: # dependent on flagd-e2e-test-harness if not running in github actions
56-
go test -count=1 -cover ./test/integration $(ARGS)
55+
flagd-integration-test: workspace-clean
56+
# this is a intentionally an "orphaned" module so that it effectively does e2e testing independently of the rest of the code
57+
cd test/integration && go test -count=1 -cover $(ARGS)
5758
run: # default to flagd
5859
make run-flagd
5960
run-flagd:

core/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ require (
77
buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1
88
connectrpc.com/connect v1.19.1
99
connectrpc.com/otelconnect v0.7.2
10-
github.com/diegoholiveira/jsonlogic/v3 v3.8.4
10+
github.com/diegoholiveira/jsonlogic/v3 v3.9.0
1111
github.com/fsnotify/fsnotify v1.9.0
1212
github.com/google/go-cmp v0.7.0
1313
github.com/google/uuid v1.6.0

core/go.sum

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
117117
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
118118
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
119119
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
120-
github.com/diegoholiveira/jsonlogic/v3 v3.8.4 h1:IVVU/VLz2hn10ImbmibjiUkdVsSFIB1vfDaOVsaipH4=
121-
github.com/diegoholiveira/jsonlogic/v3 v3.8.4/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w=
120+
github.com/diegoholiveira/jsonlogic/v3 v3.9.0 h1:ZYx6tM8+1NRo0RwFpBmVxtmJnXs/f3rtIZo9t9dCk3Y=
121+
github.com/diegoholiveira/jsonlogic/v3 v3.9.0/go.mod h1:OYRb6FSTVmMM+MNQ7ElmMsczyNSepw+OU4Z8emDSi4w=
122122
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
123123
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
124124
github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk=
@@ -336,78 +336,42 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
336336
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
337337
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
338338
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
339-
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
340-
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
341339
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
342340
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
343-
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
344-
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
345341
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
346342
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
347-
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc=
348-
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s=
349343
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
350344
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
351-
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0=
352-
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss=
353345
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
354346
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
355-
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
356-
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
357347
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
358348
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
359-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
360-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
361349
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
362350
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
363-
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
364-
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
365351
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
366352
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
367-
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
368-
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
369353
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
370354
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
371-
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo=
372-
go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk=
373355
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
374356
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
375-
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 h1:B/g+qde6Mkzxbry5ZZag0l7QrQBCtVm7lVjaLgmpje8=
376-
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0/go.mod h1:mOJK8eMmgW6ocDJn6Bn11CcZ05gi3P8GylBXEkZtbgA=
377357
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
378358
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
379-
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
380-
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
381359
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
382360
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
383-
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
384-
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
385361
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
386362
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
387-
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
388-
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
389363
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
390364
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
391-
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
392-
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
393365
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
394366
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
395-
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
396-
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
397367
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
398368
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
399-
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
400-
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
401369
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
402370
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
403-
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
404-
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
405-
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
406-
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
371+
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
372+
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
407373
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
408374
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
409-
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
410-
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
411375
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
412376
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
413377
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
@@ -553,8 +517,6 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
553517
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
554518
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
555519
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
556-
google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
557-
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
558520
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
559521
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
560522
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=

core/pkg/evaluator/fractional.go

Lines changed: 79 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,27 @@ import (
99
"github.com/twmb/murmur3"
1010
)
1111

12+
const maxWeightSum = math.MaxInt32 // 2,147,483,647
13+
1214
const FractionEvaluationName = "fractional"
1315

1416
type Fractional struct {
1517
Logger *logger.Logger
1618
}
1719

1820
type fractionalEvaluationDistribution struct {
19-
totalWeight int
21+
totalWeight int32
2022
weightedVariants []fractionalEvaluationVariant
23+
data any
24+
logger *logger.Logger
2125
}
2226

2327
type 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

4044
func (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

Comments
 (0)