Skip to content

fix(service-queue,core): the idle job-queue poll backs off, on the one shared DispatchLoop - #18134

Merged
claude[bot] merged 4 commits into
mainfrom
claude/issue-17612-idle-poll-backoff
Sep 14, 2026
Merged

claude[bot] merged 4 commits into
mainfrom
claude/issue-17612-idle-poll-backoff

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #17612

Scope item 3 (空闲轮询) — the last open item on this card. Items 1, 2 and 4 landed in #18105 and are re-verified untouched on origin/main here.

Clause-②: yes

What the card asked, and what was measured first

The card says item 3 should reuse #17610's backoff rather than write a second one, and the dispatching seat pre-ruled out the two cheap shapes: option D (a second backoff inside service-queue) by the card's own text, option B (exporting the loop from service-messaging and depending on it from service-queue) as an inverted dependency direction. The remaining direction is A — lift the loop somewhere both can depend on.

The escape hatch was to stop and report if A turned out disproportionate to the remaining benefit, since items 1/2 already removed the per-tick cost and only the statement count while idle was left. Both sides were measured before any code moved:

reads per idle hour, one registered queue
before (flat 1 s setInterval) 3601
after (DispatchLoop, 1 s base / 30 s ceiling) 124

That is a 29x reduction, linear in the number of registered queues, and on a remote driver every one of those reads is an HTTP round trip. The cost of A turned out to be small: service-queue and service-messaging already both depend on @objectstack/core, so the lift adds zero new dependency edges — it is one file move, one export, and two import re-points. A is not disproportionate to a 29x cut, so A was built rather than reported as a stop.

What changed

  • packages/services/service-messaging/src/dispatch-loop.ts moves to packages/core/src/dispatch-loop.ts and is exported from core's index. It is a timing primitive owned by neither the messaging domain nor the queue domain; core is the package all three consumers already depend on. service-messaging publishes only its index, which never carried the loop, so nothing published moved — its two dispatchers just import from @objectstack/core now.
  • DbQueueAdapter.start() runs that loop instead of a bare setInterval. The loop also subsumes the old running re-entrancy flag, which was doing tick coalescing by hand.
  • New option DbQueueAdapterOptions.maxIdleIntervalMs (default 30 s). At or below pollIntervalMs it disables the backoff and restores the flat poll exactly.
  • publish() and replay() wake the loop, so the ceiling is never on the latency path for work this process was told about. A deferred publish() deliberately does not wake it — that tick would claim nothing and would throw the backoff away for free.

Evidence

Tests: service-queue 78 passed (70 before + 8 new legs), service-messaging 460, core 1316, and the downstream consumers plugin-email 468 and plugin-audit 334 — all on the final head. typecheck green for all three edited packages, and it demonstrably reaches *.test.ts (it caught a real error there during this work).

The flat-poll leg is a negative control, not decoration. An upper bound on statements is satisfied by a worker that stopped ticking altogether — the one failure a low number cannot distinguish. So the same clock, engine and counter are run with the backoff disabled and pinned at 3601; that is what makes 124 a reading about the backoff rather than about a dead loop.

Reverse verification — two legs, each mutated on disk (anchor count 1 before, injected marker 1 / removed anchor 0 after), run, restored with git checkout HEAD --, and proved byte-identical by blob hash with git diff HEAD empty:

ablation predicted observed
the backoff itself, in core/src/dispatch-loop.ts RED RED — 1 failed / 7 passed, expected 3601 to be 124
the wake seam in DbQueueAdapter.publish() RED on the wake leg RED — 2 failed / 6 passed (wake leg + the drain leg)

The first ablation reads 3601 — independently reproducing the before number from a second direction. It also proves the new vitest alias really reaches core's source: no rebuild happened anywhere in that leg, and the verdict still moved.

Clause-② re-derivation, from the delivered diff. Reachability was taken as the two discriminating reads, not the word export and not a bundle grep:

  • .d.ts export list — core's dist/index.d.ts (the file exports['.'].types names) carries DispatchLoop, type DispatchLoopOptions and DEFAULT_MAX_IDLE_INTERVAL_MS inside its single export { ... } statement.
  • Runtime in await import(entry) through the package entry (resolved to packages/core/dist/index.js via the exports map), with controls both ways:
