Repository navigation
fix(metrics): bound latency segment memory by summing into fixed time buckets - #398
Open
LeeroyHannigan wants to merge 2 commits into
Open
LeeroyHannigan wants to merge 2 commits into
LeeroyHannigan wants to merge 2 commits into
Conversation
… keeping one point per request MetricsCollector kept a SegmentPoint (112 bytes plus two heap strings) for every data-plane request for 24 hours. At 274 requests per second that is 23.7 million points a day and about 4.3 GiB of anonymous memory (issue #397); measured here, the 0.1.13 binary grows by 193.5 bytes per GetItem. The console only reads per-operation counts and mean segments over a window, so the points are summed at record time instead. Each operation now owns two fixed rings of 64-byte buckets: 301 one-second buckets serving LastMinute and Last5Minutes, and 1441 one-minute buckets serving LastHour, LastDay and AllTime. A record is one hash lookup and two bucket updates under the write lock; the rings are allocated once per operation. Memory is at most operations * 111,488 bytes, and the server only records the 37 operations in the dispatch table, so under 4 MiB per process. A window of W seconds covers at least the last W seconds and at most W plus one bucket width. The clock is read under the lock on both the record and the query path, so a stored record is never ahead of a query that follows it. prune() no longer touches segments. record_segments(operation, &LatencySegments) no longer takes the table name: a per-table dimension would multiply the bound by the table count, which clients control, and nothing read it. The one caller is updated. Docs: the design doc states the bucket widths and what each window returns; the deployment guide gains a Process Memory subsection with the growth law for releases through 0.1.13; the todo-index entry for the table field is removed. Tests: boundedness over five million synthetic requests, a naive-reference comparison on a random workload, continuous load across three laps of the coarse ring against a closed-form reference, window filters at second and minute boundaries, ring wrap and stale-slot exclusion, and an ignored timing comparison of the old push against the new record.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 13, 2026 14:14
3 tasks done
This branch has not been deployed
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.
What
MetricsCollectorkept oneSegmentPointper data-plane request for 24 hours: a 112-byte struct plus two heap strings, pushed to aVecon every request and trimmed only by the five-minute prune. The console's Latency Breakdown and thesegmentsarray of/metricsread nothing but a per-operation count and the mean of the six latency segments over a window, so the points are now summed at record time.crates/core/src/metrics/segments.rs: each operation owns two fixed rings of 64-byte buckets, 301 one-second buckets servingLastMinuteandLast5Minutesand 1,441 one-minute buckets servingLastHour,LastDayandAllTime. A bucket holds a count and six sums. Slotkof a ring of widthbcovers elapsed seconds[k*b, (k+1)*b)since the collector's epoch and lives at positionk % len; a record into a position whose stored slot is older resets it in place.operations * 111,488bytes. The handler rejects anyX-Amz-Targetoutsideis_known_operationbefore any metric is recorded, sooperationsis at most the 37 names in the dispatch table: under 4 MiB per process, independent of request rate, table count and uptime.query_segmentsand theOperationSegmentsJSON shape are unchanged; the console needs no change. Per-operation counts are exact and the means are the same sums divided by the same counts as before (for integer microsecond inputs the f64 sums stay exact below 2^53). Operations with no request in the window are omitted, as before; results are now sorted by operation name.prune()no longer touches segments; buckets are overwritten as time advances.What changes for a reader: a window of
Wseconds returns at least the lastWseconds and at mostWplus one bucket width, soLastMinuteandLast5Minutesmay include up to 1 s beyond the window andLastHourandLastDayup to 60 s.AllTimereturns the whole 24-hour ring (before, it meant since the last five-minute prune). The console refreshes every 10 s onLast5Minutesand shows means and a count, so this is not visible there.The table name is no longer passed to
record_segments. Nothing read it (the field carried adead_codeallow and a TODO), and a per-table dimension would multiply the bound by the table count, which clients control.SuccessfulRequestLatencyin the persisted counter metrics still carries the table and operation dimensions.The issue asks for a granularity, retention, sampling or cap setting and an overflow counter. None is added: the structure is bounded by the data model rather than by a budget, so there is nothing for a setting to protect. A configuration key would also route through the RFC process and delay the fix.
Why
Closes #397. On a PostgreSQL deployment of 0.1.12 at 274 requests per second, process memory reached about 4.3 GiB in 24 hours (the reporter's measurement, 23.64 million requests) inside a 6 GiB container limit. Reproduced here on the unmodified 0.1.13 release binary: 1.2 million GetItem at 1,968 per second over 10 minutes grew anonymous memory by 193.5 bytes per request (least squares from 180 s, r² 0.998); the same build with
record_segmentsmade a no-op was flat at 0.6 bytes per request (r² 0.003), so the segment vector was the only per-request growth. 193.5 bytes times the reporter's 23.64 million requests is 4.26 GiB. PR #386 named the 24-hour segments as separate work.The vector exists at every release through 0.1.13. Until a deployment upgrades, the deployment guide now gives the growth law (about 190 bytes per data-plane request over a trailing 24 hours, the reporter's figure) and a sizing rule.
Result
Measured on this host (16 cores, PostgreSQL 15 in a container, release build, GetItem on an 8-item table at 1,984 per second for 20 minutes, 2.4 million requests, 0 errors):
The remaining movement is the one-minute counter flush: from 300 s on, anonymous memory oscillated between 88 and 103 MB with per-minute deltas alternating in sign. The
/metricssegmentscount for GetItem overLastDaywas 2,400,033 against 2,400,032 driver requests plus the one GetItem the table setup issues.Microbenchmarks, release profile, single thread, 1 million calls, three runs each: record 73 ns per call against 176 ns for the old push (the old path allocated two strings per request);
query_segments(LastDay)with 37 operations holds the read lock for 39 µs (p50, 5,000 samples), against 39.3 to 39.5 ms for the old full scan over 1 million points, during which every request's record waited. Under lock saturation the lock, not the work, is the limit for both shapes: at 8 writer threads the old push ran 2.3 to 4.5 million records per second and the new record 2.0 to 3.1 million (the hash of the operation name now runs under the lock, where the old path pushed a prebuilt point); at 16 threads both ran about 2 million. The live run above recorded 1,984 per second, three orders of magnitude below that.Testing done
crates/core/src/metrics/segments_tests.rs(new): bucket size pinned at 64 bytes; boundedness over five million synthetic requests across five operations and three simulated days asserting the bucket count equalsoperations × 1,742; a random workload of 300,000 points compared against a naive per-point reference for count (exact) and means (1e-9 relative) over four windows and four query instants; one request per second for three laps of the coarse ring checked against a closed-form arithmetic series at every ring and window boundary; window filters at second and minute boundaries; ring wrap and stale-slot exclusion; operations sorted and zero-count operations absent; an ignored timing comparison of the old push against the new record.collector_tests.rsadapted to the new signature, with the prune test rewritten to age points through the ring.boundedness_five_million_recordsandring_lap_and_stale_slotsfail; with the window filter removed from the bucket sum, six tests fail (random_workload_matches_naive_reference,window_filter_at_minute_boundary,fine_windows_cut_at_the_second,ring_lap_and_stale_slots,operations_sorted_and_zero_count_absent,prune_leaves_segments_alone_and_the_ring_ages_them_out).tests/test_metrics.py,tests/test_console_integration.py,tests/test_console_cache_coherence.pyagainst a release build of this branch: 42 passed./metricscounts forLastDay,Last5MinutesandLastMinuteconsistent with the driver count and the rate.Docs:
docs/design/06-component-server.mdsection 7.2 states the bucket widths and what each window returns;docs/manuals/11-deployment-guide.mdgains a Process Memory subsection for operators on releases through 0.1.13;docs/todo-index.mddrops the entry for the removed field; the metrics module doc notes that segments need no prune.Checklist
cargo test --workspace)cargo fmt --check)cargo clippy -- -W clippy::pedantic)Storagetrait, auth model, on-diskformat, or public CLI surface, an RFC has been accepted or is linked
below. Otherwise, an ADR captures the decision (link below).
ADR / RFC: n/a. Internal to the in-memory metrics collector; no wire, trait, auth, on-disk, config or CLI change.
Breaking changes
extenddb_core::metrics::MetricsCollector::record_segmentschanged from(&self, operation: &str, table_name: Option<&str>, segments: LatencySegments)to(&self, operation: &str, segments: &LatencySegments). The one caller in the workspace (crates/server/src/handler.rs) is updated;extenddb-coreis not published. No wire, CLI, configuration or on-disk surface changed.By submitting this pull request, I confirm that my contribution is made under
the terms of the Apache License 2.0 and I agree to the Developer Certificate of
Origin (DCO). See CONTRIBUTING.md for details.