Repository navigation
fix(core): an idle DispatchLoop wakes at the next due time, and a sole writer skips the cold-start ramp - #30225
Conversation
… for a sole writer (#29919)
…rt (#29919) Claude-Session: https://claude.ai/code/session_01Utp6CY2ugcVZ5KKbTbWpXw Co-authored-by: Claude <noreply@anthropic.com>
…tegration pin (#29919) Claude-Session: https://claude.ai/code/session_01Utp6CY2ugcVZ5KKbTbWpXw Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CY2ugcVZ5KKbTbWpXw Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 39 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 65b56e032c75904131b69ce3edc796481411694f && git checkout 65b56e032c75904131b69ce3edc796481411694f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7 a1a9fbd308c37468d652a5f60daa7cde68e4c095 && git checkout -B drift-repro ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7 && git merge --no-ff a1a9fbd308c37468d652a5f60daa7cde68e4c095
node scripts/docs-audit/affected-docs.mjs --json ff12aed37b35ecf2cc1d8fd9a3f609fa79dfa6e7
|
…s at the next due time, and dispatchSoleWriter / db.soleWriter skip the ramp (#29919) Claude-Session: https://claude.ai/code/session_01Utp6CY2ugcVZ5KKbTbWpXw 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 #29919; Clause-②: / Metadata-protocol: at line start. |
| Acceptance | Item 1: DispatchLoop.nextDueAt reads once per idle streak, with guards against chasing a due time at or before the empty tick and against a rejected read (pins in dispatch-loop.test.ts, 15). Item 2: soleWriter default false on the loop, both dispatchers, MessagingServicePlugin.dispatchSoleWriter and DbQueueAdapterOptions; first-minute count 29 → 10 with CONTROL 6-tick ramps. "No signal in packages/spec" is stated with its reason (TS plugin options). |
| Gates | Check-runs on a1a9fbd3: 59 success, 10 skipped, 0 failure, 0 pending. Dev's local reconciliation 95/95/0. |
| Declarations | Clause-②: yes (widening), measured on the built dist/index.d.ts of core, service-messaging and service-queue; every addition optional, nothing removed. Widening → this seat's review is the contract review (TIER_DEFAULT). Metadata-protocol: none; changeset patch. |
| Docs | automation/webhooks.mdx fixed at the seat's ask (idle cost two reads, not 1 + partitionCount; next-due wake; dispatchSoleWriter / db.soleWriter: who turns them on, default off; crash row). kernel/cluster.mdx, plugins/packages.mdx, api/environment-routing.mdx read: nothing made false. packages.mdx:287's lease promise belongs to #30228. |
| Out of scope | filed #30228 (queue running rows never reclaimed: the queue has no lease expiry to read, as the body says). |
| Landing | Not governed (0 of 21 paths), 1081 changed lines (< 3000). Seat lands: pr_ready + automerge_enable. After merge: seat 4 (mailbox #30189) is told of the soleWriter keys; #30228 merges main and builds acceptance 3 on this code. |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 41961141659 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Requested by Bill · project thread
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
维护者速读
packages/core/src/dispatch-loop.ts(nextDelay与防空转判断)、packages/services/service-queue/src/db-queue-adapter.ts(延迟发布改用wakeAt)、packages/services/service-messaging/src/sql-outbox.ts(nextDueAt两条读)。pnpm --filter @objectstack/core exec vitest run --project local src/dispatch-loop.test.ts,预期 15 条全绿,其中"fresh loop ticks ONCE in its first minute"对照组为 6 次。Fixes #29919
Clause-②: yes (widening) — measured on the built entry closure:
@objectstack/coredist/index.d.tsgainsDispatchLoopOptions.nextDueAt,DispatchLoopOptions.soleWriterandDispatchLoop.wakeAt;@objectstack/service-messaginggains optionalINotificationOutbox.nextDueAt/IHttpOutbox.nextDueAt, theNextDueOptions/HttpNextDueOptionstypes,soleWriteron both dispatchers' options andMessagingServicePluginOptions.dispatchSoleWriter;@objectstack/service-queuegainsDbQueueAdapterOptions.soleWriter. Every addition is optional; nothing is removed or narrowed.Metadata-protocol: none
What changes
DispatchLoopwakes at the next due time (card item 1). A loop givennextDueAt()asks for the earliest instant work becomes claimable once per idle streak: after the first empty tick followingstart(), a tick that claimed work, awake(), or the arrival of the due time it was sleeping towards. It then never sleeps past that instant. The answer is remembered, not re-read on every ceiling tick, so an idle loop's steady-state cost does not move. A deferred row written in this process later in the streak reaches the loop through the newwakeAt(dueAtMs), which moves the timer and costs no read and no tick.Two guards keep it from spinning. A due time at or before the start of the tick that just found nothing is dropped, because that work was visible and not claimable by this loop (another node holds its partition, for example). A rejected tick or a rejected read counts as idle and sleeps by the backoff.
The single-writer signal (card item 2). No existing host signal says "I am the only writer", and none is needed in
packages/spec: the dispatchers' and the queue's options are TypeScript plugin options.soleWriter(defaultfalse) onDispatchLoopOptions, on both dispatchers, asMessagingServicePlugindispatchSoleWriter, and onDbQueueAdapterOptions(reachable asQueueServicePlugindb.soleWriter) makes an empty tick sleep straight to the next due time or the ceiling instead of re-running the ramp fromintervalMs. Item 1 alone does not meet "Done when": without the flag the ramp keeps its shape (the CONTROL legs below read 6 ticks in the first minute), and only the due time cuts it short.Due-time sources, each read only after an idle tick:
NotificationDispatcherSqlNotificationOutbox.nextDueAt, 2 SELECTsnext_attempt_atof apendingrow (retry, quiet hours, digest window close), oldestin_flightclaimed_at+claimTtlMs+ 1 (the reaper takes a claim strictly older than the TTL)HttpDispatcherSqlHttpOutbox.nextDueAt, 2 SELECTsnext_retry_at, oldestin_flightclaim + TTL + 1DbQueueAdapterscheduled_forThe queue has no lease expiry to read: every write that returns a row to
pendingclearslocked_until, so no lease gates apendingrow. The in-flight read uses the reaper'sstatus, claimed_atindex; the deferred reads sort the deferredpendingrows, and an idle store has none.A deferred
publish()used to skip the wake on purpose, so as not to reset the backoff. It now callswakeAt, so the row goes out at itsscheduled_foreven under a minutes-long ceiling.Docs (head
a1a9fbd3, a docs-only commit the seat asked for at review).content/docs/automation/webhooks.mdx: an idle tick costs two reads whateverpartitionCountis (the old1 + partitionCountfigure was stale since #28383); a new paragraph on waking at the next due time (a retry is attempted when due, a crashed node's claim is recovered when its TTL passes; the backoff is left only for rows another process wrote);dispatchSoleWriterand the queue'sdb.soleWriter: what they do, who turns them on (one kernel per tenant database), who leaves them off (a second node or worker on the same database), default off; the crash row of the failure table matches.kernel/cluster.mdx,plugins/packages.mdxandapi/environment-routing.mdxwere read and nothing in them is made false by this PR.Statement counts, before and after
Taken on this repo's harnesses, at the client (knex
queryevents on the realObjectQL+SqlDriverbetter-sqlite3 harness for the outboxes; engine calls on the counting engine for the queue). The card's figures were taken at the Turso client in cloud, which counts more per read, so the absolute numbers differ. The tick counts are the same.main)soleWriterNotificationDispatcherHttpDispatcherDbQueueAdapter(one queue, 1 s base)Steady state at the ceiling is unchanged: an idle hour at 30 s reads the due time once (
steady state costs no extra readpin, 121 ticks, 1 read). Without the flag, an idle stretch costs one extra read at its start. That is the price of "a deferred row goes out within one tick of coming due" on a multi-writer host.Tests
New pins (fake timers, exact instants)
packages/core/src/dispatch-loop.test.ts(new, 15): a fresh sole-writer loop ticks once in its first minute, with a CONTROL showing the ramp the harness can see (0, 1, 3, 7, 15, 31 s).soleWriteralone goes to the ceiling. Steady state reads the due time once an hour. A row due at T+20 s is claimed at T+20 s under a 5-minute ceiling. The ramp is cut short only by a due time. Consecutive deferred rows are each on time.wakeAtcosts no read and keeps a sooner timer.wake()ticks at once. A rejected tick is idle (one tick per ceiling) and keeps a learned due time without spinning. A due time at or before the empty tick is not chased. A rejected read falls back to the backoff.stop()is final forwakeAt.db-queue-idle-backoff.test.ts(+5, 2 moved): a sole-writer queue reads 2 in the first minute (CONTROL 6), a row scheduled before boot runs at exactly +20 s, a deferred in-process publish runs at exactly +20 s with no extra read, and a failing retry is re-attempted at its 15 s backoff. The two service-queue:DbQueueAdapter.claimBatch按priority, scheduled_for排序,而sys_job_queue的声明索引不含priority—— 排序首键落空,恒定 1s 轮询每跳吃一次临时 B-tree 排序 #17612 exact counts move by the one due read of the streak (3601 → 3602, 124 → 125), with a comment saying so.dispatcher-idle-backoff.test.ts(+3) andhttp-dispatcher-idle-backoff.test.ts(+2): one tick in a sole-writer dispatcher's first minute. A deferred delivery written before boot is sent at +20 s. A crashed node's claim is re-sent at claim TTL + 1 ms. Every HTTP retry is attempted at exactly its due time under a 5-minute ceiling.dispatcher-idle-cost.integration.test.ts(+3) andhttp-dispatcher-idle-cost.integration.test.ts(+2), on a realObjectQL+SqlDriver:nextDueAton an empty outbox is null in exactly 2 SELECT statements. It answers the earliest deferred row, digest rows included, and ignores a row already due. It answers the oldest claim's expiry when that is sooner.Ablations: each mutation made through scripts/ablation-replace.mjs, landed on disk, red, then restored with blob == HEAD
All three suites resolve
@objectstack/coreto its source through their vitest alias, so nodist/rebuild is involved.if (this.soleWriter) return this.maxIdleIntervalMs;)nextDelaywakeAton a deferred publishAcceptance notes
soleWriter/dispatchSoleWriter/db.soleWriter. Cloud turns it on for hosted tenant kernels together with the queue's ceiling under cloud#3413; nothing in this repo turns it on. With cloud's 5-minutedispatchMaxIdleIntervalMs, each dispatcher ticks once in the first minute. The queue ticks once only after cloud also raisesdb.maxIdleIntervalMs(the card says that is cloud's change); at the framework's 30 s default a sole-writer queue ticks at 0 s and 30 s.content/docs/automation/webhooks.mdxis updated in this PR (see Docs above); its stale1 + partitionCountfigure is corrected here.packages/speckey was added. Seat 4's objectql: the lifecycle sweep runs 60 s after every kernel boot, even on a database created seconds earlier, so a fresh tenant kernel pays 130 statements in its first minute #29918 adds no host signal, so nothing is shared with it.Verification
Local gates and suites (head sha in the os-dev-report)
Head
def0361dc(after mergingorigin/mainatdfafae813; no incoming commit touchespackages/core,service-messagingorservice-queue).pnpm --filter @objectstack/core --filter @objectstack/service-messaging --filter @objectstack/service-queue run test: core 134 files / 2989 tests, service-messaging 66 / 812, service-queue 5 / 82, all passed (lock VERDICT command-exit 0).run typecheckon the same three: exit 0 (core includescheck:test-typecheck).pnpm lint: exit 0.node scripts/pm/dispatch-gates.mjs --commands: 95 derived families. The 65 code families ran atdef0361d(65 × exit 0; the docs commit touches no code), and the 30 families the docs page adds ran ata1a9fbd3(30 × exit 0).--ranreconciliation: 95 derived, 95 run, 0 NOT-MEASURED. Readings: project fileserver-seat5/gates-29919-ran-r2.txt.2d31b756with three exit 3 (prerequisites:check-plugin-teardown-shape --self-teston a shallow clone,check:dual-build-cjs-loadsandcheck:i18nwithout the built CLI closure). On this head all three ran aftergit fetchof the fixture commit and the gate's own named build: exit 0.Generated by Claude Code