Make SQLite durable under concurrent readers and stop re-delivering stranded webhooks (closes #256) #263
Reference in New Issue
Block a user
Delete Branch "issue-256-sqlite-durability"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #256.
Reproduced first, on unmodified
next6 active
httptargets, 60 events at 5/s, sinks holding each POST 1.5 s so deliveries are still in flight, and asqlite3 <db> .dumploop started 4 s in:delivery_resultsrowspragma journal_modedeletedeletewalwalThe baseline reader arm is the reported defect end to end: inbound rejections, 112 duplicate POSTs confirmed by payload at the sink, and an event log claiming 132 attempts for 244 actual deliveries. (The baseline no-reader arm's 160 is my harness not draining before the restart, not the bug; both fixed arms drain and add nothing.)
Durability,
internal/database/sqlite_open.goEvery SQLite file — main, per-webhook, archive — now opens through one function. Nothing else builds a DSN.
busy_timeout= 10 s. Under WAL a reader never blocks a writer, so the only conflict left is writer-versus-writer: this process's 10 delivery workers against each other, or against another process. Those clear in milliseconds. Ten seconds is far above that and well inside the receiver's request budget, so an inbound webhook waits rather than being rejected with a 500.Pool bound = 4 open, 2 idle, 5 min lifetime, 1 min idle time. The bound exists because
database/sqlcannot detect a poisoned connection:modernc.org/sqliteimplements neitherdriver.Validatornordriver.SessionResetter, so a connection whoseCOMMITfailed goes back into the pool with its transaction still open and is handed out forever after. That is how 4database is lockedbecame 593cannot start a transaction within a transaction. Four is above the one writer SQLite allows at a time, so reads proceed while a write is in flight, and low enough that contention resolves through the busy handler rather than by piling connections against a lock only one can hold. The lifetime bounds the damage if a connection is poisoned anyway._txlock=immediate. This carries as much of the fix as the busy timeout and was not in the issue's DoD. A deferred transaction takes the write lock lazily on its first write, and that upgrade returnsSQLITE_BUSYwithout consulting the busy handler — SQLite cannot block a transaction that may already hold a read snapshot. SoCOMMITfails while the transaction stays open on the connection: the poisoning mechanism itself.BEGIN IMMEDIATEmoves the wait to where the handler applies.Pragma order.
busy_timeoutis set beforejournal_mode. The driver executes_pragmaparameters in order on every new connection, andPRAGMA journal_modetakes a lock; the pool opens connections lazily, so it does that precisely when the database is under load. The reverse order leaves the one pragma that can block uncovered by the handler meant to cover it, and produces exactly theSQLITE_BUSYatelapsed_ms: 0that review observed.cache=sharedremoved, as the issue requires: under a shared cache an in-process conflict isSQLITE_LOCKED, which the busy handler does not retry.Confirmed on live handles rather than by inspecting the DSN — all three tiers assert
pragma journal_mode(wal) andpragma busy_timeout(10000) on a running database. The three test helpers that opened their owncache=sharedhandles now go throughOpenSQLitetoo, so no test can pass against settings production does not use.Eligibility,
internal/delivery/inflight.goWhat makes a delivery eligible for re-dispatch, and what makes that decision exclusive — one mechanism, because three independent guards on one transition is how the next duplicate gets in.
inflightSetis a reference-counted set of the delivery ids the engine owns. A reference is taken when a task is queued (Notify), when a target schedules a retry (ScheduleRetry, so ownership spans the whole backoff window rather than lapsing while the row sits atretryingwith nothing running), and by every recovery path before it re-dispatches. It is dropped when the worker that ran the task returns.Nothing decides eligibility from a row's age. A delivery's row says
pendingfrom creation until its outcome is written, which covers four different situations — never dispatched, waiting in a channel, being attempted right now, genuinely stranded — and no column separates them.deliveryChannelSizeis 10000 against 10 workers, so a perfectly healthy delivery can wait far longer than any workable age bound before its attempt even begins; reasoning from age re-sends it. In-memory state is authoritative becauseinternal/datadiradmits one process perDATA_DIR; deliveries owned by a process that died are in no successor's set, and restart recovery is what picks those up.takeForRedispatchis the single gate every re-dispatch goes through, and asks two different questions in order: does the engine already own this delivery (ownership, which is what excludes), and is the row still in the status the batch read (a conditional update, which is what catches a stale batch). Stampingupdated_atis part of that same statement and is re-dispatch cadence, not a claim: without it a delivery the database will not let the engine settle would be re-sent on every 60-second tick.reconcileDeliveredandfailUnretryableRetrytake ownership too, so every recovery-side write passes the gate.pendingSweepMinAgeis 15 minutes — clear ofMaxTargetTimeoutSeconds(300 s) by 3x, so the two guards do not both have to be right.Bookkeeping,
internal/delivery/engine.goWhat status when the send succeeded but the result write failed. The delivery keeps the non-terminal status it already holds —
pending, orretryingfor a retry attempt — and nothing is written at all. Both are retryable perDeliveryStatus.Terminal(), and both are now swept. Writing a status in the failure path would need the very database that just refused a write and would be one more thing to fail; not writing cannot fail.recordResultandupdateDeliveryStatusreturn their errors and every call site stops advancing the status.That leaves the honest at-least-once case — a send that reached the receiver whose result row did not land is attempted again, and recorded as the further attempt it is. What no longer happens is the silent duplicate the event log denies.
Recovery distinguishes the two histories. A delivery holding a successful
DeliveryResultis markeddeliveredinstead of re-sent. The result row is written before the status, so its presence means the wire I/O happened and was recorded and only the status is missing — the state the issue notes did not exist. This runs on every recovery path, pending and retrying alike: a second attempt that reached the receiver and whose status write then failed sits atretryingholding a successful result, and re-sending it is the same duplicate.Attempt numbering continues. Recovery re-enqueued at
attemptNum = 1unconditionally; it now uses the delivery's real attempt count, so numbers do not collide in the event log and the retry path computes backoff from the right attempt.Pending arm on the sweep, capped at 500 per webhook per sweep, running the same reconcile-then-gate-then-dispatch path, so a stranded delivery no longer waits for a restart. Event bodies are read after the gate, one delivery at a time, so a batch that is mostly refused never materialises.
Documentation
WAL creates
-wal/-shmsidecars, andREADME.mdpreviously stated flatly that none are produced. Everything below was measured against a live instance rather than reasoned about:sqlite3 .backup— 40/40 rows, single consistent file, no sidecars of its own. Procedure unchanged.cp -a, start — 40/40 rows, restored into a fresh directory, started, read back. Procedure unchanged; the reasoning under it is not. A clean stop closeswebhooker.dband theevents-*.dband checkpoints their sidecars away, but not the archive files: their handle is never closed at shutdown, so after a clean stoparchive-….dbwas 4096 bytes with no schema while its-walheld all 8 archived events. CopyingDATA_DIRin full is what makes that a non-issue..dbalone — fails loudly (no such table), because a young database's schema is still in the WAL. The existing "a hot copy is not safe" warning is still true and now louder.SIGKILL— the.dbalone reads 40 rows where.db+-walreads 50. The restore instructions were inverted: they said to drop journal files, and now say to carry any-wal/-shma salvaged copy contains..dbwas 4096 bytes and all ten rows sat in a 189 KB-wal. It self-contains on the next write past the debounce, when the pool retires the idle connection about a minute later, or at the idle sweep.Two code comments that reasoned from "these files are not WAL" are corrected.
Verification
make checkgreen withGOFLAGS=-count=1, 0(cached)packages, lint executed uncached in the digest-pinned container, after rebasing onto currentnext.delivery_results. The same harness on the pre-rework commit re-dispatched 1510 of them across four sweeps.TestFailedResultWriteLeavesDeliveryRecoverabledropsdelivery_resultsso the send succeeds and only the bookkeeping fails, and asserts the POST happened and the row stayedpending.TestConcurrentReaderDoesNotBlockWrites— a second handle holding a read transaction open across 25 writes.Not in this PR
-walsurvives a clean stop. Documented here; closing it would make the move-the-file-away workflow single-file again, which is a behavioural change to the archive lifecycle rather than part of this fix.clawbot referenced this pull request2026-08-24 02:10:19 +02:00
FAIL —
needs-rework.Reproduced the defect first on unmodified
next(fd5966f) with my own harness — 6httptargets, 60 events at 5/s, sinks holding each POST 1.5 s, asqlite3 <db> .dumploop from t+4 s. All sink counts are by payload at the receiver.delivery_resultsvs POSTspragma journal_modedeletedeletewalwalThe baseline reader arm also left the file answering
database is lockedto an external reader after all load and readers stopped. The fixed reader arm's reader is positively evidenced, not assumed: 243 completed.dumpruns growing 180 → 800 lines, zero reader errors.Everything else checked passes:
make checkgreen from a clean clone withGOFLAGS=-count=1(1m16s, 20 packages, 0(cached); lint executed uncached in the pinned container, 51.5 s, 0 issues); CI success on027f089; fast-forward ontonext; commit message and trailers clean; upgrade of a rollback-journalDATA_DIR(50 events →wal, nothing lost, new events accepted); both documented backup procedures end to end under WAL (.backup50/50, stop-cp -a-start 40/40, each restored into a fresh directory, started, and accepted new events); afterSIGKILLthe.dbalone has no schema at all where.db+-walreads 50, so the corrected restore instruction is right and understated; archive tier confirmedwalon the live file.No throughput regression from
_txlock=immediate— the opposite. DB-bound, no reader, 300 events x 6 fast targets: inbound 126/s → 857/s, sink drain 162/s → 253/s, 1800/1800 delivered both sides, 0 write errors, no convoy or deadlock. The pool bound of 4 is right for 10 workers: under WAL the binding constraint is SQLite's single-writer lock, not the pool, and 10 workers against an 857/s request path produced no contention failure.Four defects.
1.
internal/delivery/engine.go:527,:578,:821— theretryingarm re-sends a delivery that already holds a successfulDeliveryResult.reconcileDeliveredis wired intorecoverPendingBatchonly;recoverRetryingDeliveriesandsweepSingleRetryhave no equivalent check. This change creates that state deliberately:bookkeepingFailed(:1153) leaves the delivery in "whichever non-terminal status it already held —pending, orretryingfor a retry attempt". So a retry attempt (N ≥ 2) that reaches the receiver, whoserecordResultlands and whosesettleStatus(Delivered)write then fails, sits atretryingholdingsuccess = true, and the next sweep re-POSTs it. Confirmed with a probe test: a delivery atretryingwith attempt 1success=falseand attempt 2success=trueis re-dispatched as attempt 3. The DoD is unqualified by status — "Recovery must not re-send a delivery that already has a successfulDeliveryResult". Acceptable: run the same reconcile on theretryingrecovery and sweep paths, settling todeliveredrather than rescheduling.2.
internal/delivery/engine.go:1334—claimPendingis not a claim, and restart recovery races the sweep.UPDATE deliveries SET updated_at = ? WHERE id = ? AND status = 'pending'is one atomic statement, but it does not modify the column it tests, so the predicate is never invalidated: measured, three successive claims of the same row each returnRowsAffected = 1. It does correctly lose to a delivery another worker settled, which is what the doc comment claims — but it gives no exclusion between two simultaneous claimants, and there are two.Engine.start()launchesgo e.recoverPending(ctx)(:309) andgo e.retrySweep(ctx)(:313) concurrently, andrecoverPendingDeliveries(:622) has no age bound and no batch limit, so everypendingrow older thanpendingSweepMinAgesits in both batches. Driving both entry points concurrently on an aged pending row double-dispatched in 40 of 40 iterations, withsendRecoveredDeliverieshanding both tasks the sameattemptNum. In production this needs recovery still running when the sweep's first tick lands at t+60 s — a large stranded backlog, which is exactly the post-wedge condition #256 describes ("recovery re-enqueued the strandedpendingrows across 10 workers and re-wedged the same file"). A race rather than a certainty, but the outcome is the duplicate POST this PR exists to remove. Acceptable: make the claim exclusive — CASstatusto a distinct claimed value, orWHERE status = 'pending' AND updated_at = <the value that was read>.3.
internal/delivery/engine.go:52— the new pending sweep re-sends deliveries that are merely QUEUED, and I measured it duplicating on a perfectly healthy database.updated_atis stamped at row creation and is never refreshed when a worker dequeues the task, so the age bound measures row age rather than attempt age — anddeliveryChannelSize = 10000(:27) against 10 workers means a delivery can wait in the channel far longer thanpendingSweepMinAgebefore its attempt even begins.Measured on this branch, no concurrent reader, nothing wrong with the database: 6
httptargets, 400 events accepted in 0.46 s (400/400200), 2400 deliveries, sinks holding 1.5 s so the queue drains at ~6.7/s. At t+360 s one sweep fired —retry sweep: recovering stranded pending deliveries ... count=20— against 20 deliveries that were still sitting indeliveryCh. Result at the receiver:delivery_resultsrows for 2400 deliveriesThat is the defect #256 exists to remove, reproduced by the fix's own new sweep arm, with no wedge and no write failure anywhere in the run. The same test scaled to 900 events / 5400 deliveries is worse: its sweeps hit the
pendingSweepBatchcap,count=500re-dispatched on each of the two sweeps observed, and it was still draining when this was posted — so 500 duplicate POSTs per 60-second sweep for as long as a backlog outlives the age bound.Separately,
pendingSweepMinAge = 5 * time.MinuteandMaxTargetTimeoutSeconds = 300(internal/delivery/target_headers.go:17) are the same 300 seconds, so a target at the maximum timeout the UI accepts has a legitimate in-flight attempt that reaches the bound with zero margin. A direct test of that case did not produce a duplicate — the attempt's own timeout fired marginally first and settled the row tofailed— but a coincidence is not a margin. The comment justifying the bound reasons fromhttpClientTimeout(30 s) and never mentions the 300 s per-target ceiling the same repo permits.Acceptable: stamp
updated_atwhen a worker dequeues the task, so the bound measures attempt age rather than row age, and set the bound aboveMaxTargetTimeoutSecondsby a real margin.4.
README.md— "if a-walis there, a write is in flight" is not true for archive files. The archive close/reopen is debounced and happens on the next write after the window elapses (internal/delivery/target_database_archive.go:253), so after the last write of a burst no checkpoint happens and the handle stays open until the idle sweep (RETENTION_SWEEP_INTERVAL, default1h) or shutdown. Measured 45 s after the last of 10 events, fully idle:archive-….db4096 bytes (header only),-wal193672 bytes holding the schema and all 10 rows. An operator following the paragraph's main claim — "what is left to move is a single self-contained.db" — moves a file that opens withno such table: archived_events. It fails loudly and the caveat clause gives the right action, so this is not silent data loss, but the paragraph this PR rewrote states the wrong normal case and the wrong reason.Non-blocking notes.
sweepWebhookPendingPreload("Event")s up to 500 rows to build tasks that discard bodies ≥ 16 KB — up to ~500 MB transient per webhook per 60 s sweep at the 1 MB ingest cap (recoverPendingDeliveriesdoes the same with no limit; pre-existing, now on a timer).reconcileDeliveredsettles throughsettleStatus(..., targetMap[...].Type, ...), labelling the metric with an empty target type when the target no longer exists. Mutation-checked the new tests:TestSweepClaimsAStrandedDeliveryOnlyOnceandTestFailedResultWriteLeavesDeliveryRecoverablego red when the corresponding fix is removed, but disabling thesettled-map skip insendRecoveredDeliveriesleaves every test green — the re-send is actually prevented byclaimPendingfailing on the already-settled row, so thesettledmap is belt-and-braces and unpinned. The archive tier's WAL is asserted by shared code path plus my own live check, not by a live-handle test like the other two tiers.gomodguarddeprecation ignored as tracked; #262 out of scope.Two disclosures. The targeted probe and mutation tests above were run with
go test -run …in a throwaway copy of the tree rather than throughmake test, becausescript/testtakes no filter and the mutations must not touch the reviewed tree;make checkon the reviewed tree was run throughmakeas required, and the reviewed clone is unmodified. And in one run of eight (the 2400-delivery one) six read-onlySELECTs against the main database returnedSQLITE_BUSYwithelapsed_ms: 0— the busy handler was not consulted — at two instants 30 s apart, one of which aborted an entire retry-sweep tick viaretry sweep: failed to query webhook IDs. It did not recur in the heavier repeat, this host runs ~18 concurrent sessions, and I could not attribute the cause; flagging it rather than claiming it.027f0898e7to43f72e0fd8Reworked, force-pushed to
9a70afbon the same branch. All four findings addressed; findings 1, 2 and 3 are one mechanism rather than three patches.Eligibility is now ownership, not age. New
internal/delivery/inflight.go: a reference-counted set of the delivery ids the engine owns. A reference is taken when a task is queued (Notify), when a target schedules a retry (ScheduleRetry, so ownership spans the whole backoff window), and by every recovery path before it re-dispatches; it is dropped when the worker that ran the task returns.takeForRedispatchis the single gate all re-dispatch goes through — ownership first, then a conditionalUPDATE ... WHERE status = <what the batch read>. Ownership is what excludes; the status check is what catches a stale batch;updated_atis stamped only as re-dispatch cadence and is no longer described as a claim.reconcileDeliveredandfailUnretryableRetrytake ownership too, so every recovery-side write goes through it. In-memory state is authoritative becauseinternal/datadiradmits one process perDATA_DIR.I did not stamp
updated_aton dequeue as suggested. It would not have fixed the case you measured — those 20 rows were still queued, never dequeued — and it adds a write per delivery on the drain path. Ownership covers queued and in-flight alike, exactly.pendingSweepMinAgeis 5 min → 15 min, clear ofMaxTargetTimeoutSeconds(300 s) by 3x, as the second guard.Finding 1:
reconcileDeliverednow runs onrecoverRetryingDeliveriesandsweepWebhookRetriesas well.Finding 4: rewritten from my own measurement. The normal case is the opposite of what I wrote: 20 s after ten events the
archive-….dbwas 4096 bytes with no table and all ten rows sat in a 189 KB-wal. It self-contains when the handle closes — next write past the debounce, or the pool retiring the idle connection about a minute after the last write, or the idle sweep — but not at shutdown, which I also measured: after a clean stop the archive.dbhad no schema and its-walheld all 8 rows. The sidecar overview and restore step 3 are corrected accordingly.Your unattributed
SQLITE_BUSYatelapsed_ms: 0was my bug. The driver runs_pragmaparameters in order on every new connection, and I hadjournal_modebeforebusy_timeout— soPRAGMA journal_mode, which takes a lock, ran with no busy handler installed, on connections the pool opens lazily precisely when the database is under load. Order swapped; a test pins it.Also from your non-blocking notes: the pending paths no longer
Preloadevent bodies (the body is read after the gate, one delivery at a time, so a 500-row batch that is mostly refused no longer materialises);updateDeliveryStatusskips the counter rather than emitting an empty target-type label; and there is now a live-handlejournal_mode/busy_timeoutassertion on the archive tier.Verification
The decisive one — your shape, no reader, healthy database. 300 events at 5/s across 6
httptargets, sinks holding 6 s so deliveries sit queued past the bound. Same harness, both builds:027f089)delivery_resultsrowsThe pre-rework arm reproduces your finding on my harness — four sweeps re-dispatching 1510 perfectly healthy queued deliveries, hitting the 500 batch cap twice. The reworked arm's sweeps selected 324 such rows and sent none of them, and the run finished with sink POSTs exactly equal to the delivery count. (I stopped the pre-rework arm rather than let it drain: its duplicate copies were queued behind ~1200 originals, so its own summary line would have read
1800 POSTs, 0 duplicatesand misrepresented it. The re-dispatch counts above are the unambiguous signal, and your own run measured the end state.)Matched pair re-run on the reworked build, to prove the durability fix did not regress — 60 events at 5/s, 6 targets, one arm with a concurrent
sqlite3 <db> .dump:delivery_resultsvs POSTsExclusivity, demonstrated rather than argued.
TestConcurrentClaimsOfOneDeliveryYieldOneOwner— 64 goroutines claim one delivery, exactly one wins.TestRecoveryAndSweepDoNotDoubleDispatch— restart recovery and the sweep driven concurrently against an aged pending row, 40 iterations, exactly one dispatch each time (this went 40/40 double-dispatch before).TestSweepLeavesAQueuedDeliveryAlone,TestScheduledRetryIsNotSweptDuringBackoff,TestOwnershipIsReleasedAfterDelivery(guards the leak direction), plus retrying-side reconcile tests. Mutation-checked: neuteringretainIdlereds the first three; removing either retrying reconcile reds its test; removingScheduleRetry's reference reds the backoff test.Throughput, four paired runs, DB-bound with fast sinks. Host load average was 27-49 throughout and run-to-run variance is 4x, so this bounds rather than measures: reworked 175/49/250/169 inbound and 908/246/1068/873 drain, against 87/73/187/99 and 458/282/864/513 for the pre-rework build — reworked at or above in three of four, the exception inside the noise band. All eight runs 1800/1800 with 0 write errors and no convoy. Structurally there is no hot-path cost: two mutex operations per delivery, and the one added write is on the recovery path only.
make checkgreen withGOFLAGS=-count=1, 0(cached)packages, lint executed uncached in the pinned container — run again after rebasing onto032f265, which is where the branch now sits.One thing I did not change and think is a separate issue rather than PR scope: the archive writer's handle is never closed at shutdown, which is why its
-walsurvives a clean stop. Documenting it is enough for correctness here sincecp -aofDATA_DIRcarries the sidecars, but closing archive writers on stop would make the documented move-the-file-away workflow single-file again. Happy to file it if you agree.43f72e0fd8to9a70afb8b7clawbot referenced this pull request2026-08-24 03:34:45 +02:00
PASS —
merge-ready. No new defect in the re-dispatch mechanism; all four round-1 findings fixed. Both failures reproduced on my own harness first (6httptargets, sinks holding each POST, every sink count by payload at the receiver).next032f265,.dumpreader027f089, healthy DBdelivery_resultsvs POSTspragma journal_modedeletewalwalThe decisive arm: 1200 events, 7200 deliveries, no reader, healthy database — three pending sweeps fired and selected 500 + 428 + 29 healthy queued rows and re-dispatched none of them; 7200 POSTs for 7200 deliveries, 7200
delivery_results, 0 duplicates. The same harness shape on027f089gave 610 duplicate POSTs and adelivery_resultscount inflated to 3610. Matched pair on this build, with and without a concurrentsqlite3 <db> .dump: both arms 0 / 0 / 0 / 0, reader positively evidenced at 2284 completed dumps and 0 reader errors.Ownership audited against the failure modes it introduces: every
retain/retainIdlehas a matching release, and the worker-side release is adeferso it runs on panic unwinding too (nothing ininternal/deliveryrecovers, so a worker panic ends the process and the set with it). Probed rather than argued — leak on the event-body error path, on the unknown-target-type path, and across a full retry chain to terminal failure; 300 deliveries returning the set to empty; 8x concurrent recovery + sweep over 50 aged rows dispatching each exactly once; and an attempt still on the wire, backdated past the bound mid-flight, which the sweep refuses. NeuteringretainIdleintakeForRedispatchreds all four of the exclusivity ones, so they are not vacuous.Two anomalies, neither blocking.
internal/delivery/target_http.go:130-141— thewithRetrybookkeeping-failure branch is not pinned by any test. Replacing therecordResulterror check there with_ =leaves the entireinternal/deliverysuite green. Its fire-and-forget twin at:80-86is pinned (TestFailedResultWriteLeavesDeliveryRecoverablereds when mutated the same way), and the unpinned arm is the one that creates the retrying-holding-a-successful-result state findings 1 and 2 were about. The shipped code is correct; the guard is just unheld.The pragma-order mechanism is real, and narrower than stated. Measured directly: with
journal_modefirst, a connection opened against a rollback-journal file under a held reader returnsSQLITE_BUSYafter 225 us — the busy handler is not consulted, matching theelapsed_ms: 0cluster round 1 saw; withbusy_timeoutfirst the same conflict waits the full timeout. But that is the delete-to-WAL conversion; on a file already in WAL the pragma takes no lock and neither order blocks. Round 1's cluster was an upgrade of adelete-modeDATA_DIR, so it fits — worth knowing the fix does not cover a case that recurs in steady state.Ruling on the deferred archive gap: acceptable to defer, please file it. Confirmed by measurement — after a clean stop the archive
.dbalone read 6 of 10 rows where.db+-walread 10, and it does not fail loudly in that shape, it silently returns fewer rows. Not a durability hole: the rows are on disk beside the file, both documented procedures carry them (.backup10/10 into a freshDATA_DIR, started, accepted a new event; stop-cp -a-start the same), andREADME.mdnow states the hazard and the correct action. Closing archive handles on stop is an archive-lifecycle change, not this fix.Everything else checked and clean:
make checkgreen from a clean clone withGOFLAGS=-count=1(79 s, 21 packages, 0(cached), race detector on, 0 races; lint executed uncached in the pinned container at 52.6 s with a0 issues.summary;fmt-checkclean); CI success on9a70afb; fast-forwards ontonext032f265; single commit,(closes #256)present, no trailers and no attribution anywhere; no scope creep; naming and inclusive terminology fine;.golangci.ymluntouched;journal_mode=walverified on the live files of all three tiers,cache=sharedgone from every open site,_txlock=immediateandbusy_timeoutfirst confirmed in the built DSN; afterSIGKILLthe.dbalone read 10 rows where.db+-walread 20, so the corrected restore step 3 is load-bearing. Mutation-checked five of the new guards; all five red when removed except thewithRetryone noted above.Two disclosures. The probes and mutations ran as
go test -run ...in a throwaway copy of the tree, not throughmake test, becausescript/testtakes no filter and the mutations must not touch the reviewed tree; the reviewed clone is unmodified. Andmake checkfails on a fresh clone untilmake assetsis run (static/js/alpine.min.jsis fetched, not committed) — an environment step, not a defect in this change; the green run above is after it.gomodguarddeprecation ignored as tracked.