Skip to content

fix(service-queue): reclaim a running job whose lease ran out, and renew a live handler's lease - #30277

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-o05z5l
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/project-thread-o05z5l

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

维护者速读

  • 改了什么:数据库队列在处理器还没跑完时进程挂了(崩溃、OOM、重新部署),那条任务现在会在租约到期后被下一次轮询收回:记一次失败尝试,按退避重排,次数用尽进死信;活着的慢处理器会定期续租,不会被别的节点抢走重跑。
  • 为什么改:leaseMs 文档写着"到期后可由其他 worker 回收",但认领只读 pending,挂掉的任务永远停在 running,既不重试也不进死信,运维看不到。
  • 风险与回滚:多节点(未开 soleWriter)时每个租约窗口(默认 30 秒)多一条带索引的读,空闲一小时从 125 条变 246 条;单写者(soleWriter,云端租户内核)只在开机多读一次,空闲一小时不再多读。处理器若同步阻塞事件循环超过大半个租约,会被当成死掉而重跑。回滚即撤本 PR,无数据迁移。
  • 先看: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 leaseMs reclaim; no accept set or export widens
Metadata-protocol: none

What changes

The card's acceptance, quoted:

  1. A running row whose locked_until has passed is claimable again (or moved to the retry path with its attempt counted), within one idle tick of the lease running out.
  2. A live worker that is still inside its lease is never double-claimed.
  3. Pins: a crashed-worker fixture (row left running with an expired lease) is reclaimed; a row inside its lease is not. The nextDueAt read core DispatchLoop: an idle loop re-runs its fast backoff ramp after every boot, so a fresh tenant kernel spends 30 statements in its first minute on empty delivery outboxes and job queues #29919 adds (PR fix(core): an idle DispatchLoop wakes at the next due time, and a sole writer skips the cold-start ramp #30225) also counts the earliest lease expiry, so a sole-writer loop wakes for it.

