fix(wintermute): bound backfill shutdown by a grace budget and claim resync rows first - #273
Conversation
Hybrid-mode backfill could not stop within systemd's 60 s: fetch workers finished in-flight fetches, the sink drained every in-flight record through Postgres with no deadline, and the listRepos backoff slept up to 300 s without observing SHUTDOWN. Add BACKFILL_SHUTDOWN_GRACE_SECS (default 20) as one budget for the whole stop: long sleeps become shutdown-aware, workers still in flight past the budget are aborted, and the sink drain is bounded by what is left, abandoning uncommitted records to the claimed-row recovery on restart. Resync requests from the live sync 1.1 path were claimed in cooldown order alongside millions of enumerated repos. Add a repo.priority column (set by request_resync, cleared on completion) with a partial index, claim priority rows first, and upgrade an existing state file in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
finish_sink timed out `&mut finish` and left the future alive, still holding PgSink's writers mutex from its first poll whenever a writer was mid-batch; abandon() then waited on that lock forever with all runtime workers parked, and systemd SIGKILLed the daemon at 60 s. Own the finish future in a block so it is dropped before abandon runs. Bound the whole stop path from the top as well: BackfillManager::run races the manager future against SHUTDOWN + grace + 5 s slack and logs which phase was pending, then tears the runtime down with shutdown_timeout(2 s). abandon() bounds its lock and each aborted writer at 1 s and counts writers that did not settle. The end-of-drain progress log no longer samples the state gauges (a COUNT ... GROUP BY over the whole repo table) while shutting down. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up: the stop path still hung after the sink was abandonedgdb on the box showed the backfill thread still inside The blocker, found by reading the code: Changes
🤖 Generated with Claude Code |
Two fixes to
rsky-wintermute's backfill runner from tonight's soak test on the appview box.Problem A: hybrid mode cannot stop within systemd's 60 s
With
BACKFILL_MODE=hybridthe daemon was SIGKILLed on every stop (three of three cycles); with the mode off it stopped in 16 s. Thread probes and logs showed three unbounded waits in the stop path:fetch worker stopped(up to ~35 s after SIGTERM);BackfillManager::runthen awaitedsink.finish(), which drained every in-flight record (up toBACKFILL_MAX_RECORDS_IN_FLIGHT) through Postgres with no deadline;enumerate_pages's transient backoff (sleep_secs(delay), up to the 300 sRetry-Aftercap) did not observeSHUTDOWNat all.Claimed rows are already returned to pending on restart (
reset_claimed, covered byclaimed_rows_are_recovered_after_a_crash), so abandoning in-flight work at shutdown is safe.Design
BACKFILL_SHUTDOWN_GRACE_SECS(default 20), read inBackfillConfig::from_env, threaded intoRunnerConfig::shutdown_grace, documented in the README env table. It is one budget for the whole stop, not per stage: the coordinator records when it first saw the flag, spends what it needs on the workers, and hands the remainder to the sink drain.runner::sleep_or_shutdown(dur, flag)polls the flag every 250 ms and returns early. Every wait that can run long goes through it: thelistReposretry backoff, the hubble cooldown, the enumerator's 60 s error backoff, the re-enumeration interval, and the coordinator poll. A backoff cut short by a stop returns fromenumerate_pagesas an incomplete pass (cursor persisted) rather than a host failure, so the host is not put on a 300 s cooldown for being stopped.drainawaits the fetch-workerJoinSetundertokio::time::timeout(remaining grace); workers that finish in time settle normally (their repos complete). Anything still in flight past the budget is aborted (JoinSet::shutdown) with awarnnaming the sources. An aborted fetch never reached the sink, so it leaves no receipt and no completion task: the row staysclaimedandreset_claimedrecovers it on the next start. The enumerator task is aborted as before.RecordSinkgainsfinish(&self)/uncommitted()/abandon()(defaults are no-ops;NullSinkuses them).PgSink::finishnow takes&self(the intake sender lives in aMutex<Option<_>>and is taken to close the channel), which also removes theArc::into_inner(sink)dance that silently skipped the drain whenever a completion task still held the runner.backfiller::finish_sinkawaitsfinishunbounded until a stop is requested, then undertimeout(grace); on timeout it logs atwarnhow many repos/records are abandoned and that they will be re-fetched from their claimed state on restart, thenabandon()aborts the writers.run_and_finishties the runner and the sink drain together and is used by the manager and thebackfillCLI.committedreceipt is a oneshot that resolves exactly once:Ok(complete),Err(fail with cooldown), or dropped when a writer is aborted (fail(..., "sink dropped")returns it to pending). Fetch tasks aborted before ingest leave the row claimed.ingestalso rolls its in-flight counters back if the send is rejected by a closed channel.RunnerConfig::shutdown: &'static AtomicBool(defaultcrate::SHUTDOWN), so tests inject their own flag instead of racing each other on the global.Problem B: resync requests queued behind millions of enumerated repos
RepoStateStore::claim_for_sourceordered bycooldown_untilonly, so live sync 1.1 resync requests (request_resync,last_error = 'resync: ...') competed equally with ordinary pending rows. In the soak, three resync rows sat pending/claimed for 24 minutes with zero completions while 7.8M enumerated repos were pending.Design
repo.priority INTEGER NOT NULL DEFAULT 0column.request_resyncsets 1;complete(andmark_dry_run) clear it to 0 alongsidelast_error = NULL. A transient failure keeps it, so the retry is still prioritised.repo_priority_claimable ON repo (source, state, cooldown_until) WHERE priority > 0keeps the priority probe a lookup in a tiny index.claim_for_sourceclaims from it first (ORDER BY cooldown_until, rowid), then fills the remainder from the existingrepo_claimableindex withpriority = 0, all in one transaction. Net order: priority desc,cooldown_until, rowid.EXPLAIN QUERY PLANconfirms both selects andhas_claimablestay on their indexes.RepoStateStore::init:PRAGMA table_info(repo)is checked, the column is added withALTER TABLE ... ADD COLUMNif missing, and waiting resync rows (last_error LIKE 'resync:%', state pending/claimed) are promoted to priority 1 so the box's existingstate.sqlitefixes its three stuck rows on the first start. The partial index is created after the migration so an old file does not hit an unknown column.Tests
New: shutdown-aware sleep returns promptly when the flag flips (before, at once, and midway); a 300 s
Retry-Afterbackoff ends at shutdown without cooling the host; a fetch that finishes within the grace budget is awaited and completes; a fetch still in flight past the budget is aborted with the row left claimed andreset_claimedrecovering it, then a hung sink is abandoned at once because the shared budget is spent;drainstops within the budget on shutdown;finish_sinkabandons a never-resolving sink after the grace budget (and counts the budget from a stop that arrives midway);run_and_finishreturns within the budget against a sink whosefinishnever resolves; a resync row is claimed before older pending rows for the same source (and a priority row under cooldown still waits); priority clears on completion; a pre-priority state file is upgraded in place with waiting resyncs promoted.cargo fmt --all -- --checkclean;cargo clippy -p rsky-wintermute --all-targetsreports zero diagnostics for the crate. The backfiller tests were run five more times with no flakes.Not verified here: the stop time on the box itself under real Postgres load; the design bounds the stop at
BACKFILL_SHUTDOWN_GRACE_SECSplus the ~250 ms flag-poll granularity, so the default 20 s stays well inside systemd's 60 s.Commits are unsigned (
commit.gpgsign=false).🤖 Generated with Claude Code