A concurrent reader wedges the per-webhook database, stranding delivered webhooks at pending and re-delivering them on restart #256
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Reproduced end to end during the deployability audit. This is the defect that blocks 1.0.
Premises, confirmed on disk
Every SQLite handle opens as
file:...?cache=shared&mode=rwcwith no pool bound.pragma journal_modereturnsdelete(rollback journal, not WAL) andpragma busy_timeoutreturns0, so a lock conflict returns immediately and nothing in the stack retries. The engine runs 10 delivery workers against the same file.Load alone does NOT trigger it
Honest negative result first. Fresh source, 6 active targets, no concurrent reader:
Also clean with
RETENTION_SWEEP_INTERVAL=5s, and clean with a propersqlite3 .backuprunning every 2 s at up to 25 events/s. Genuinely low volume with an operator pollingselect count(*)every 2 s is clean.A concurrent long-held read DOES trigger it
Matched control, identical load, the only difference being an operator running
sqlite3 <db> .dump— an export of their own data:.dumpreader), same load: 60 of 60 inbound webhooks rejected with HTTP 500 (failed to commit transaction: database is locked (5) (SQLITE_BUSY)), 60 engine write errors, database wedged and unreadable.Senders see 500. Those events never enter the system at all.
One lock conflict poisons a pooled connection permanently
Error census across the run: 593 x
SQL logic error: cannot start a transaction within a transaction (1)against only 4 xdatabase is locked (5). A singleSQLITE_BUSYleaves the connection mid-transaction;database/sqlkeeps handing that connection out, and its open transaction holds the file lock.The per-webhook DB became unreadable even in
mode=ro, a hot-journalfile remained, and the event log page hung past 180 s. Still locked 45 s after all load and readers stopped — it does not self-heal. Blast radius is one webhook; other sources kept returning 200. A restart clears it.The stranding and the duplicates, measured
From the run that first wedged a database:
delivery_resultsrecorded. 206 deliveries left atpending, 204 of them with zero attempt rows.{"tag":"moderate","seq":167}arrived at the sink at 22:13:05 and again at 22:21:48, straddling the restart. The event log claims that delivery isdeliveredwith 1 recorded attempt. The receiver got two POSTs. The audit trail is falsified.It is self-sustaining
After the restart that was supposed to clear the wedge, with no external reader running at all, recovery re-enqueued the stranded
pendingrows across 10 workers and re-wedged the same file: 60 xfailed to begin transaction: cannot start a transaction within a transaction. The restart that clears the wedge is itself a write burst that recreates it.Mechanism
All in
internal/delivery/engine.go:recordResult(:913-940) andupdateDeliveryStatus(:955-975) both log-and-swallow their errors, so a failed write leaves the row at its previous status.pending—sweepWebhookRetries(:692-735) queriesstatus = 'retrying'only.recoverPendingDeliveries(:602-642) andsendRecoveredDeliveries(:1171-1213) re-enqueue everypendingrow unconditionally withattemptNum = 1, with no check for an existing successfulDeliveryResultand no compare-and-set claim.There is no state distinguishing "delivered but unrecorded" from "never attempted", so the engine cannot do better than re-send.
Definition of done
Two halves. Both are required; fixing only the first leaves the falsified audit trail, and fixing only the second leaves the wedge.
Durability layer,
internal/database:cache=shared.cache=sharedturns an in-process table conflict intoSQLITE_LOCKED, which a busy timeout does not retry, so removing it is part of the fix rather than incidental.Delivery layer,
internal/delivery/engine.go:recordResultandupdateDeliveryStatusmust return their errors rather than swallowing them, and the caller must leave the delivery in a RETRYABLE state rather than a lying one.DeliveryResult.pending-with-age-bound arm, so a stranded delivery is recovered without requiring a restart.Verification
make checkgreen.sqlite3 .dumpreader and one without. Both arms must show 0 inbound HTTP 500s and 0 engine write errors.pendingdelivery that already has a successful result row.Reproduced on unmodified
nextbefore writing any code, with a harness matching the report: 6 activehttptargets, 60 events at 5/s, a concurrentsqlite3 <db> .dumploop started 4 s in.delivery_resultsholds 132 rows for 244 actual POSTs: the audit trail is falsified exactly as described.pragma journal_mode=deleteon the live file.Plan.
Durability (
internal/database). One DSN builder used by all three open sites (main DB, per-webhook event DBs, archive DBs): dropcache=shared, add_pragma=journal_mode(WAL),_pragma=busy_timeout(...), and_txlock=immediate. The last one matters as much as the busy timeout: a deferred transaction that upgrades to a write lock mid-transaction getsSQLITE_BUSYwithout the busy handler being consulted, and that is the failure that leaves a pooled connection mid-transaction and produces the 593cannot start a transaction within a transaction.BEGIN IMMEDIATEtakes the write lock up front, where the busy handler does apply. Plus explicit pool bounds (SetMaxOpenConns/SetMaxIdleConns/SetConnMaxLifetime/SetConnMaxIdleTime) so a connection that is poisoned anyway is retired rather than handed out forever.WAL introduces
-wal/-shmsidecars, so the backup/restore section ofREADME.mdgets corrected in the same commit — both documented procedures (sqlite3 .backup, stop-copy-startcp -a) are re-verified under WAL.Delivery (
internal/delivery/engine.go).recordResultandupdateDeliveryStatusreturn their errors. On a bookkeeping write failure the caller does not advance the status, so the row stays in whichever non-terminal state it already held (pendingorretrying) — the retryable states, perDeliveryStatus.Terminal(). This needs no write on the failure path, so it cannot itself fail.Recovery and the sweep then reconcile before re-sending: any
pendingdelivery that already has a successfulDeliveryResultis markeddeliveredinstead of re-enqueued. That distinguishes "delivered and recorded but status unwritten" from "never attempted", which is the state the issue notes does not exist today. Recovery also stops re-enqueueing atattemptNum = 1; it uses the real attempt count.sweepWebhookRetriesgains apending-and-older-than-N arm running the same reconcile-then-dispatch path, so a stranded delivery is recovered without a restart.Verification is the matched pair above plus a restart, with the sinks counted by payload, and a unit test that recovery skips a
pendingdelivery holding a successful result row.Built in #263 (branch
issue-256-sqlite-durability, basenext). Full rationale is in the PR body; the verification is here.Matched pair, same load both arms (6
httptargets, 60 events at 5/s, sinks holding each POST 1.5 s so deliveries are in flight), one arm with a concurrentsqlite3 <db> .dumploop:delivery_resultsrowsSink counts are by payload at the receiver, not from the database. The before/no-reader arm's 160 is my harness not draining before the restart rather than the defect; both after arms drain and add nothing.
Pragmas confirmed by querying live handles, not the DSN:
journal_mode=wal,busy_timeout=10000on both a running per-webhook database and the main database (TestPerWebhookDBAppliesPragmasOnALiveHandle,TestMainDBAppliesPragmasOnALiveHandle).make checkgreen withGOFLAGS=-count=1, lint in the digest-pinned container, re-run after rebasing onto currentnext.Both documented backup procedures re-verified under WAL.
.backupand stop-copy-start still work unchanged; the restore instructions were inverted and are corrected — after a crash the.dbalone read 40 rows where.dbplus-walread 50, so a-walin a salvaged copy must be carried, not dropped.Two answers worth stating here. Send-succeeded-but-write-failed leaves the delivery in whichever non-terminal status it already holds and writes nothing, because the failure path must not depend on the database that just refused a write; the sweeps then recover it, and a delivery whose result row did land is settled to
deliveredrather than re-sent. And re-dispatch is claimed by compare-and-set, otherwise every 60-second sweep would send the same still-running delivery again.One unrelated defect found while auditing the transaction paths, filed rather than fixed here: #262.