Repository navigation
fix(service-queue): reclaim a running job whose lease ran out, and renew a live handler's lease - #30277
Conversation
…new a live handler's lease Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
…opping Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
…st live lease Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
…costs Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b31f8f35f871bacaa1e817a6a88028d74aa010c2 && git checkout b31f8f35f871bacaa1e817a6a88028d74aa010c2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 959c26be1f4179202b0ed42f58076190d4d590d5 8d430e0cdaf1b7a8032c1b54e7497d601e645262 && git checkout -B drift-repro 959c26be1f4179202b0ed42f58076190d4d590d5 && git merge --no-ff 8d430e0cdaf1b7a8032c1b54e7497d601e645262
node scripts/docs-audit/affected-docs.mjs --json 959c26be1f4179202b0ed42f58076190d4d590d5
|
… reclaim read uses no $in Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CaNicTDZwYQgDRwD7a Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb 席位核对 · ACCEPT · head
|
| Item | Reading |
|---|---|
| Shape | Draft, base main, relay pr_create. Body: attribution, signature, ## 维护者速读, Fixes #30228; Clause-②: / Metadata-protocol: at line start. Report 6649780135. |
| Acceptance | 1: an expired (or lease-less) running row goes to the retry path with its attempt counted (dlq at max_attempts, last_error names the dead worker), within one idle tick. 2: the reclaim write is a multi: true compare-and-set re-stating the expiry and locked_by; a live handler renews every leaseMs/3, so a renewed row matches nothing. 3: nextDueAt folds in the earliest live lease, on top of #30225 (771b2db20). Crashed-worker fixture red before, green after; ablations A1–A6 red. |
Seat REWORK round (9a2d7712 → 8d430e0c) |
(a) Idle cost: the first head doubled an idle hour's reads for every host, undoing #30225's sole-writer gain. Now a soleWriter reads once per boot and then only when one of its own unsettled claims runs out (pinned: 1 read per idle hour, against 120 without the flag); multi-writer unchanged (125 → 246 per idle hour, stated in 维护者速读). (b) CI red in @objectstack/plugin-email (its fake engine has no $in): the reclaim read is now status: 'running' equality across every queue (it reclaims, it never runs a handler), so no new operator; plugin-email 785/785 locally, surface not widened. |
| Gates | Dev: service-queue 91/91, typecheck, pnpm lint 0; derived 90, 87 green, 3 left to CI (no full dist). CI on 8d430e0c: 39 success, 0 failure, 1 pending. |
| Declarations | Clause-②: no: no option or export added (soleWriter and leaseMs already existed; the documented reclaim is restored). Metadata-protocol: none; changeset patch. |
| Docs | plugins/packages.mdx:287 (lease promise) now true as written. automation/webhooks.mdx sole-writer and idle-cost sentences re-read: they describe the dispatchers and the flag, still exact. kernel/runtime-services/audit-service.mdx:71 is the auth lock's locked_until, unrelated. Census files widened by the seat 03:38Z (+2 write sites). |
| Landing | Not governed (0 of 5 paths), 686 changed lines. Seat lands: pr_ready + automerge_enable. |
Requested by Bill · project thread
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
维护者速读
leaseMs文档写着"到期后可由其他 worker 回收",但认领只读pending,挂掉的任务永远停在running,既不重试也不进死信,运维看不到。packages/services/service-queue/src/db-queue-adapter.ts的reclaimRow(CAS 谓词)、renewLease(续租)、reclaimExpired(读的节流界)与nextDueAt(并入最早活租约)。pnpm --filter @objectstack/service-queue exec vitest run src/db-queue-idle-backoff.test.ts -t 30228,预期 9 条全绿;其中"CRASHED-WORKER FIXTURE"一条在修复前的代码上是红的。Fixes #30228
Clause-②: no — restores the documented
leaseMsreclaim; no accept set or export widensMetadata-protocol: none
What changes
The card's acceptance, quoted:
Reclaim (item 1).
pollOnce()now starts withreclaimExpired(): one read of the table'sstatus = 'running'rows ordered bylocked_until, every queue's (reclaiming runs nothing, so a row of a queue this node does not subscribe to just goes back topendingfor whoever does; the predicate is the claim read's plainstatusequality, served by the declaredstatusindex, with no operator a stand-in engine might not answer), and each row whose lease has passed (or that has no lease at all) goes to the retry path. Of the card's two options this takes the second, retry path with the attempt counted, becauseattemptsis written only after a handler settles: a worker that died left the row one attempt short, so reclaiming without counting would retry forever a message that kills its worker every time (an OOM on a large payload). The reclaim writes that attempt down and takes the branch a thrown handler takes:pendingon its backoff, ordlqoncemax_attemptsis spent, withlast_errornaming the dead worker. A reclaimed row counts in the tick's result, so the loop snaps back topollIntervalMsand claims it on its backoff rather than after the idle ceiling.The reclaim write is a compare-and-set (item 2). Its predicate re-states the expiry:
id,status: 'running', thelocked_byit read, andlocked_untilnull or at most now. A holder that renewed between the read and the write matches nothing and keeps its run. It is amulti: truewrite on purpose: the by-id path binds only the primary key and drops the rest of the predicate (theoutbox-dispatcher-scope.tsprecedent). Two workers reclaiming the same row write the same values from the same read and neither runs it, so that race costs nothing.sys_job_queuecarries no tenant column (ADR-0131 D7), so the predicate writes need no tenant-audit warrant.A live handler renews its lease (item 2). This is the risk the reclaim itself introduces. Before, nothing reclaimed, so a handler slower than
leaseMswas safe; with a reclaim and no renewal it would be reclaimed mid-run and run twice.dispatch()now moves its row'slocked_untila full window ahead every third ofleaseMswhile the handler runs: amulti: truecompare-and-set onlocked_by = workerId, logged when it matches nothing (the lease was lost underneath). A lease therefore passes only when its holder stopped renewing: the process died, or its event loop was blocked for most of a window. That is how a dead worker is told apart from a slow one, and theleaseMsdoc now says the one thing an author must know: keep synchronous stretches well under the lease.The read is bounded, not per tick. The read also returns the earliest lease still running. No lease can run out before the earlier of that lease and
now + leaseMs(every running row was in the read, a row claimed after it holds at leastleaseMsfrom its claim, and renewal only moves a lease later), so the next read waits until then. A full page means more may be hidden, so the next tick reads again. Cost withoutsoleWriter: a busy 1 s poll pays one reclaim read per lease window; an idle loop at the 30 s ceiling pays one per tick, and an idle hour at the defaults goes from 125 to 246 reads (pinned).A sole writer reads once per boot. With
soleWriterno other process claims rows, so the window term is dropped. The first read afterstartfinds what a crashed previous process left behind; after it, the only leases that can run out are the live ones it saw and this process's own claims whose settling write (completed, retry, dead letter, release) failed and left the rowrunning. The adapter keeps those unsettled claims' leases (renewal moves them, a settling write drops them, the next read replaces them all), reads again only when one runs out, and folds them intonextDueAt. An idle sole writer's hour carries one reclaim read, the boot's, against 120 for the same hour without the flag (pinned); its fresh first minute goes from 2 to 3 reads.nextDueAtcounts the earliest live lease (item 3). Built on #30225'sDbQueueAdapter.nextDueAtafter it merged (771b2db). The reclaim read already returns the earliest lease still running; the adapter keeps it, andnextDueAtanswers the earliest of that, a sole writer's unsettled claims and the earliest deferredscheduled_for, at no read of its own. That read ran on the empty tick the answer follows, or the bound it set has not passed. AsoleWriterloop, which otherwise sleeps to its ceiling, now wakes when a row its own previous process leftrunningacross a restart runs out of lease. The window term (now + leaseMs) is deliberately not in the answer: it only bounds the next read, and folding it in would wake a sole-writer loop every lease window for nothing.Docs. Read sentence by sentence, none made false by this PR, left unchanged:
content/docs/plugins/packages.mdx:287("multi-node claim via a lease on thedbadapter", note 6648413565) is now true as written;content/docs/automation/webhooks.mdx, thedispatchSoleWriter/db.soleWriterparagraph and the idle-cost sentences, describe the HTTP and notification dispatchers' reads and the flag's meaning, both still exact (the queue's sole-writer loop still sleeps to its next due time or the ceiling; a lease is one more due time);content/docs/kernel/runtime-services/audit-service.mdx:71'slocked_untilis the account lock's field on an auth audit event, not the queue's; the package README'sleaseMsline is now true.Tests
packages/services/service-queue/src/db-queue-idle-backoff.test.ts, new#30228block, on that file's counting engine (itsupdatealready opens withassertEngineUpdateDispatch; it now applies amultidispatch to the rows itswherematches and reports the count):running,locked_by: 'worker_dead', lease 1 ms past: one tick returns it topending,attempts: 1,scheduled_for= now + 1 s,last_errornaming the worker; on its backoff it runs as attempt 2 and completes.attempts: 2of 3 goes todlq, andlistFailedreturns it.runningunder its holder.completed,attempts: 1.running, lease 40 s from expiry, ceiling 5 min: the row runs at exactly 41 s (reclaimed at the lease's expiry, then its 1 s backoff).soleWritermakes 120 (the engine counts the reclaim read on its own).completedwrite fails once (the row staysrunningunder itself) reclaims it when its 3 s lease runs out and runs it again: 2 runs,completed,attempts: 2, 2 reclaim reads (boot, then the expired lease).Moved pins in the same file, each with its reason in the comment: the idle hour (125 → 125 + 121), the flat-poll negative control (3602 → 3602 + 121), the per-tick cost (n claim reads + 1 reclaim read, then n on a tick inside the same window), the sole-writer fresh minute (2 → 3) and its ramping control (6 → 6 + 2).
Crashed-worker fixture, before / after
The six reclaim legs, run against the pre-fix adapter before the
origin/mainmerge (db-queue-adapter.tsrestored from the branch base6f095b9c, blob67be673a, then restored to HEAD and proved byte-identical). The seventh leg (sole-writer restart) needs #30225's loop; its before-reading is ablation A4 below.Ablations (each through scripts/ablation-replace.mjs, restored to HEAD blob, git diff HEAD empty)
renewLeasereplaced by a no-opexpected [ 'a', 'b' ] to deeply equal [ 'a' ]expected 1 to be +0(a renewed lease was taken back)now + leaseMswindow bound droppednextDueAtexpected [] to have a length of 1(no run inside the minute)expected [ … ] to have a length of 2 but got 1(the row staysrunning)expected 120 to be 1A1–A6 were re-run on head
8d430e0c(A3 now also reds the sole-writer and ramp-control pins, 5 legs in all).A0 (the whole pre-fix adapter) is the before/after above. Each leg's on-disk landing and restore are printed by the tool (
anchor 1 -> 0,blob == HEAD,git diff HEADempty).Acceptance notes
pendingrow, and delivery is at-least-once). This PR does not change that; it adds no new way to double-claim, and the reclaim never runs anything itself.locked_bytoo would close that last window and is a separate change.census +2 write sites (reclaimRow, renewLease), surface widened by seat 03:38Z.check:tenant-audit-censuscounts every engine write call site, socontent/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.mdare regenerated (node scripts/tenant-audit-census.mjs --write) and the page's hand-written figures moved with them (259 → 261 sites, 182 → 184 decidable, 107 → 109 undecidable, 66 → 68 const-named, 56% → 55%).leaseMs, as they do when the nodes share oneQueueServicePluginconfiguration. A node with a shorter lease than this one's is still reclaimed, at most one lease window late.Verification
All readings on head
8d430e0c(code identical to87fd3a2e; the last commit edits only the changeset).pnpm --filter @objectstack/service-queue exec vitest run --maxWorkers=2: Test Files 5 passed (5), Tests 91 passed (91).pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2(after building its closure): Test Files 48 passed (48), Tests 785 passed (785). On9a2d7712its suites were red in CI (Test Core 7/8, "fakeEngine: unsupported operator $in"): the reclaim read then filteredqueue: { $in: … }, which that package's stand-in engine refuses; the read now carries onlystatusequality.pnpm --filter @objectstack/service-queue typecheck(tsc --noEmit, whose program listsdb-queue-idle-backoff.test.ts): exit 0.eslint --no-inline-configon the two changed source files: exit 0.pnpm lint: exit 0.Derived gates: 90 families, 87 green, 3 NOT MEASURED
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackagainst merge base771b2db20, each command's exit code recorded before any pipe, reconciled with--ran:90 derived famil(ies) accounted for — 87 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).node scripts/check-plugin-teardown-shape.mjs --self-test(its positive control is a pinned commit a shallow clone cannot reach),pnpm --filter @objectstack/spec run check:skill-examples(needs the client packages built),pnpm check:dual-build-cjs-loads(needs every package'sdist/; this worktree built only the queue and email closures).pnpm check:tenant-audit-censusand its--self-test: exit 0 after the census regeneration listed under Acceptance notes (red before it, 259 vs 261).Generated by Claude Code