fix: aggregate metrics bucket counters on insert - #259
Open
rjmackay wants to merge 1 commit into
Open
Conversation
rjmackay
marked this pull request as draft
July 31, 2026 00:55
1 task
rjmackay
force-pushed
the
fix/aggregate-metrics-bucket
branch
3 times, most recently
from
July 31, 2026 01:56
a033d7d to
1d8a9f4
Compare
rjmackay
marked this pull request as ready for review
July 31, 2026 02:05
rjmackay
force-pushed
the
fix/aggregate-metrics-bucket
branch
from
July 31, 2026 02:06
1d8a9f4 to
1068b12
Compare
The metrics bucket is read-modify-written in a cache shared by every PHP process on every update, and it holds one record per flag evaluation, so the bytes written per metrics interval grow with the square of the number of updates in that interval. Over one interval with 100 distinct flags, 20,000 updates write 2,980 MB against 7.5 MB for 1,000 updates. DefaultMetricsBucketSerializer now writes each distinct entry once with the number of times it occurred appended, instead of repeating the entry, which keeps the payload proportional to the number of distinct feature, outcome and variant combinations: 3.7 KB instead of 298 KB after 20,000 updates, and 71 MB written per interval instead of 2,980 MB. deserialize() expands the counts back into records, so MetricsBucket and every public API are untouched. An entry with no count means one evaluation, which is exactly what earlier versions wrote, so this reads their payloads in full and they read these as one evaluation per entry rather than throwing an uncaught TypeError mid rolling deploy. A count can claim far more evaluations than the repeated-entry format could, so the expansion is bounded per bucket, and the limit is a constructor argument because only the consumer knows what its memory_limit and request budget can absorb. An entry over the budget, or one that isn't a feature, an outcome and a variant, is skipped on its own and the bucket keeps its real start date: callers decide whether the interval has elapsed from that date, so resetting it would leave the interval permanently incomplete and stop metrics being sent at all. Only a payload whose interval boundaries are unreadable yields a fresh bucket, and nothing throws inside a metrics flush. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rjmackay
force-pushed
the
fix/aggregate-metrics-bucket
branch
from
July 31, 2026 03:02
1068b12 to
dd3e8c2
Compare
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Description
Fixes quadratic growth in metrics cache writes, in the existing format and without changing any contract.
MetricsBucketholds oneMetricsBucketToggleper evaluation and is read-modify-written in a shared cache on every update, so bytes written per interval areN²·b/2forNupdates — the bucket grows with every write, and every write rewrites all of it.DefaultMetricsBucketSerializernow writes each distinct entry once with a count, instead of repeating it, and expands the counts back on read.MetricsBucket,MetricsBucketToggle,DefaultMetricsHandlerandCacheKeyare untouched, as is every existing public API — the serializer gains one optional constructor argument, and it had no constructor before.Fixes #258
Builds on #198, doesn't revisit it. #198 fixed cost per byte ("a simple (and small) string which can be reconstructed to the metrics bucket object is serialized instead") and left record count alone, so the growth survived with a smaller constant. This is the remaining half, in the format #198 introduced.
Effect — one interval, 100 distinct flags (script in #258). Cache value is what the bucket occupies at the end of the interval; written over the interval sums every one of those writes, since each update rewrites the whole bucket:
The first column is why the second one happens. Today the cache value grows by ~15 bytes per evaluation, so summing it across N writes comes to
payload × N/2— 298 KB × 10,000 = 2,980 MB, which is the measurement, andN²·b/2confirmed. With a flat payload that sum is~3.5 KB × Ninstead: 20× the updates costs 397× the bytes before and 26× after.The number of writes doesn't change — one per update either way; this changes how big each is. Real cache traffic is roughly double the figures above, because every update reads the value before rewriting it, and both sides improve by the same factor.
What changed
One file.
serialize()groups the records by feature, outcome and variant, and appends the number of times each combination occurred:There's one entry per feature, outcome and variant combination, so a feature evaluated both ways takes two entries and each variant adds one — bounded by how the features are configured, not by how often they're evaluated, which is the property that fixes the bug.
deserialize()reads the count and rebuilds that many records, so the bucket keeps exactly the shape it has today. The stubDefaultFeatureis the same one the current implementation rebuilds; it's now allocated once per entry rather than once per record, as is theDefaultVariant.The count is an extension of the format, not a new one
An entry with no count means one evaluation — which is precisely what every earlier version wrote. The two formats are therefore the same format, and both directions of a rolling deploy are safe. Verified against 2.10.1, not just asserted:
1700000000;checkout-v2:1:blue,checkout-v2:0:~,search-ranking:1:~,checkout-v2:1:blue;~→{"checkout-v2":{"yes":2,"no":1,"variants":{"blue":2}},"search-ranking":{"yes":1,"no":0}}. Nothing is lost on upgrade.That second direction is why the count is a fourth field inside the entry rather than a version marker or a new top-level field. 2.10.1's
deserialize()destructures on;unguarded, and anything it doesn't recognise throwsTypeError: DefaultVariant::__construct(): Argument #1 ($name) must be of type string, null given— uncaught, from insideisEnabled(). Any format it can't parse 500s every process still running it for the length of the deploy. This shape can't trigger that.In the other direction
deserialize()never throws, and a payload it can't fully make sense of costs as little as it can:nowleaves the interval permanently incomplete and metrics silently never send — not delayed, never. A feature name containing a comma is enough to trigger it, sinceserialize()writes it anddeserialize()then can't read it back: measured throughDefaultMetricsHandler, 0 flushes in 2.4s against a 0.5s interval, against 5 once the entry is skipped instead.The budget is a constructor argument, defaulting to
DEFAULT_MAX_EVALUATIONS = 500_000, because only the consumer knows what itsmemory_limitand per-request budget can absorb — a web tier at 128M and a queue worker at 1G in the same deployment want different answers. Measured on 8.4: ~113 bytes per expanded record, so the default is ~54 MB, and expansion costs ~0.2ms per 1,000 records. Reaching it honestly needs more than 8,000 evaluations a second, fleet-wide, against a 60 second interval.Feature names containing
;,,or:still don't round trip, exactly as before — onmainsuch a name makes 2.10.1's own reader throw the TypeError above; here it fails soft. I did try percent-encoding the names, and dropped it: it would have made a payload written now unreadable to older versions, which isn't worth fixing a case that has never worked.What this deliberately does not fix
CPU is still
O(evaluations)per update, becauseMetricsBucketstill holds one record per evaluation —deserialize()has to rebuild them, just as it does today. Same loop, but reading the bucket back each time the wayDefaultMetricsHandlerdoes:Faster and lighter than today at every size, but the same shape of curve. This PR fixes the cache-traffic half of the bug — the half that hits a shared Redis and that #197 was reported about.
Aggregating inside
MetricsBucketwould make thatO(1)too, and I have it working — it needsadd()/getCounts()on the bucket and either deprecates or replacesgetToggles(). I left it out becauseMetricsBucketSerializeris public and a third-party implementation may callgetToggles(), so this way the PR asks nothing of anyone. Happy to push it here or as a follow-up.Also out of scope, as separate concerns: the per-call cache I/O in
DefaultMetricsHandler(what a custom handler solves, per #197), and the lost-update race inherent to read-modify-write over PSR-16 — concurrent processes can still overwrite each other's counts exactly as they can today, and fixing it needs atomic increments PSR-16 can't express.Type of change
How Has This Been Tested?
All green locally on 8.4:
composer phpunit(279 tests, incl. the client-specification submodule),composer phpstan(level=max),composer fixer. Rector's 7.2 downgrade was also run oversrcand the suite re-run against the transpiled output (239 tests —UnleashBuilderTestandAbstractNumberOperatorValidatorTestneed their*Impl80helpers, which can't load against transpiledsrcon 8.4; unrelated to this change). Both compatibility directions were checked by running the real 2.10.1 code against payloads from this branch and vice versa, as quoted above.New tests, all in
DefaultMetricsBucketSerializerTest:flag:1:blue:300,flag:1:green:100,flag:0:~:100, with the counts the server receives asserted alongside it.name:outcome:variantprefix and the count stays last.jsonSerialize()identical after a round trip for counts, variants, interval start, null and non-null end date, plus the record count coming back as expected.The existing round-trip data provider is unchanged, so the buckets it already covered still have to survive the new format.
Checklist:
Docs are unchanged because none of them mention
MetricsBucket,getToggles()or the stored format — the README's Metrics section documents builder options only. Happy to write up the cache format if you'd like it somewhere.