Repository navigation
CAS GC: read-ahead only over listed ref logs; resolve mounts from listed server roots - #2542
pauloschade wants to merge 5 commits into
Conversation
…roots The GC's ref-log walks hinted the next `4 x cas_gc_io_concurrency` positions of the epoch they were in by arithmetic alone. A walk learns where an epoch ends only by decoding its seal, so every crossing requested a window of ids past the seal that never exist. The orphan sweep's protection view replays each namespace from its snapshot (or genesis) on every page, and every remount adds a seal-only epoch to every table, so the waste grew with epochs x visits instead of staying "one window per crossing". On the production GC leader: 2.4 M GETs/day answered 404 under `cas/ns/stream/*/_log/`, a median of 63.9 per epoch crossing per stream. The round already holds the exact key set: phase 4 lists `cas/ns/stream/` once (`RefScanSummary::logs_by_life`). The intake, the sweep's recovery walk and its committed-tail walk now hint only ids that listing returned; ids within an epoch are dense, so the first unlisted id ends the run. The listing decides only what is fetched early: every take, decision and counter is where it was, a key the LIST omits is read inline, and concurrency 1 hints nothing as before. `floorForNamespace` probed the mount key of every `/`-prefix of a namespace, longest first, so a table namespace cost two 404s per page before reaching its root. Phase 3 already lists `gc/server-roots/`; `HeartbeatFloor` now returns the srids it listed and the page reads only those. A mount younger than that LIST is not found and retains, as an absent mount always did. The direct decommission path holds no such LIST and keeps probing. Tests: the sweep's epoch-crossing gtest now spans seal-only epochs and asserts zero wasted hints and the inline request set; a fold-level gtest crosses six epochs through a lying LIST at concurrency 1 and 16 and compares the decisions; the floor gtests assert one read at the listed root; test_cas_gc_s3 restarts the server across epoch changes and asserts CASGCReadAheadWasted stays 0 while GC still reclaims. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gtest_cas_ref_catalog's compile pin still described the fold signature from before 9ade010, so unit_tests_dbms did not compile on the builder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tests With `hintRefLogsWithinEpoch` stopping at the first id the round's LIST did not return, no id past a seal (or past a compacted cursor) is ever hinted, so the discards at the seal crossing in the recovery walk and in the committed-tail walk (and before the missing-cursor cross) released nothing. Remove them; the tail walk's guard keeps its destructor for the budget break and the throw, and every advance simply re-arms it. Tests: restore the original name of FloorIsReadOncePerNamespacePerPage; drop the epoch-crossing loops that pinned the double walk and the past-seal zeros (wasted == 0 and inline_gets == ahead_gets prove it); drop the concurrency-1 hits assertion; in the integration test assert wasted only before the DROP (the reclaim may leave holds untaken) and fix the comment that claimed each round walks the whole history. No production behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…isted One rule instead of two modes: whoever resolves a namespace's mount floor passes the server roots it listed under `gc/server-roots/`, and only those prefixes are tried. `floorForNamespace`, `prefixEligible` and `sweepNamespace` take the set as a required argument; the nullptr "try every prefix" path and the comment admitting it are gone. A GC round passes phase 3's own LIST (`HeartbeatFloor::listed_srids`): the orphan sweep page already did, and now the fold's dead-precommit check does too. Callers outside a round (fsck, rebuild, decommission) take one `listServerRoots` pass (a thin wrapper over the existing read-only `listMounts`, no new parse) once per call, outside their per-manifest/per-prefix loops, and pass it down. The sweep test support takes its server-root listing from the same wrapper instead of a fourth copy of the `/mount` parse. Behaviour: a mount created after the caller's listing is seen only on the next listing, uniformly on every path (the sweep page already behaved so; the fold did not). This is the conservative direction -- not found means retain / keep the barrier / over-protect -- and it is only reachable after `DROP POOL MEMBER`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-Wmissing-designated-field-initializers (an error in CI) rejects the `{.server_roots = ...}` initializer 260d77d introduced in `listRoundForTest`, because `ref_logs` has no default member initializer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…through; CAS-358 genesis replay Review of Altinity#2542 on 2026-10-12. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks a lot for the early testing, the detailed report and the PR. The per-epoch numbers and the "window × epochs × visits" cost model are exactly right. This PR fixes the consequence, and we would rather remove the cause. The cause is that the orphan-manifest sweep replays each namespace from The direction we want: the sweep should not replay at all. The fold already extracts Two pieces of your PR we will keep: bounding the hint overshoot by If you want to continue, we'd be glad to have you: a call to go over your numbers and the plan, and PRs in that direction. Feedback from early adopters at your scale is what we need most right now. |
Changelog category (leave one):
Changelog entry:
CAS GC: read-ahead hints only ref-log ids that the round's own LIST returned, so a walk no longer requests the positions past every epoch seal (which never exist). The orphan sweep resolves a namespace's mount from the server roots that the round already listed, instead of probing every prefix of the namespace.
Documentation entry for user-facing changes
Problem
We run CAS in production on 26.6 Antalya: one writer, two readers, and about 800 tables. The GC leader sends about 2.4 M GETs per day that return 404 under
cas/ns/stream/<life>/_log/<epoch>-<seq>.zst. That is 58 % of the leader's GETs, and we pay for each of them in S3 requests and GuardDuty S3 events.What we measured:
4 × cas_gc_io_concurrencyat the default, which is the read-ahead window._snap.epoch_seal. Recovery closes every table's epoch on every remount, and a table smaller than the snapshot threshold never publishes a_snap, so these epochs accumulate forever. They also pile up above an existing_snap.GcReadAheaddocuments this waste as bounded: "overshooting by at most one window per epoch crossing". Per walk that is true. But the orphan sweep rebuilds each namespace's protection view from_snap, or from genesis when there is none, on every page. So the cost is window × epochs × visits, and every remount adds an epoch to every table. The cost model indocs/en/antalya/cas/architecture/garbage-collection.md("What a round costs") does not include it.There is a second, smaller cost.
floorForNamespacereads the mount key of every/-prefix of a namespace, longest first. A namespace likewriter/store/913/<uuid>@cas@costs 2 extra 404s per page (96 k per day for us).Change
The round already holds both key sets:
cas/ns/stream/once (RefScanSummary::logs_by_life, which is a hint, not a census);gc/server-roots/.The change uses them. It adds no new state, setting or format.
hintRefLogsWithinEpoch(..., listed)stops at the first id that is not in the life's listing. Within an epoch the ids are dense, so an unlisted id either lies past the seal or was written after the LIST. In the second case the walk takes it inline.RefTableListingthat itswitnessAbovealready reads.recoverRefTableDetailedFromAuthority(..., reader, listed_logs)andactiveManifestKeys.floorForNamespace,prefixEligibleandsweepNamespacetake the set of server roots the caller listed undergc/server-roots/as a required argument, and only those prefixes are tried. There is one rule and no "try every prefix" mode.HeartbeatFloor::listed_srids, the setcomputeHeartbeatFlooralready builds to pruneobs). This applies to the orphan sweep page and to the fold's dead-precommit check.listServerRootspass per call, outside their per-manifest and per-prefix loops.listServerRootsis a thin wrapper over the existing read-onlylistMountsand adds no new parse.DROP POOL MEMBER.With hinting bounded by the listing, no id past a seal is ever hinted, so the seal-crossing discards in the recovery and committed-tail walks became dead code and are removed.
The read-ahead contract is unchanged. It caches results, never decisions: every take, decode, counter, hold and event stays at its site. Concurrency 1 hints nothing. A LIST that omits a key costs one inline read, and the decision is the same.
Tests
CASOrphanSweepRequests.EpochCrossingsWasteNoHintAndReadExactlyTheInlineKeyscovers a birth epoch, four seal-only epochs and a 40-log epoch. It asserts that nothing is wasted, that nothing past any seal is read, and that the GET set, outcome and nominations are identical inline and at concurrency 16.CASGCArithmeticIntake.ReadAheadAcrossRemountEpochsWastesNothingAndDecidesAsInlineruns a full round over six crossings through a lying LIST, at concurrency 1 and 16. Decisions are identical, nothing is wasted, and the read-ahead hits.CASOrphanSweepRequests.FloorIsReadOncePerNamespacePerPage(one read instead of three; none when the mount is absent).test_cas_gc_s3::test_gc_read_ahead_wastes_nothing_across_epoch_changes, with 5 restarts (3 with inserts, 2 idle). It assertsCASGCReadAheadWasted == 0andHit > 0before the DROP, and that the dropped table is reclaimed.On our builder (aarch64, Clang 21.1.8) at the same tree on top of
v26.6.4.20001.altinityantalya, the CAS gtests passed 2527/2527 andtest_cas_gc_s3passed 2/2. This branch is the same five commits rebased ontoantalya-26.6: two comment and call-site conflicts with the laterio_pool/gc_io_concurrencyrename were resolved, and the new test usesgc_io_concurrency. It has not been compiled on this base yet, so CI will be the first build.Production result
We rolled the patch out on 2026-10-11 to all three members, at the default read-ahead concurrency (16). We measured on the GC leader, with one round every 600 s:
CASGCReadAheadWasted1); ~2.4 M per day at the defaultThe 19 in the first round came right after a leadership change, with a backlog (that round deleted 1,698).
CASGCReadAheadWastedalso counts hints that are legitimately left untaken, such as "a namespace held below its lookahead" or "a HEAD candidate that kept an edge", so we read it together with the 404 count.Separate point, not in this PR
On every page, the protection view still rebuilds a namespace from genesis when it has no
_snap. For us that explains most of the ~956 k successful reads per day. Two options are a cheaper rebuild source (for example, the fold seal'slast_folded_ref_idplus a cached view) or letting a seal count toward the snapshot threshold. We would like to discuss which direction you prefer before we write it.🤖 Generated with Claude Code