symbol role result
DispatchLoop subject REACHABLE
DEFAULT_MAX_IDLE_INTERVAL_MS subject REACHABLE
DispatchLoopOptions subject, type-only not reachable at runtime, by design; present in the .d.ts export list
ObjectKernel, LiteKernel positive controls, published before this diff REACHABLE
DispatchLoopTimer negative control, plausible sibling not reachable
dispatchLoop negative control, wrong case not reachable

files is ["dist", ...], so that entry ships. A new symbol is reachable from a shared package's published entry ⇒ Clause-② is yes, and the changeset grades @objectstack/core minor accordingly (never patch).

Gate denominator, reconciled against scripts/pm/dispatch-gates.mjs --ran on the final head 95070c921: 73 derived, 70 run, 3 NOT MEASURED, 0 UNRUN. The three are check:dual-build-cjs-loads, check:i18n and check:type-check-debt, each of which exited 3 — the code those gates use for "prerequisite not met, nothing was measured", all three wanting a whole-repo pnpm build. They are declared NOT MEASURED rather than read as green; CI builds fresh and runs them.

Three gates went red against my own diff during this work and were fixed, not routed around: check:engine-double-contract (the new double's update() now opens with assertEngineUpdateDispatch), check:objectql-double-limit (the double applies the caller's limit by presence, so limit: 0 is not silently widened to the whole table), and check:test-source-alias (the package now aliases @objectstack/core to source).

scripts/engine-double-contract.pinned.json grew by 2 rows — one delete, one update, both for the new test file — from 800 entries / 875 pinned-sum on main to 802 / 877.

Acceptance notes

  • The consumer radius was swept by construction site rather than by edited package: every new DbQueueAdapter in the repo was enumerated, and every test site passes autoStart: false and drives pollOnce() by hand, so the timer change reaches none of them. The one production construction is QueueServicePlugin, whose destroy() already awaits stop().
  • start() now runs its first tick immediately, where the old setInterval waited one interval. That is strictly lower latency and no test depended on the delay.
  • Observation, not filed: the sibling engine doubles in db-queue-adapter.test.ts and job-queue-retention.test.ts apply limit by truthiness (if (opts.limit)), the same shape check:objectql-double-limit rejected in the new file. They are inside that gate's existing baseline, so it already knows; a fixture that asked for limit: 0 would read the whole table.

Generated by Claude Code

…@objectstack/core

The idle-backoff timer loop lived in service-messaging/src/dispatch-loop.ts,
unexported. A third polling worker (service-queue's DbQueueAdapter) needs the
same mechanism, and neither a second copy nor a queue -> messaging dependency
is acceptable. Both services already depend on @objectstack/core, so the loop
moves there and is exported from its index.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
…ing flat at 1s

DbQueueAdapter.start() ran a flat setInterval, so a registered-but-idle queue
issued one candidate read a second forever -- 3600 an hour per queue, every one
an HTTP round trip on a remote driver. It now runs the shared DispatchLoop, and
publish()/replay() wake it so local work keeps its base-interval latency.

Measured on the engine boundary over one simulated idle hour, one queue:
3601 reads with the backoff disabled, 124 with it on.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
…ct and to core source

The new idle-cost double is opened with assertEngineUpdateDispatch alongside
assertEngineDeleteDispatch, and applies the caller's limit by presence so a
`limit: 0` is not silently widened to the whole table. vitest now aliases
@objectstack/core to its source, so these verdicts are about the loop in this
checkout rather than about core's build state.

Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/service-messaging, @objectstack/service-queue, touching 15 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/services/service-messaging/src/dispatcher.ts, packages/services/service-messaging/src/http-dispatcher.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/jobs.mdx (via intervalMs (symbol, a field of class DispatchLoop; a field of interface DispatchLoopOptions))
  • content/docs/automation/webhooks.mdx (via intervalMs (symbol, a field of class DispatchLoop; a field of interface DispatchLoopOptions), maxIdleIntervalMs (symbol, a field of class DispatchLoop; a field of interface DbQueueAdapterOptions; a field of interface DispatchLoopOptions))
  • content/docs/protocol/kernel/lifecycle.mdx (via intervalMs (symbol, a field of class DispatchLoop; a field of interface DispatchLoopOptions))
What this run could not see
  • 4 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/services/service-messaging/src/dispatcher.ts, packages/services/service-messaging/src/http-dispatcher.ts, …) — pages documenting those are invisible to this run
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 24 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 a90a9f26794e5a2c34c1eded83ba0e25087e4433packageMentionDocs.

