Repository navigation
feat(worker): At most one work item per storage config runs at a time #337
Description
Problem
A Worker runs one work item at a time, but the claim does not look at what else is running: it takes the oldest queued item with FOR UPDATE SKIP LOCKED (EfWorkItemStore.ClaimAsync). When two Workers serve the same storage config, for example a per-user Worker beside an all-user Worker or a second replica, items of that config run at the same time on the same files: a crawl beside a burst detection, or beside a purge (#47).
#47 relies on this not happening. Its purge deletes a soft-deleted asset's proxy files, and the proxy folder derives from the config and the content hash, so a crawl of the same config that ingests the same content as New at that moment would lose its freshly written proxies, and the asset would have no thumbnail for good.
Desired behaviour
At most one item per storage config runs at any time, across all Workers. A Worker does not claim a scoped item while another item with the same StorageConfigId is Running; it claims the next eligible item instead, so other configs' work and unscoped work are not held up.
- Unscoped items (
TokenCleanup,WorkItemRetention,ProxySweep, an unscopedProxyDeletion) are not serialised against anything: they do not touch originals, and the proxy sweep is already safe beside a crawl (one-hour age floor, re-check before delete). - Not a global one-item-at-a-time rule: per-user Workers mount different folders and must not wait for each other (decided by the maintainer during the spec: See soft-deleted assets and purge them #47 grilling, 2026-10-28).
- Parallelism stays inside a Worker (
Worker:MaxConcurrentFiles), not across Workers on the same files.
Acceptance
- Two Workers never run two items of the same storage config at once (integration test on real PostgreSQL, concurrent claimers, beside the existing contract tests).
- An item of another config, and an unscoped item, are claimed while a config's item runs.
- The design note settles the window in which a requeued item's old attempt is still running (lease expired, the old handler not yet cancelled by its fenced heartbeat), and what the rule promises there.
- ADR 0008 records the rule.
-
scripts/mutation-changed.shat 100% (the claim keeps its// Stryker disable alland ADR 0005 row).
First caller
The claim itself: the config's Crawl and BurstDetection items are serialised by it from the first deploy. #47's purge is the caller that depends on it and is blocked by this issue.
Design
Design note: #337 (comment), as amended by the maintainer's decision (2026-10-28): #337 (comment)
🤖 Generated with Claude Code
Activity
bitbiter-dev commentedon Oct 28, 2026
Design note
Problem and constraints:
Runningitem perStorageConfigId, across all Workers. Unscoped items are not serialised. Decided in the spec: See soft-deleted assets and purge them #47 grilling (2026-10-28).UPDATE … WHERE "Id" = (SELECT … ORDER BY "RequestedAt" LIMIT 1 FOR UPDATE SKIP LOCKED)under READ COMMITTED (EfWorkItemStore.ClaimAsync). ANOT EXISTS (running item of the same config)filter on its own is not enough: two Workers claiming at the same moment each take a different queued item of config C.SKIP LOCKEDlocks only the row each one picked, and neither sees the other's uncommittedRunning. The rule needs something that serialises those two claims, or rejects the second.WorkExecutor.HeartbeatAsynclogs a failed heartbeat and carries on, and the handler stops only onLost, which needs a database answer. A Worker cut off from PostgreSQL therefore keeps running its handler, including file writes and deletes, after its lease has expired. Meanwhile another Worker'sRecoverExpiredLeasesAsyncrequeues the item, and a new claim starts work on the same config. Database writes are fenced by(Id, Attempt); file side effects are not. Any per-config rule only holds inside the database unless the old attempt also stops itself.// Stryker disable all(ADR 0005), covered by integration contract tests (ADR 0008).Options:
NOT EXISTSin the claim, plus a partial unique index("StorageConfigId") WHERE "Status" = 'Running' AND "StorageConfigId" IS NOT NULL.NOT EXISTSmakes the claim pass over a config that is busy and take the next eligible item, so other configs and unscoped work are not held up.Running. It shows up indocs/schema.sql.UPDATEfails with a unique violation on that index. The claim recognises it by index name (aPostgresExceptionoverload next toIsUniqueViolation) and returnsnull, so the Worker tries again at the next poll (5 s).Runningwhen the index is created,CREATE UNIQUE INDEXfails and so does host startup. That only happens if two Workers are already serving one config. The migration first requeues all but the earliest-started running item of each config. Their old attempts seeLostat their next heartbeat and stop.NOT EXISTS, serialised by a transaction-scoped advisory lock.pg_advisory_xact_lock(<claim key>)runs as its own statement in an explicit transaction before theUPDATE. It has to be a separate statement, because under READ COMMITTED a statement's snapshot is taken before a lock inside it is granted. This makes every claim across all Workers take turns; claims are tiny and run every 5 s, so that costs nothing measurable. Cost: the rule lives only in the claim's code, with no schema backing, and a second statement and transaction on every claim.pg_try_advisory_lock(config)on a connection kept open while the handler runs). Cost: one held connection per running item. It also doesn't close the zombie window, because the server releases the lock only once it notices the dead connection, and the cut-off Worker carries on regardless.Whichever of these is chosen, the old attempt has to stop itself: the executor cancels the handler once half a lease (30 s by default) has passed since the last heartbeat that succeeded. The deadline must not depend on a heartbeat call returning.
store.HeartbeatAsyncruns withCancellationToken.None, and against an unreachable database it can hang for Npgsql's connect and command timeouts (15 s + 30 s), or longer on a dead TCP connection, which would use up the whole margin. So the deadline is a timer of its own,running.CancelAfter(lease / 2)through theTimeProvider, re-armed after each successful beat. Each heartbeat call also gets its own timeout of one heartbeat interval, so a hung call doesn't hold up the next one. Recovery needs the full lease (60 s) to expire, so an attempt has stopped, or been asked to stop, at least half a lease before anyone else can claim work for its config. That margin covers clock skew between Workers and the time a handler takes to honour cancellation. The cancelled attempt can't write its outcome either, so lease recovery requeues the item and counts a stall, as it does today. Handlers already honour their token between steps (the handler contract).Recommendation: A, plus the self-stop.
QueuedDedupKeyUnique, the closest precedent).// Stryker disable all.First caller: the claim. From the first deploy it serialises a config's
Crawl,BurstDetectionand scopedProxyDeletion; #342's purge is the caller that depends on it. Tests:WorkItemStoreTests/WorkScopeTests, real PostgreSQL:Running;Runningfor C violates the index;WorkExecutorTestswithTimeline: heartbeats fail continuously → the handler's token is cancelled at half a lease, not before; one heartbeat that succeeds resets the clock; a heartbeat call that never completes still cancels the handler at half a lease.Behaviour change to confirm: a database outage longer than half a lease now cancels every running item, a long first crawl included. Lease recovery then requeues it and counts a stall, and the third such outage fails it as stalled (
WorkMaxStalls). Nothing can be saved while the database is down anyway, so the work lost is only what was done since the outage began. The maintainer hasn't decided this yet; it was surfaced on 2026-10-28.Hard to reverse: little. The index can be dropped in a later migration. The self-stop changes how the executor reacts to a database outage: a long outage now cancels running work rather than letting it finish blind. That is the intended behaviour. ADR 0008 gets a line covering both.
🤖 Generated with Claude Code
bitbiter-dev commentedon Oct 28, 2026
Decision
Decided by the maintainer on 2026-10-28, on the design note.
Runningitem perStorageConfigId, across all Workers. Unscoped items are not serialised.NOT EXISTS), and a partial unique index("StorageConfigId") WHERE "Status" = 'Running' AND "StorageConfigId" IS NOT NULLrejects a racing second claim. The migration only creates the index, with no clean-up of existing rows: the product has never been deployed.null, and the Worker waits for the next poll. No immediate retry.WorkMaxStallsth such stall fails the item.FailAsync: today, a handler cancelled whilestoppingis not set falls through toFailAsync, which would mark the itemFailedfor good once the database is back.Corrections to the note for the implementer
timeProvider(for example aCancellationTokenSourcecreated with theTimeProvider, linked into the handler's token).runningcomes fromCreateLinkedTokenSource, sorunning.CancelAfter(...)runs on the system timer and aTimelinetest cannot drive it.UPDATEopen in an explicit transaction, run the second claim so it blocks on the index, commit the first, and assert the second returnsnull. Without that,NOT EXISTSalone usually wins on timing and the test proves nothing about the index.IsQueuedDedupViolation, made to take the index name, rather than adding aPostgresExceptionoverload to Core (it has no other caller).WorkExecutorTests,Timeline): heartbeats fail until the self-stop, then the database answers again → noFail, nothing written, and the attempt is logged as self-stopped.🤖 Generated with Claude Code
bitbiter-dev commentedon Oct 28, 2026
Design review
Architect review of the design note by a session other than its author, checked against
EfWorkItemStore.csandWorkExecutor.cson master (31a5583). It was done on 2026-10-28, before the maintainer's decision, and settled there.Holds:
NOT EXISTSplusFOR UPDATE SKIP LOCKEDunder READ COMMITTED, two claimers can each take a different queued item of one config. Something has to serialise the two claims or reject the second.HeartbeatAsynclogs a failed heartbeat and carries on, and the handler stops only onLost.ix_workitems_dedupkey_queuedprecedent.SystemProcessLauncherkills the FFmpeg process tree on cancellation.Findings, and how they were settled:
OperationCanceledExceptionwhilestoppingis not set, so it falls through toFailAsync. If the database is back by then, the item endsFailedwith no retry. The note's "can't write its outcome" holds only while the database stays down.ix_workitems_dedupkey_queuedand requeue cancel-requested items.running.CancelAfter(...)on a linked CTS runs on the system timer, so aTimelinetest cannot drive it.timeProvider.NOT EXISTSusually wins.IsQueuedDedupViolation, made to take an index name.QueueFailedduring a rolling deploy.Design approved, as amended by the decision: A plus the self-stop is the only combination that keeps file side effects of two attempts on one config from overlapping, which is the guarantee #342's purge depends on.
🤖 Generated with Claude Code
feat(worker): At most one work item per storage config runs at a time (…