Archive ephemeral oplogs from a sweep instead of a scheduled action - #3802
Archive ephemeral oplogs from a sweep instead of a scheduled action#3802kmatasfp wants to merge 33 commits into
Conversation
f82588f to
342952c
Compare
|
Copying the agent review directly here, they seem to be valid:
|
… tick budget, listing cost
|
All five addressed in [P1] Redis duplicates satisfying the quiet gate within one pass. Correct, and worse than the write-up in one respect: the two sightings need not even be in the same tick, since a pass survives truncation, so fn assess(remembered: Option<Seen>, current: OplogIndex, pass: u64) -> Verdict {
match remembered {
Some(seen) if seen.index == current && seen.pass < pass => Verdict::Move,
_ => Verdict::Wait,
}
}No latency cost: a pass visits each key once, so the sighting a healthy agent qualifies on already came from the previous pass. [P1] Deleted components stranding oplogs permanently. [P2] Multi-SQLite rescanning every file per page. Three changes: the listing is cached per meta-namespace prefix, [P2] The archive step cap stranding the remaining layers. The bound is now derived from the stack the sweeper was built over ( One consequence worth flagging: [P2] Tick budgets being per route. |
Every agent invocation that ends in
Idle | Failed | Exitedregisters aScheduledAction::ArchiveOplogrow, and that registration is a synchronous write to remote storage on the oplog commit path, taken while holdingupdate_state_lock. A chaos run that injected 500ms of latency on the database backing the executor's scheduler storage drove ephemeral p50 from 180ms to 31s. Invocation throughput pins atpool_size / write_latencyand no amount of executor concurrency moves it. The same fault against this branch leaves ephemeral at its baseline 170ms; see Verified on a cluster.The row exists to answer one question: which agents have oplog entries sitting in the wrong layer. That answer is already in the storage, so this replaces the row with a scan.
What this does
Stops ephemeral agents registering the action.
schedule_oplog_archive_if_neededreturns early forAgentMode::Ephemeral. The write leaves the invocation path and the scheduler table stops accumulating a row per ephemeral invocation. Durable agents are untouched and still register as before.Adds an on-executor sweep that covers what the guard stops doing. Without the registration, an ephemeral oplog is drained only by
archive_ephemeral_oplogon teardown, and that is a detachedtokio::spawnwhose result is discarded and which is not in the executor's join set. A pod that crashes, shuts down, or simply loses that task strands the entries with nothing scheduled to move them.OplogSweeperfinds them by paginating the oplog layer itself.Adds
IndexedStorage::scan_stable. A walk that survives the caller deleting what it walks, implemented across all five backends. See below for whyscancould not be used.The module is a work list, not a mover
MultiLayerOplog::archivealready moves a prefix down one layer, handles the primary hop and the lower hops, dispatches on agent mode, and reports whether a layer below still holds entries.sweep_onceis the body of the existingArchiveOploghandler with the row replaced by a scan; it still callsopen_oplogandtry_archive_blocking, so the archiving semantics are the ones already in production.That includes the exclusion.
open_oplogbuilds a suspendedWorker, which is what the scheduled action relies on to avoid racing a live writer, and the arithmetic favours keeping it: the scheduled action paid one construction per registered row, and for ephemeral agents every row passed its index gate, so one per invocation. The sweep pays one per agent that genuinely has a tail to move, which is rare because the size trigger atentry_count_limitalready handles busy agents.One pass is
sweep_once(&self, shutdown: &CancellationToken) -> SweepReport: deterministic, infallible, and the whole test surface.Routes are derived from the layer stack rather than hand-assembled. One default method was added to
OplogArchiveService:The compressed archive answers with its level, the blob archive keeps the
None. A layer that cannot enumerate its own keys can receive an archive step but never be the source of one, so the bottom of any stack is a target only.Everything that decides is a private free function with no
self, no async and no storage:parse_agent_id,owns,assess,triage,tally,merge. The driver that talks to storage is thin, and ten of the module's thirty-three tests need no runtime at all.A tick has two phases, and they must not interleave
The scan phase walks the namespace, decides every key it sees, and touches nothing. Only when the walk has finished does the archive phase run. That ordering is forced: an archive step ends in a
drop_prefixthat removes the key the scan was walking, so archiving mid-walk would delete keys out from under the cursor.Deciding is cheapest first. Parsing the key and testing the shard need only the assignment already in memory. Residency comes next, also in memory, because on a busy executor most scanned keys belong to agents this pod is running and probing their index would be one storage read each per tick to learn what
ActiveWorkersanswers for free. Only what survives that gets a storage read, and the component lookup that resolves the target environment happens only for an agent about to be archived.An agent is archived when its last oplog index is unchanged between the two scans that saw it. Since a scan resumes where it stopped, a key is visited once per pass rather than once per tick, so the gate is one interval only while the namespace fits inside one tick's budget, and one full pass over it otherwise.
Scanning a layer you are deleting from
IndexedStorage::scancannot page this walk. Its cursor is a position on every backend that has one, anOFFSETin Postgres and SQLite and an iteration count in memory, so deleting a key behind the cursor shifts everything after it down and the next page steps over exactly that many keys nothing has looked at.scan_stableresumes by seeking instead:Ordered backends return the last key they handed back. The multi-file SQLite backend returns the file it finished, because its keys are ordered only within a file. Redis returns its own
SCANcursor, which already tolerates deletion. The contract is that a key present for the whole walk is handed back at least once, and a key the caller deletes may or may not be.Resume tokens live in an in-process map and are never persisted, so a token cannot outlive the backend that minted it.
What a tick costs
page_sizemax_concurrencymax_archives_per_tickmax_scanned_per_tickmax_tracked_agentsmax_tick_durationmax_backoff_intervalsA tick that hits a bound keeps its resume token and picks up from the same place next time, so work is deferred rather than dropped.
Ephemeral agent ids are unbounded, since an invocation with no phantom id gets a fresh
Uuid::new_v4(), and an agent drained by its own teardown never appears under that id again. Tracking entries are therefore stamped with the scan pass that touched them and dropped when a pass completes without seeing them, somax_tracked_agentsis a backstop rather than the mechanism.Three metrics:
oplog_sweep_outcome_totalby route and outcome,oplog_sweep_tick_seconds, andoplog_sweep_truncated_total. In steady state the sweep does nothing: it scans the in-flight set, decides every one of themResidentin memory, and archives none of them.oplog_sweep_outcome_total{outcome="archived"}climbing on a healthy cluster is the signal that teardown drains are being lost.The sweep stands down when the store is slow
The count budgets say how much a tick does. They do not say how long it holds anything, and under a degraded store those stop being the same quantity.
PostgresIndexedStorageputs one semaphore in front of its pool,max_concurrent_ops, and every caller shares it: the primary oplog on the durable commit path, the compressed archive an ephemeral agent writes through, and this sweep. golem-dev sets it to 64 against a 96-connection pool, so the gate binds before the pool does. A tick sized in operations is brief when an operation is milliseconds and runs for minutes when an operation is seconds. Without a time bound the sweep therefore stops being a periodic user of that semaphore and becomes a near-continuous one, at exactly the moment the invocation path can least afford it.This is measured, not reasoned about. Chaos scenario S23 adds 500ms of one-way latency to the indexed-oplog cluster. It was run twice against the same image, the same driver commit and the same manifest, with one config flag between them:
promiseduring the faultTwo caveats a reader should have. Each configuration was run once, so 2 aborts against 7 is directional rather than statistically settled. And the durable and scheduled latency percentiles look better in the sweep-on run purely because fewer operations survived to be measured, so those columns are not comparable between the two runs and are left out above.
Nothing was lost in either run — exactly-once held across 14k keys both times. The failure mode is availability, not correctness, and the double fires are downstream of the aborts rather than an independent defect.
So a tick now carries a deadline as well as its budgets, and the loop backs off while that deadline keeps being hit, doubling the wait to a cap and resetting on the first tick that finishes inside it. The sweep has no other way to know what the storage layer is doing; this is the one signal it can read about itself.
The deadline is enforced by cancelling the tick's own token, a child of the shutdown token. That is deliberate reuse rather than a new mechanism: every boundary in
sweep_oncethat already tests for shutdown becomes a place the tick can stop for time too, each already marks the report truncated, and the invariant shutdown is written around — an archive step is never cut between its append below and its drop above — is inherited for free.Deferring archiving costs nothing durable. The work list is the layer itself, so a skipped tick leaves the same agents to be found later; only the latency of moving a stranded oplog grows, against a default
archive_intervalof a day.Both numbers are untuned and labelled as such in the config docs. 30s is half the default interval, so the duty cycle stays under half even when every operation is slow, before the backoff takes it lower.
Shutdown and failure
runis spawned into the executor'sJoinSet, which the server binaries drain withjoin_next().await. Cancellation is observed between routes, between scan pages, before each component the archive phase has to resolve, and before each agent it reaches, but never inside one. So a shutdown never interrupts an archive step between its append to the layer below and its drop from the layer above; an agent already under way is finished, all of its layers, before the loop returns.A sweep holding no shard assignment, or one whose shard count is still zero, does nothing and reports itself unassigned rather than routing an agent through a zero divisor.
A non-transient indexed-storage error inside a tick panics through
retry_storage_op, and this workspace builds withpanic = "abort", so it takes the process down. That is how every other oplog operation already behaves; the sweep is another caller of the same code, not a new failure mode.Which agent modes are swept is not configurable
Ephemeral is not optional, because the guard took the per-invocation registration off the commit path and this sweep is now the only thing that moves an ephemeral oplog a crashed pod stranded.
Durable is absent rather than off, and there is deliberately no setting that turns it on.
IndexedStorage::appendis a plainINSERTagainst a table withPRIMARY KEY (namespace, key, id), a unique violation is not classified transient, andretry_storage_opturns that into a panic underpanic = "abort". Re-appending a prefix into an indexed layer therefore takes the pod down, and an archive step interrupted between its append and itsdrop_prefixleaves exactly that prefix to be re-appended. Blob targets are safe, since their append is aputat a path keyed by the chunk's last index and a repeat rewrites identical bytes. With the default stack the ephemeral hop targets blob and the durable primary hop targets the indexed compressed layer.There is a second gate on the same thing.
lib.rshandsover_layersthe archive stack alone, and the primary oplog service is not anOplogArchiveService, so level 0 is never a route source. A durable mode added to the list would sweep the compressed hops and leave the level-0 hop withScheduledAction::ArchiveOplog.Covering durable therefore needs the append to become an upsert and a layer passed in that can enumerate the primary's keys. Both are code changes, which is the point of keeping the list out of
OplogSweepConfig. A test asserts no route the sweeper builds names a mode other than ephemeral.The durable per-invocation registration stays exactly as it is.
enabledremains, as the operational lever that stops the whole sweep without a redeploy. It is read in two places, because the sweep and the registration it replaces are one switch: it stops the background loop, and it stops the guard inschedule_oplog_archive_if_neededfrom suppressing the ephemeral registration. So off is the behaviour that preceded this PR rather than a third state in which an ephemeral oplog has no mechanism behind it at all. An integration case asserts the rows come back.Not fixed here
Moving a prefix reads the whole source layer into one
Vec, written out twice, inEphemeralOplog::background_transferand inBackgroundTransfer::run. The sweep calls whichever applies rather than adding a third copy, so an agent costs it exactly whatarchive_ephemeral_oplogalready costs on teardown. What the sweep adds is a ceiling on how many run at once. Bounding the read itself means chunking both copies, or unifying them first, and belongs in its own change.Archiving an agent builds a suspended
Workerthat nothing evicts, becausestop_if_evictablematches only a running instance. Each archived agent therefore keeps anActiveWorkersentry for the life of the pod. It is inherited fromScheduledAction::ArchiveOplog, which built the same worker far more often, and it is why the sweep drains an agent fully in one visit rather than leaving a hop for a later tick.No single hop knows that an agent's whole stack has drained, so
WorkerService::remove_cached_status, which the scheduled action calls whenmore == false, has no equivalent here. Ephemeral agents write no cached status, so this only matters once durable routes are enabled, and the code says so at the call site.Testing
The guard is proven red then green by counting rows in the executor's own scheduler storage: 30 ephemeral invocations leave 31 rows on unmodified
1.5.xand 0 with the guard. A durable agent in the same component is the control, so an empty table cannot mean the guard stopped scheduling for every mode. A third case runs the same workload withenabled = falseand asserts the rows return.The sweep carries 37 tests at the module's interface, with no executor process. The pure functions are tested directly; above them, tests build the real layer stack over in-memory indexed and blob storage and assert on what moved between layers rather than on what the report claimed. Notable cases: an agent is archived only after its index survives a scan; a running agent and an agent on another shard are left alone; a completed pass drops the tracking entries for agents that left the layer; every budget truncates and resumes; a zero-shard assignment archives nothing; no route is ever built for a durable layer; cancelling the token stops the loop after it has archived. The backoff is a pure function tested directly, including its cap, its reset, and a zero cap read as one so the wait can never multiply to nothing and spin the loop.
scan_stableis pinned by a cross-backend test that deletes keys behind the cursor mid-walk and asserts every surviving key is still handed back, run against every indexed-storage backend, plus a test that the multi-file SQLite walk crosses its files a page at a time. The in-memory page selection was checked against the naive implementation over 200000 randomised single-page cases and 20000 full multi-page walks.Both pre-existing archive tests in
scalability.rsstill pass. Full worker-executor unit suite is 568 passing,cargo fmtandcargo clippy --all-targets -- -D warningsare clean, and the generated config files are regenerated.Verified on a cluster
The branch was deployed as a pre-release build and the two scenarios that found the problem were rerun against it, each on a freshly reset cluster. Same 500ms injected latency, same workload, same duration; only the platform build differs.
Ephemeral alone. This is the control that originally established the cause, by showing ephemeral slowed down with no other stream running to blame.
The delay is now invisible to ephemeral: p50 during the fault equals baseline, p90 is 200ms against 201ms, and throughput holds full rate instead of collapsing to 45%. The scenario's
unexpected-slowdown: ephemeralfinding cleared, leaving no findings at all.All four streams. The full scenario runs durable, ephemeral, promise and scheduled together.
Throughput during the fault rose on every stream. That every stream improved, and not only the one this change touches, follows from the fault being queueing on a connection pool the four share: ephemeral ran the highest invocation rate and so was the largest single consumer of that pool, and removing its writes frees capacity for everything else on it.
Ephemeral's remaining 74x here is queueing behind the three streams that still register the action, not ephemeral reaching that storage. Running it alone gives 1.00x, which is what separates the two. Closing that gap means the durable write under Not fixed here.
Correctness was unaffected in both runs: every key exactly once with no findings, read-back consistent, every scheduled action fired exactly once, no executor restarts and no out-of-memory kills. One incidental improvement, scheduled registrations that were indeterminate but fired went from 104 to 0.