Which tree this was computed on

This run read content/docs from eff93004cf2f4bebfd801bc06ac55a8ec4e62f4f — the merge of head 95070c921484eabf579456da80a0d6f7aef84446 into base a90a9f26794e5a2c34c1eded83ba0e25087e4433, 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 eff93004cf2f4bebfd801bc06ac55a8ec4e62f4f && git checkout eff93004cf2f4bebfd801bc06ac55a8ec4e62f4f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a90a9f26794e5a2c34c1eded83ba0e25087e4433 95070c921484eabf579456da80a0d6f7aef84446 && git checkout -B drift-repro a90a9f26794e5a2c34c1eded83ba0e25087e4433 && git merge --no-ff 95070c921484eabf579456da80a0d6f7aef84446

node scripts/docs-audit/affected-docs.mjs --json a90a9f26794e5a2c34c1eded83ba0e25087e4433

⚠️ 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 a90a9f26794e5a2c34c1eded83ba0e25087e4433 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 14, 2026
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Served-tier: CONTRACT_REVIEW_TIER

Contract review

Reviewed head: 95070c921484eabf579456da80a0d6f7aef84446 — PR #18134 still points here at review time (2026-09-14T06:22Z); 38 check runs on it, 0 failed (8 skipped by design). Independent review: nothing below is taken from the PR body, the dev report or the dispatching seat's conclusions — every row is re-measured on this head in a fresh checkout (pnpm install --frozen-lockfile, closures rebuilt).

Implemented-by: claude/issue-17612-idle-poll-backoff
Reviewed-by: session_012TrjHGU176HeupgyBu9wow

① Derived judgments (declaration Clause-②: yes)

Reachability was read from packages/core/package.json (exports['.']dist/index.d.ts / dist/index.js / dist/index.cjs; files: ["dist", …]) after rebuilding core at this head — ⛔ not from the word export and ⛔ not from a bundle grep. Two discriminating reads: the top-level export { … } list of dist/index.d.ts, and 'Symbol' in await import(entry) on BOTH runtime entries (index.js via ESM, index.cjs via require.resolve('@objectstack/core', { paths: [service-queue] })).

symbol .d.ts export list index.js index.cjs judgment
DispatchLoop listed reachable (function) reachable correct — new published runtime symbol
DEFAULT_MAX_IDLE_INTERVAL_MS listed reachable (30000) reachable correct — new published runtime symbol
DispatchLoopOptions listed as type absent absent correct — type-only, by design
ObjectKernel, LiteKernel (positive controls) listed reachable reachable probe reads the published set
DispatchLoopTimer, dispatchLoop (negative controls) absent not reachable not reachable probe discriminates
  • DbQueueAdapterOptions.maxIdleIntervalMs — a new key on an already-published payload: DbQueueAdapterOptions is export type from packages/services/service-queue/src/index.ts:8. Correctly named in the changeset. This alone is a clause-② floor hit on service-queue, independent of core.
  • DbQueueAdapter.wake() — a new PUBLIC method on the published class (index.ts:7). ⚠️ The dev's re-derivation lists only the three core symbols and the option key; wake() is a real widening of service-queue's surface that the changeset prose does not name. The minor grade already covers it, and IQueueService (@objectstack/spec) is untouched, so this is an evidence gap in the write-up, not a wrong claim — recorded, not blocking.
  • @objectstack/service-messaging: "nothing published moved" — verified true. On origin/main its exports map has only "." and src/index.ts never re-exported dispatch-loop.ts or DEFAULT_MAX_IDLE_INTERVAL_MS. Rebuilt at this head: the .d.ts export list carries none of DispatchLoop / DispatchLoopOptions / DEFAULT_MAX_IDLE_INTERVAL_MS; the runtime entry reports false for all three and true for NotificationDispatcher (control). dispatcher.ts:52 still re-exports the constant from core, but that module is not a published entry.
  • Transitive: packages/runtime and plugin-hono-server do export * from '@objectstack/core', so they gain the three names. .changeset/config.json has updateInternalDependencies: "patch" and runtime's CHANGELOG shows the established pattern is the automatic Updated dependencies bump, not a hand-written entry — accepted as repo convention.