Reclaim (item 1). pollOnce() now starts with reclaimExpired(): one read of the table's status = 'running' rows ordered by locked_until, every queue's (reclaiming runs nothing, so a row of a queue this node does not subscribe to just goes back to pending for whoever does; the predicate is the claim read's plain status equality, served by the declared status index, 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, because attempts is 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: pending on its backoff, or dlq once max_attempts is spent, with last_error naming the dead worker. A reclaimed row counts in the tick's result, so the loop snaps back to pollIntervalMs and 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', the locked_by it read, and locked_until null or at most now. A holder that renewed between the read and the write matches nothing and keeps its run. It is a multi: true write on purpose: the by-id path binds only the primary key and drops the rest of the predicate (the outbox-dispatcher-scope.ts precedent). 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_queue carries 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 leaseMs was safe; with a reclaim and no renewal it would be reclaimed mid-run and run twice. dispatch() now moves its row's locked_until a full window ahead every third of leaseMs while the handler runs: a multi: true compare-and-set on locked_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 the leaseMs doc 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 least leaseMs from 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 without soleWriter: 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 soleWriter no other process claims rows, so the window term is dropped. The first read after start finds 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 row running. 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 into nextDueAt. 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.

nextDueAt counts the earliest live lease (item 3). Built on #30225's DbQueueAdapter.nextDueAt after it merged (771b2db). The reclaim read already returns the earliest lease still running; the adapter keeps it, and nextDueAt answers the earliest of that, a sole writer's unsettled claims and the earliest deferred scheduled_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. A soleWriter loop, which otherwise sleeps to its ceiling, now wakes when a row its own previous process left running across 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 the db adapter", note 6648413565) is now true as written; content/docs/automation/webhooks.mdx, the dispatchSoleWriter / db.soleWriter paragraph 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's locked_until is the account lock's field on an auth audit event, not the queue's; the package README's leaseMs line is now true.

Tests

packages/services/service-queue/src/db-queue-idle-backoff.test.ts, new #30228 block, on that file's counting engine (its update already opens with assertEngineUpdateDispatch; it now applies a multi dispatch to the rows its where matches and reports the count):

  • CRASHED-WORKER FIXTURE: a row written running, locked_by: 'worker_dead', lease 1 ms past: one tick returns it to pending, attempts: 1, scheduled_for = now + 1 s, last_error naming the worker; on its backoff it runs as attempt 2 and completes.
  • A lease that runs out while the loop is deep in its idle backoff (another node's crash, nothing tells this process) is reclaimed within one idle interval of expiry and run.
  • The lost run counts: a fixture at attempts: 2 of 3 goes to dlq, and listFailed returns it.
  • A row inside its lease is not touched (no write at all).
  • CAS: a renewal landing between the reclaim read and its write leaves the row running under its holder.
  • A live handler three leases long, with a second adapter polling the same table: one run, completed, attempts: 1.
  • In core DispatchLoop: an idle loop re-runs its fast backoff ramp after every boot, so a fresh tenant kernel spends 30 statements in its first minute on empty delivery outboxes and job queues #29919's block: a sole-writer adapter booting over a row its previous process left 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).
  • A sole writer's idle hour, with one job run at 10 min, makes exactly 1 reclaim read; the same hour without soleWriter makes 120 (the engine counts the reclaim read on its own).
  • A sole writer whose completed write fails once (the row stays running under 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/main merge (db-queue-adapter.ts restored from the branch base 6f095b9c, blob 67be673a, 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.

before  × CRASHED-WORKER FIXTURE: an expired lease goes back to the retry path …   AssertionError: expected +0 to be 1
        × a lease that runs out while the loop is deep in its idle backoff …       row still { status: 'running', attempts: 0 }
        × the lost run counts: … dead-lettered, not retried forever                AssertionError: expected +0 to be 1
        ✓ a row INSIDE its lease is never reclaimed                                (trivially: nothing reclaimed anything)
        ✓ the reclaim is a compare-and-set …                                       (trivially)
        ✓ a LIVE handler three leases long is never double-claimed …               (trivially)
after   ✓ all of them, and the whole package: Test Files 5 passed (5), Tests 91 passed (91)
Ablations (each through scripts/ablation-replace.mjs, restored to HEAD blob, git diff HEAD empty)
Leg Mutation Expected Observed
A1 renewLease replaced by a no-op live handler reclaimed and run twice red: expected [ 'a', 'b' ] to deeply equal [ 'a' ]
A2 the expired-lease term deleted from the reclaim predicate CAS leg red red: expected 1 to be +0 (a renewed lease was taken back)
A3 the now + leaseMs window bound dropped a lease another node takes after the read is never re-read red: idle-reclaim leg, plus both cost pins (measured before the merge: 3602 and 125 reads against the then pins 3722 and 245)
A4 the live lease dropped from nextDueAt sole-writer restart leg red red: expected [] to have a length of 1 (no run inside the minute)
A5 a sole writer's claims not tracked as unsettled failed-settle leg red red: expected [ … ] to have a length of 2 but got 1 (the row stays running)
A6 the window term kept for a sole writer sole-writer idle-hour pin red red: expected 120 to be 1

A1–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 HEAD empty).

Acceptance notes

  • The pending → running claim is still the by-id write it was (the comment above it says so: concurrent claimers may both take a pending row, 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.
  • A worker whose lease was reclaimed while it was stalled and then finishes still writes its terminal state by id. The renewal logs the lost lease; making the terminal writes a compare-and-set on locked_by too would close that last window and is a separate change.
  • Deviation: census +2 write sites (reclaimRow, renewLease), surface widened by seat 03:38Z. check:tenant-audit-census counts every engine write call site, so content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md are 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%).
  • Leases assume every node runs the same leaseMs, as they do when the nodes share one QueueServicePlugin configuration. 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 to 87fd3a2e; 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). On 9a2d7712 its suites were red in CI (Test Core 7/8, "fakeEngine: unsupported operator $in"): the reclaim read then filtered queue: { $in: … }, which that package's stand-in engine refuses; the read now carries only status equality.
  • pnpm --filter @objectstack/service-queue typecheck (tsc --noEmit, whose program lists db-queue-idle-backoff.test.ts): exit 0.
  • eslint --no-inline-config on 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/objectstack against merge base 771b2db20, 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).

  • NOT MEASURED, exit 3 (PREREQUISITE NOT MET), left to CI: 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's dist/; this worktree built only the queue and email closures).
  • pnpm check:tenant-audit-census and its --self-test: exit 0 after the census regeneration listed under Acceptance notes (red before it, 259 vs 261).

Generated by Claude Code

…new a live handler's lease

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>
@github-actions github-actions Bot added the size/l label Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-queue, touching 20 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/webhooks.mdx (via DbQueueAdapter (symbol, a top-level class))
  • content/docs/kernel/runtime-services/audit-service.mdx (via locked_until (literal, a string literal in reclaimExpired))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via DbQueueAdapter (symbol, a top-level class), DbQueueAdapterOptions (symbol, a top-level interface))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 959c26be1f4179202b0ed42f58076190d4d590d5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b31f8f35f871bacaa1e817a6a88028d74aa010c2 — the merge of head 8d430e0cdaf1b7a8032c1b54e7497d601e645262 into base 959c26be1f4179202b0ed42f58076190d4d590d5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 959c26be1f4179202b0ed42f58076190d4d590d5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

席位核对 · ACCEPT · head 8d430e0c · 2026-11-05T05:15Z

Head-sha: 8d430e0

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.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 05:16
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit 8b117c0 Nov 5, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-o05z5l branch November 5, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-queue: a job whose worker dies mid-handler stays running forever — leaseMs promises a reclaim nothing performs

1 participant