Repository navigation
libsql-server: fix unbounded memory growth from idle-evicted histogram handles - #39
Draft
raphaelfeitoza wants to merge 3 commits into
Draft
raphaelfeitoza wants to merge 3 commits into
raphaelfeitoza wants to merge 3 commits into
Conversation
…iction
The Prometheus exporter is configured with idle_timeout(MetricKindMask::ALL,
120s). Five histograms were recorded through cached once_cell::Lazy<Histogram>
handles (connection_create_time, connection_alive_duration,
total_response_size_before_lock, namespace_load_latency, replication_latency).
When a pod sees no sample for one of them between two scrapes >120s apart,
the exporter deletes the registry key; the cached handle keeps its
Arc<AtomicBucket<(f64, Instant)>> and every later record() appends a 16-byte
sample to a bucket that is never drained again. RSS then grows linearly with
the connection rate (133-187 MiB/day/pod observed; earlier pod generations
reached 47-55 GiB and were SIGKILLed).
Record every histogram through the `histogram!` macro, which re-registers the
key on each call, remove all Lazy<Histogram> statics (five of them were dead:
never forced, their names already recorded via macros elsewhere), extract the
exporter configuration into `prometheus_builder()`, and register HELP text
once after the recorder is installed. The idle timeout and mask are unchanged
so per-namespace counters/gauges and bottomless {db_name} histograms are still
pruned.
Fixes: shop/issues-retail#36220
(cherry picked from commit c044b61)
Add unit tests in metrics.rs that install the production exporter configuration (shared prometheus_builder, MetricKindMask::ALL) with a 50 ms idle timeout and: - reproduce the bug mechanism: a cached Histogram handle is orphaned once its key is idle-evicted (later samples never appear in a render), while the `histogram!` macro path re-registers the key and is drained; - check every formerly handle-recorded production histogram reappears and keeps advancing after eviction, and has a HELP line; - check idle counters/gauges are still pruned (the idle timeout keeps doing its job for per-namespace series). metrics 0.21 only supports one process-global recorder, so the tests share one and run serially: concurrent render() calls race inside the exporter and can resurrect an evicted distribution, which would make the eviction assertions flaky. Refs: shop/issues-retail#36220 (cherry picked from commit c37d4c0)
Review follow-up. For each production histogram, record a burst of 10 samples after the idle eviction and assert that an immediate render with no new samples does not count the burst again (an undrained bucket would add >= 10), instead of only checking that the count keeps increasing. Exact equality is still not asserted because other unit tests in the same process may record the same production metric concurrently. Also reword the serialisation comment so it does not imply production renders can never overlap. Refs: shop/issues-retail#36220 (cherry picked from commit ccc7db6)
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.
Summary
once_cell::Lazy<Histogram>) with literal-namehistogram!recording functions (record_connection_create_time,record_connection_alive_duration,record_total_response_size_before_lock,record_namespace_load_latency,record_replication_latency), so every sample registers against the exporter's current registry entry, including after idle eviction. Remove five unused cached histogram statics.MetricKindMask::ALL, the 120 s idle timeout, all metric names/values, andapp/versionlabel behaviour, so per-namespace labelled series are still pruned. Register the existing HELP text once after recorder installation, and document the cached-handle hazard next to the exporter config and the metric definitions.Based on the release branch
v0.9.30-shopify-patches(production image pin856b4d20is an ancestor). The three commits are clean cherry-picks (-x) of the reviewedmain-based branchfix/metrics-histogram-idle-eviction-leak, with no conflicts. The release branch's/metricsauth exemption is unchanged, and the cumulative patch is identical to the reviewed one.Root cause
The admin server sets up
metrics-exporter-prometheuswithidle_timeout(MetricKindMask::ALL, 120s)(http/admin/mod.rs#L83-L86). Hot-path histograms are cachedLazy<Histogram>statics (metrics.rs#L30,#L45,#L50) that are recorded on every connection create/drop (connection/mod.rs#L363-L364,#L392,#L414).If a pod has no connection activity for more than 120 s between scrapes, the exporter evicts those keys. However, each static handle keeps its old
Arc<AtomicBucket<(f64, Instant)>>, and the exporter only drains (clear_with) buckets for keys still in its registry. From then on, eachrecord()adds 16 B that is never freed, until restart or OOM. We observed 133–187 MiB/day/pod in EU. A previous US pod generation reached 47–55 GiB RSS against 56 GiB limits.Fixes https://github.com/shop/issues-retail/issues/36220
Test evidence (rebased branch, HEAD
2acafe46ef, Rust 1.98.1 perrust-toolchain.toml)cargo test -p libsql-server --lib metrics::tests -- --nocapture: 3 passedcargo test -p libsql-server --lib: 103 passed, 0 failed, 2 ignoredcargo fmt --check: clean.git diff --check origin/v0.9.30-shopify-patches...HEAD: clean.record_*bodies (working tree only) makesproduction_histograms_survive_idle_evictionfail withlibsql_server_connection_create_time did not reappear after idle eviction. This was reproduced on this rebased branch. During review, the same failure was shown for each of the five conversions reverted individually. A mutation that re-counts samples on an idle render trips the new drain (burst) assertion.ARwrapper to work around anar/ldproblem withlibsql-ffi'spcre2_internal.hmember. This is environmental and pre-existing, and Linux CI is not affected.libsql-server/tests) were not run locally. They setdisable_metrics = true, and CI runs them.connection::connection_core::test::test_many_concurrent, whose code is unchanged. It did not recur in any later run.Clippy disclosure (gate waived by owner)
cargo clippy -p libsql-server -- -D warningsexits 101 on this branch. The base branch fails identically. The failure comes from untouched path dependencies, and clippy aborts before it reacheslibsql-server:libsql-ffi/build.rs: 6 errors (5×needless_borrows_for_generic_args, 1×unnecessary_map_or) on both branch and bareorigin/v0.9.30-shopify-patches.--keep-going, both report the identical full set: 26 errors (6libsql-ffi/build.rs+ 20vendored/sqlite3-parser).--no-deps --all-targets: both have 265 (lib) / 306 (lib test) pre-existinglibsql-servererrors, with identical diagnostics and per-file counts, and 0 inmetrics.rs. This branch adds no new lints.No unrelated lints were fixed, no suppressions were added, and no crates or toolchain were bumped. Clippy is not part of this repo's CI.
Rollout notes (owner actions; nothing deployed by this PR)
v0.9.30-shopify-patchesafter merge.Shopify/libsql-infrastructureDockerfile and cut a new image version.Shopify/infrastructure(applications/libsql-infrastructure): staging (libsql-stg-us-ce1-jm3) first, then EU (libsql-eu-we1-yk9), then US (libsql-us-ce1-za8)._count/_sumrestart from zero. Existing macro-based histograms already behave this way.rate()/increase()treat it as a counter reset, so this is fine.Follow-ups (not in this PR)
Lazy<Counter>/Lazy<Gauge>handles still disappear from/metricspermanently after 120 s idle (e.g.libsql_server_concurrent_connections). This only affects observability and causes no memory growth. It needs a separate change.tursodatabase/libsqlmainhas the same bug, so this fix is a candidate to upstream./metricsrequests can bring back an evicted series as a frozen "zombie". The effect is bounded and is not a leak. This is a dependency issue, to look at in a future crate bump.main-based branchfix/metrics-histogram-idle-eviction-leakis kept for a later forward-port tomain.origin/mainhas moved since that review, so it will need re-checking.This change was produced with AI assistance and independently AI-reviewed. Both review findings (drain-assertion strength, comment wording) were addressed. The only remaining review blocker was the strict clippy gate, which the owner has waived as described above.