② Semver grading

package declared what it did verdict
@objectstack/core minor 3 new published exports (2 runtime, 1 type) matches
@objectstack/service-queue minor new option key, new public wake(), new idle behaviour matches
@objectstack/service-messaging patch 2 import lines re-pointed (dispatcher.ts, http-dispatcher.ts), a test comment; published surface byte-identical in the export list; same loop code runs matches — genuinely only re-points imports

③ Boundary flags

  • Design choice (lift to core). Dependency claim verified in the manifests: service-queue{core, platform-objects, spec}, service-messaging{core, platform-objects, spec, types}, core{spec, types, zod}. No package.json changes in the diff, so zero new edges is literally true. No cycle or inversion: core imports nothing from either service (the one "service-messaging" hit in packages/core/src is a comment in index.ts), and dispatch-loop.ts has no imports at all. The card's 「复用而非另写一套」 is honoured: git records a rename, not a copy, and messaging's own idle-backoff pins now run against core's SOURCE through its pre-existing alias (service-messaging/vitest.config.ts:32) — 460/460 green. Accepted.
  • Ledger. scripts/engine-double-contract.pinned.json diffed row-by-row against origin/main: entries 800 → 802, pinned-sum 875 → 877; exactly two rows added (db-queue-idle-backoff.test.ts × delete / update, pinned 1 each), 0 dropped, 0 changed, $comment identical. check:engine-double-contract on head reads the same 802. Accepted.
  • Three gates. Each run on this head with --self-test: check:engine-double-contract exit 0, check:objectql-double-limit exit 0 (its own output: "baseline key set verified against a90a9f2: no files added" — the new double is JUDGED, not grandfathered), check:test-source-alias exit 0. The three gate scripts and all their baselines (engine-double-contract.baseline.json, .seams.json, objectql-double-limit.baseline.json) are byte-identical to main. The alias is the anchored array form the gate prescribes. Genuinely fixed, not routed around, weakened or baselined.
  • Docs. None of the three flagged pages names DispatchLoop, dispatch-loop, or a package location for the loop (nothing in content/docs outside releases does). jobs.mdx:87,95 intervalMs is the job-schedule trigger field; lifecycle.mdx:696,706 is PluginHealthMonitor; webhooks.mdx:381-398,624 describe HttpDispatcher "in @objectstack/service-messaging" ticking from intervalMs up to maxIdleIntervalMsHttpDispatcher still lives there and still exposes those options. The drift rows are homonym hits. No page is inaccurate; no edit owed. (service-queue/README.md:62 pollIntervalMs: 1000 // worker poll cadence remains true as the base cadence.)
  • Behaviour change (first tick immediately). Verified in DispatchLoop.start(). The one production site, QueueServicePlugin, constructs with autoStart defaulting to true, so the immediate tick now runs inside the constructor before any subscribe()pollOnce() returns 0 without touching the engine when no handler is registered, so that tick issues no read. All 15 test-side constructions pass autoStart: false (15/15 grep). Accepted. ⓘ Observation for the seat, not a contract error: subscribe() does not wake(), so a queue registered after the loop has backed off sees rows already persisted in sys_job_queue up to maxIdleIntervalMs late rather than ≤ 1 s; this is inside the documented latency class ("a row another node wrote") and every locally published or replayed row wakes the loop.

Last-defence checks

  • No test skipped, disabled, quarantined or weakened. Diff grep for .skip / .only / .todo / xit / removed it( lines: none. Test files touched: one added, one comment-only line in http-dispatcher-idle-backoff.test.ts.
  • The control exists and discriminates. db-queue-idle-backoff.test.ts runs the NEGATIVE CONTROL leg first (maxIdleIntervalMs: BASE, the documented "no backoff" setting) on the same counting engine and fake clock, pinned at 3601. I ablated three ways, each restored to the HEAD blob hash with a clean tree: (a) backoff disabled in core/src/dispatch-loop.ts → the 124 leg fails with expected 3601 to be 124 (1 failed / 7 passed) — reproduces the before number and proves the alias reaches core source with no rebuild; (b) dead loop (schedule() never arms the timer) → BOTH legs fail, the control reading expected 1 to be 3601 — so 124 cannot be a stopped loop; (c) publish() wake seam removed → the wake leg and the vacuity-trap leg fail (2 failed / 6 passed).
  • Wake seam keeps the ceiling off the latency path. publish() wakes after the insert only when Date.parse(scheduledFor) <= now (db-queue-adapter.ts:377); replay() wakes unconditionally after its update (:454); DispatchLoop.wake() resets the exponent and ticks at once or coalesces into one follow-up; wake() is a no-op once stopped (pinned). The wake leg asserts pickup ≤ BASE.
  • Suites on this head, local: service-queue 78/78, service-messaging 460/460, core 1364/1364 (the dev reported 1316 for core — a count delta I could not attribute; all green either way), typecheck OK for all three, eslint --no-inline-config clean on the 7 changed source files. No model identifier in the diff or commit messages. node scripts/pm/check-clause2-carriers.mjs --pair 18134 exit 0 before this record.

Verdict: PASS


Generated by Claude Code

@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

needs:contract-review cleared from BOTH carriers — clause-② contract review PASS.

Record of governing verdict: comment 5659878791 on this PR, judging head 95070c921484eabf579456da80a0d6f7aef84446 — the head this PR still points at, re-read immediately before this clear.

  • Served-tier: CONTRACT_REVIEW_TIER — the constant's NAME. ⛔ No model identifier anywhere in the record (checked).
  • Implemented-by: claude/issue-17612-idle-poll-backoff — a mode:subagent dev, named by its BRANCH.
  • Reviewed-by: session_012TrjHGU176HeupgyBu9wow — a DIFFERENT identity, so the independence pair holds.

Three things the review measured rather than accepted, worth reading:

  1. A surface the write-up missed. DbQueueAdapter.wake() is a new public method on a published class (service-queue/src/index.ts:7), and the dev's clause-② re-derivation named only the three core symbols plus DbQueueAdapterOptions.maxIdleIntervalMs. The minor grade already covers it and IQueueService (@objectstack/spec) is untouched — so this is an evidence gap in the write-up, ⛔ not a wrong claim. Recorded, not blocking.

  2. The docs flags were homonyms — this seat's concern was unfounded. I asked the reviewer whether the three pages naming intervalMs / maxIdleIntervalMs had gone stale now that DispatchLoop moved packages. Measured: none of them names DispatchLoop at all. jobs.mdx is the job-schedule trigger field, lifecycle.mdx is PluginHealthMonitor, and webhooks.mdx describes HttpDispatcher — which still lives in @objectstack/service-messaging and still exposes those options. ⇒ no page is inaccurate and ⛔ no doc edit is owed.

  3. The negative control was itself verified. The 3601 → 124 idle-read measurement only means something if 124 is a reading about backoff rather than about a stopped loop. The reviewer ablated three ways, including making the loop dead — under which both legs fail and the control reads expected 1 to be 3601. That is what rules out the vacuous reading.

Also verified independently: the ledger diffed row-by-row (entries 800 → 802, pinned-sum 875 → 877, exactly two rows added, 0 dropped, 0 changed, $comment identical); the three gates the dev turned red are genuinely fixed with all baselines byte-identical to main, ⛔ not routed around or baselined away; and no test was skipped, disabled, quarantined or weakened.

ⓘ Non-blocking observation carried forward for whoever next touches this area: subscribe() does not wake(), so a queue registered after the loop has backed off sees already-persisted rows up to maxIdleIntervalMs late rather than ≤ 1 s. That sits inside the documented latency class (work this process was not told about); every locally published or replayed row wakes the loop.


Generated by Claude Code

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/m tests tooling

Projects

None yet

2 participants