One walk at a time, and a claim that survives a concurrent restore - #106
Merged
Merged
Conversation
Both boot-time walks enqueued unconditionally. A restart mid-walk leaves the
chain's `{after: X}` job `queued` -- `reclaimStale` only ever looks at `running`
rows -- so boot put a fresh `{}` beside it and both ran. The face repair is the
expensive one: the fresh walk re-runs ONNX detection over the whole library from
the start, on every restart that lands mid-walk.
`JobQueue.enqueueUnique` is the guard, under a per-name advisory lock rather than
a bare check-then-insert: the rows being counted do not exist yet, so there is
nothing to take a row lock on, and two servers booting together would both read
an empty queue. A chain's own re-enqueue stays on `enqueue`, since it runs while
its own row is `running` and would otherwise refuse to continue.
One walk at a time also closes the record race #86 inherited and left here: with
a single chain, reaching a short batch means the library really has been walked,
so the scheme it records is honest.
Closes #92
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The claim selected a trashed row and then brought it back under a guard on `deletedAt`, so a row restored by a concurrent caller read exactly like a row the retention sweep had destroyed: no match. The claim answered `undefined`, the ingest fell through to an INSERT, and the owner got a 500 off `assets_owner_checksum_key` where the answer was a tidy `duplicate: true`. The guard was redundant for the case it was written for. The sweep *deletes* the row, so its absence already says the photograph is gone -- keying the restore on the id alone reads that the same way and stops mistaking one case for the other. The row lock settles the rest: the sweep's own delete is guarded on `deletedAt`, so whichever side commits first, the other re-evaluates and finds the row restored or gone rather than both proceeding. `api/uploads.ts` deliberately does not take the ingest advisory lock. It inserts no asset, so it has no unique index to trip; the explicit restore route does not hold that lock either, so the claim has to be right without it; and putting every resumable `begin` behind one per-owner lock is the contention that drove faces off exactly that key. Closes #93 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of the dedup above caught what it traded away. The chain pages by id, so
resuming one the upgrade interrupted covers the tail of the library and then
records the new scheme as walked over a head it never read -- and the head is
exactly the part hashed under the rule that just changed. Before the dedup the
fresh `{}` chain covered it; now there is only the one chain, so the chain has to
say which rule it is walking under and start again when that is no longer the
current one. A payload with no scheme is one inherited from an older build, which
is the same situation.
Also from the review: `heldOpen` hung the suite rather than failing it when the
change it applies threw, and `ExistingAsset.restored` still documented the
narrower meaning the claim no longer has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dedup above treats a `running` row as a live walk, and `reclaimStale` is the only thing that returns one a dead worker left behind. It ran at boot and once more a minute later, and nothing re-enqueued the chore after that -- so a walk whose worker died mid-batch sat `running` inside the fifteen-minute stale window, boot declined to start a second chain, and the pass waited for a restart landing more than that window after the crash. Before the dedup the fresh chain covered it. The chore now queues its own next run, and boot adds one only if none is waiting, so a restart cannot multiply the chain it just made recurring. Also from the review: say why an inherited chain with no scheme restarts rather than resuming (it cannot name the rule it began under, and a needless pass is the cheap mistake), and scope the three tests that flip a job to `running` to the row they mean rather than every row in the table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Making the reclaim chore recurring was an attempt to keep a walk stranded `running` from being blocked by its own dedup. Review found the cure worse than the disease: the chore is the only recurring caller of `reclaimStale`, so once it is uniquely enqueued and self-chaining it becomes the one chain with no rescuer -- a worker that dies inside it strands reclaim, pruning and session expiry for good -- and a death between its self-enqueue and its `done` write forks the chain in two for ever. Neither belongs in a PR about the walks. `maintenance.ts` goes back to what is on `main`, and the cost is recorded in the PR body: a walk whose worker dies mid-batch now waits for a restart landing more than the stale window after the crash, where before it was replaced by a duplicate walk. That is the trade #92 asks for, narrowly worse in a crash and much better in the ordinary restart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two concurrency bugs on the same seam: the boot-time walks and the ingest claim they share a queue with.
#92 — a restart during a walk started a second one alongside it
scheduleFaceRepairandscheduleContentHashBackfillenqueued unconditionally. A restart mid-walk leaves the chain's{after: X}jobqueued—reclaimStaleonly ever looks atrunningrows — so boot put a fresh{}beside it and both ran. The face repair is the expensive one: the fresh walk re-runs ONNX detection over the whole library from the start.JobQueue.enqueueUniqueis the guard, under a per-name advisory lock rather than a bare check-then-insert — the rows being counted do not exist yet, so there is nothing to take a row lock on, and two servers booting together would both read an empty queue. A chain's own re-enqueue stays onenqueue: it runs while its own row isrunningand would otherwise refuse to continue.On the "head-origin chain" gate that was rejected before. That flag could not discriminate, because a resumed chain descends from a head chain too — the ancestry is the same. This does not look at ancestry at all. It asks a question about the present: is there a walk in the queue right now? One chain exists or none does, and
{after: X}and{}answer identically. The scheme record is then honest for free: with a single chain, reaching a short batch means the library really has been walked.That one-chain rule has a consequence the review caught, and the fix is in this PR: a chain pages by id, so it is only resumable under the rule it started with. An upgrade is the one event that both bumps
CONTENT_HASH_SCHEMEand restarts the process, so resuming the interrupted chain would cover the tail of the library and then record the new scheme as walked over a head it never read. The chain now carries the scheme it is walking and starts again from the beginning when that is no longer current. A payload with no scheme cannot name the rule it began under and is treated the same way: a needless pass is the cheap mistake, a stale hash the expensive one.#93 — a lost restore race returned
undefinedand the INSERT tripped the unique indexclaimExistingAssetselected a trashed row and restored it under a guard ondeletedAt, so a row somebody else had restored in between read exactly like a row the sweep had destroyed: no match,undefined, and the caller fell through to an INSERT that trippedassets_owner_checksum_key— a 500 where the answer wasduplicate: true.The guard was redundant for the case it was written for.
sweepTrashdeletes the row, so its absence already says the photograph is gone; keying the restore on the id alone reads that the same way (emptyRETURNING) and stops conflating it with a concurrent restore. The row lock settles the rest: the sweep's own delete is guarded onisDoomed, so whichever side commits first, the other re-evaluates and finds the row restored or gone rather than both proceeding.restorednow means "trashed when this claim looked, live now", which is what it is for — it drivesfaces.refreshFor, and that recount is owed either way.api/uploads.tsdoes not take the ingest advisory lock, deliberately. Three reasons. It inserts no asset, so it has no unique index to trip — the lock inclaimIdentityguards an insert, and there is none on this path. The explicit restore route does not hold that lock either, so the claim has to be right without it; a lock could only narrow the window, not close it. And putting every resumablebeginbehind one per-owner key is exactly the contention that droveFaceServiceoff a per-owner lock after a live import logged thousands ofcanceling statement due to lock timeout.Tests
Every one was written first and watched fail.
{after: X}queued, and the same row flipped torunning) plus four on the primitive inqueue.test.ts. All four schedulers returnedtrueand enqueued a second walk before the fix.heldOpenapplies a change in its own transaction and leaves it uncommitted, so the row lock is still held when it returns. The claim's SELECT reads the pre-commit snapshot and sees the row trashed; its UPDATE then blocks on that lock instead of racing for it, and the test decides when the other side commits. Nothing depends on which side wins a timer. Before the fix the restore case failed withduplicate key value violates unique constraint "assets_owner_checksum_key"→ HTTP 500. The companion test holds aDELETEopen the same way and proves the swept row still stores the photograph afresh.Found, not fixed
scheduleMaintenanceenqueuesSWEEP_TRASH_JOB,PRUNE_UPLOADS_JOBandPRUNE_JOBS_JOB60 seconds after boot and nothing re-enqueues any of them. On a long-lived server the trash is swept once per boot and never again, andreclaimStaleruns only at boot and once a minute later. Pre-existing, and larger than this PR.runninginside the 15-minute stale window, boot declines to start a second chain, and the pass waits for a restart landing more than that window after the crash — where before it was replaced by a duplicate walk. Narrowly worse after a crash, much better on the ordinary restart A restart during a repair or backfill walk starts a second one alongside it #92 is about. A recurringreclaimStalecloses it; I tried making the prune chore self-chaining and review found that cure worse than the disease (it is the only recurring caller ofreclaimStale, so it becomes the one chain with no rescuer, and a death between its self-enqueue and itsdonewrite forks it in two for ever). Backed out in cd69e13 — it belongs with the recurrence bug above, not here.AdminService.startRepairkeeps its own check-then-enqueue rather than moving toenqueueUnique. Its comment says the looseness is deliberate; tightening it is a separate decision.name = $1 and status in ('queued','running')has no supporting index —jobs_claim_idxis partial onstatus = 'queued'. It runs twice per boot, so it is cosmetic today.Review
code-reviewat medium onorigin/main...HEAD, three iterations. Iteration 1 found a blocker (the resumed chain recording a scheme it had not walked) and two nits; iteration 2 found the stranded-walk regression; iteration 3's findings were all in the maintenance change, which is backed out above. The remaining diff reviewed clean, withmedia/identity.tsexplicitly cleared — the reviewer reinstated the old guard and confirmed the new test fails with the 500.Left open rather than self-merged: a significant is deferred (the stranded-walk delay, above), and the review cap is three iterations.
Verify:
bun install && bun run verify && bun run --filter '@imogen/web' build—953 pass, 0 fail, web bundle built. Nobun.lockdiff. SDK pin untouched atv0.5.1.Closes #92
Closes #93
🤖 Generated with Claude Code