Repository navigation
Description
repo:cloud#2 · os-zhuang · session_01QUs3BtyTpTkZhBZdWY1Xun
Filing gate ①: a product defect with a named locus and a measured reach. Left over from cloud#3413 after objectstack#28383 (PR #28403) landed; this is item 4 of #28383 ("wake at the next due time instead of a blind ceiling"), which #28403 put out of scope, plus the ramp it would remove.
维护者速读
#28403 把每次空闲轮询降到 2 条读,这部分已经生效。剩下的是 DispatchLoop 的退避爬坡:每次内核启动,循环都从基础间隔(投递调度器 500 毫秒,队列 1 秒)开始一倍一倍地往上退,所以全新内核第一分钟里,两个投递调度器各轮询 5 次(20 条),DbQueueAdapter 也轮询 5 次(10 条)。outbox 和队列都是空的,而同一进程里的写入本来就会立刻唤醒循环,这 30 条都是空转。cloud#3413 的目标是第一分钟不超过 10 条。
Reach (measured)
Measured on cloud's hosted tenant composition (ArtifactKernelFactory with HOSTED_FORCED_REQUIRES, production TursoDriver in remote mode over a local libSQL file, every statement counted at the client), framework pin 118bee4d, with scripts/dev-local/bootstrap-curve.mjs --hosted --objects 0 --first-boot --hold 1800 --hold-slice 60 --hold-shapes in objectstack-ai/cloud, and a control run with OS_LIFECYCLE_DISABLED=1 … --hold 120 that leaves only the loops:
| Loop | Statements a tick | First minute | After it |
|---|---|---|---|
NotificationDispatcher + HttpDispatcher (@objectstack/service-messaging) |
4 (the reap read and the hasDueWork read, each) |
5 ticks = 20 | one tick per cloud's 5-minute ceiling (dispatchMaxIdleIntervalMs) |
DbQueueAdapter (@objectstack/service-queue) |
2 (SELECT * FROM "sys_job_queue" WHERE "queue" = ? AND "status" = ? AND (scheduled_for due) … LIMIT ?) |
5 ticks = 10 | 2 ticks a minute at the framework's 30 s ceiling = 4 a minute |
A 10-second slicing shows the ramp: the dispatchers tick 3 times in the first 10 s, then at about 15 s and 31 s; the queue 2, 1 and 1 times in the first three 10 s slices. At pin 99a6a781 (2026-10-31) the queue issued nothing in the same reading; at 118bee4d it polls.
Expected
An idle loop on a single-writer database costs one tick at start and then sleeps until something can be due:
- Wake at the next due time (service-messaging: an idle delivery dispatcher tick costs 18 + 10 statements (8 partition probes each, an unconditional reap UPDATE), so a resident tenant kernel reads its database 56 times a minute with nothing queued #28383 item 4): after a tick that claimed nothing, one read of the earliest future due time (delivery
next_attempt_at/ digest window close / claim expiry; queuescheduled_for/locked_until), and sleep untilmin(that, maxIdleIntervalMs). Nothing deferred means the ceiling. - No ramp from a cold start. Producers in this process already call
wake()(emit(),enqueueHttp(),publish()), so the ramp only buys latency on rows written by another process. Where the host says it is the only writer (a hosted tenant kernel is: one kernel per tenant database), start the idle delay at the ceiling, or at the next due time with item 1, after the first empty tick.
With both, a fresh kernel's loops cost about 6 statements in the first minute (one tick each) instead of 30.
Cloud's side, for the record: cloud passes the 5-minute ceiling to the delivery dispatchers (cloud#3427) but not yet to DbQueueAdapter (QueueServicePlugin db.maxIdleIntervalMs); that is a cloud change under cloud#3413 and does not need this card.
Done when
The same reading at the new pin shows at most one tick per loop in a fresh kernel's first minute, a deferred row (a delivery retry, a scheduled job) still goes out within one tick of coming due, and wake() still delivers in-process work at once. cloud then re-measures under cloud#3413.
Business reason
- Who benefits: every hosted environment's database reads (Turso quota and bill) on launch day; self-hosted single-node deployments the same.
- If not done: even with the lifecycle sweep fixed (the sibling card, 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), a fresh kernel's first minute stays at about 30 statements, three times cloud#3413's target, and the queue keeps polling at 4 a minute until cloud overrides its ceiling.
Reader who acts
objectstack triage routes it to the lane owning packages/core/src/dispatch-loop.ts, with packages/services/service-messaging (dispatcher due-time read) and packages/services/service-queue/src/db-queue-adapter.ts (queue due-time read).
Dedup
Searched objectstack issues, open and closed: "DispatchLoop idle backoff ramp" (1 hit), "dispatch loop next due time" (no relevant hit beyond #28383) and "DbQueueAdapter idle poll" (3 hits: #28383, #17612, #17610, all closed). #17612 added the queue's idle backoff and #28383 / #28403 cut the dispatchers' per-tick cost; this card is the ramp and the wake time they left. The sibling lifecycle-sweep card is #29918.
Activity
objectstack-fleet commentedon Nov 3, 2026
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U
Path: none
Triage: first-touch audit · p2 kept ·
domain:serverkept ·pm:queue· 2026-11-03T12:54Z⛔ Not a claim.
DispatchLoopre-runs its fast backoff ramp after every boot, so empty outboxes and queues cost 30 first-minute statements. In-process writes already wake the loop. The sibling of 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 (cloud#3413), so p2 kept.Generated by Claude Code
objectstack-fleet commentedon Nov 5, 2026
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
Claim: PM loop round 1
Session:
session_01Utp6CYVV6fcAQrNTDQRvPb(seat) · dev = project card threadsession_01Utp6CY2ugcVZ5KKbTbWpXwAccount:
os-billBranch:
claude/project-thread-ue4hxr(harness-pinned thread branch; platform exception toclaude/issue-<n>-<slug>)Worktree:
objectstack-issue-29919Domain:
domain:serverSeat:
domain:server#5File surface:
packages/core/src/dispatch-loop.tsand tests;packages/services/service-messaging/src/**(dispatcher due-time read);packages/services/service-queue/src/db-queue-adapter.tsand tests; one changeset. ⛔packages/spec/**,ui/**,skills/**,docs/adr/**,.claude/**,AGENTS.md(stop on breach; explain in the report)Container & model:
M,mode:thread,model: default tier (opus) — dispatch-gates --tier at 0fd363d1: no path-derived mandate,effort: high(M)Clause-②: yes (widening) — a new optional member on the exported
DispatchLoopOptions(or a dispatcher/adapter option) widens@objectstack/core's public surface; the dev measures the built entry closure and the seat corrects tonoif no exported type changesMetadata-protocol: none
Responsibility:
@objectstack/core DispatchLoop's idle ramp restarting at every boot, with no next-due-time read (reading at 0fd363d1) | none: #28403 cut the per-tick cost, not the ramp | every hosted tenant kernel and self-hosted single node on main, measured by cloud#3413Thread-read: 6604337552
Premise: holds — at
0fd363d1,DispatchLoopdoubles the idle delay fromintervalMstomaxIdleIntervalMsand resetsidleTickson start andwake(); no loop reads a next due timeSerial constraints cleared:
#29918 (seat 4, objectql lifecycle sweep, sibling card from cloud#3413) is in flight on packages/objectql; no shared file expected. No in-flight claim names these paths.Order: project file
server-seat5/dispatch-29919.md, delivered to the card thread.test: pin the next-due wake and the sole-writer cold start (#29919)
chore(changeset): DispatchLoop next-due wake and sole-writer cold sta…
test(service-messaging): type the claim credential in the next-due in…
Merge origin/main into claude/project-thread-ue4hxr (#29919)
objectstack-fleet commentedon Nov 5, 2026
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
os-dev-report
Card thread
session_01Utp6CY2ugcVZ5KKbTbWpXw· branchclaude/project-thread-ue4hxr· draft PR #30225 · headdef0361dc{ "issue": 29919, "status": "done", "branch": "claude/project-thread-ue4hxr", "pr": "https://github.com/objectstack-ai/objectstack/pull/30225", "session": "session_01Utp6CY2ugcVZ5KKbTbWpXw", "premise_still_valid": true, "summary": "Premise held at b7075d99: DispatchLoop ramps from intervalMs on every start/wake and reads no due time; no host 'single writer' signal exists anywhere. Item 1: DispatchLoopOptions.nextDueAt is read once per idle streak (after start, a claiming tick, a wake, or an arrived due time), never per ceiling tick, and the loop never sleeps past it; DispatchLoop.wakeAt carries an in-process deferred row at no read. Both SQL outboxes and both memory outboxes implement nextDueAt (deferred row + oldest claim expiry), DbQueueAdapter reads the earliest future scheduled_for and its deferred publish now calls wakeAt. Item 2: measured that item 1 alone keeps the ramp (6 ticks in the first minute), so soleWriter was added as TypeScript plugin options (DispatchLoopOptions, both dispatchers, MessagingServicePlugin.dispatchSoleWriter, DbQueueAdapterOptions via QueueServicePlugin db) with no packages/spec key; with it a fresh loop ticks once in its first minute. Assignee untouched; PR opened without assignee or review request.", "tests": "head def0361dc: core 134 files/2989 tests, service-messaging 66/812, service-queue 5/82, all pass (os-verify-lock VERDICT command-exit 0); new pins: core dispatch-loop.test.ts 15, queue +5, messaging unit +5, messaging real-SQLite integration +5. Ablations via scripts/ablation-replace.mjs, each landed (anchor 1 -> 0, blob moved), red, restored (blob == HEAD, git diff HEAD empty); suites resolve @objectstack/core from source through their vitest alias: A1 ramp restored -> core 3 red, queue 1, messaging 3; A2 due time ignored -> core 5, queue 2, messaging 3; A3 at-or-before-tick-start guard removed -> core 2 (spin); A4 queue wakeAt removed -> queue 1.", "gates": "dispatch-gates --commands on def0361dc: 65 derived families, 65 x exit 0, captured before any pipe; --ran reconciliation 65 derived / 65 run / 0 NOT-MEASURED / 0 UNRUN. pnpm lint exit 0. typecheck core, service-messaging, service-queue exit 0. Pre-merge run on 2d31b756 had 3 x exit 3 (teardown-shape self-test on shallow clone, dual-build-cjs-loads and i18n without the CLI closure build); cleared on the final head by fetching the fixture commit and the gate's own named build. CI check-runs on #30225: in_progress at report time.", "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm", "line_budget": "20 files, +992 / -44 before merge (code 4 src files in core/messaging/queue + 1 new helper; the rest tests and one changeset); under the 3000-line class (c) threshold", "deviations": [ "Clause-② measured yes (widening) on built dist/index.d.ts of all three packages: kept as claimed.", "Ablation by mutation through the source alias, no dist rebuild: all three suites alias @objectstack/core to src.", "Thread-designated branch, so the harness's model-named trailer appears on no commit; the first two WIP commits carry no trailer pair, later ones the model-free pair." ], "files_changed": [ ".changeset/29919-dispatch-loop-next-due-wake.md", "packages/core/src/dispatch-loop.ts", "packages/core/src/dispatch-loop.test.ts", "packages/services/service-messaging/src/dispatcher.ts", "packages/services/service-messaging/src/http-dispatcher.ts", "packages/services/service-messaging/src/outbox.ts", "packages/services/service-messaging/src/http-outbox.ts", "packages/services/service-messaging/src/index.ts", "packages/services/service-messaging/src/memory-outbox.ts", "packages/services/service-messaging/src/memory-http-outbox.ts", "packages/services/service-messaging/src/sql-outbox.ts", "packages/services/service-messaging/src/sql-http-outbox.ts", "packages/services/service-messaging/src/next-due.ts", "packages/services/service-messaging/src/messaging-service-plugin.ts", "packages/services/service-messaging/src/dispatcher-idle-backoff.test.ts", "packages/services/service-messaging/src/http-dispatcher-idle-backoff.test.ts", "packages/services/service-messaging/src/dispatcher-idle-cost.integration.test.ts", "packages/services/service-messaging/src/http-dispatcher-idle-cost.integration.test.ts", "packages/services/service-queue/src/db-queue-adapter.ts", "packages/services/service-queue/src/db-queue-idle-backoff.test.ts" ], "mcp_calls": "3 — mcp__github__issue_read (get, get_comments x2); no write tool", "api_writes": "2 — relay pr_create (PR #30225, fw-20261105T022642Z-875ef5, read-back identical 11114 bytes); this comment via post-stamped", "open_questions": [], "security_findings": "none", "out_of_scope_findings": [ "class: b · reach: named producer DbQueueAdapter.claimBatch writes status running with locked_until, and no reader in service-queue ever returns an expired running row to pending · evidence: DbQueueAdapterOptions.leaseMs is documented as 'Lease duration before another worker may reclaim', but claimBatch selects only status pending, and every write back to pending comes from dispatch/releasePending inside the live worker; a worker that dies mid-handler leaves its row running forever · Seam: runtime-option:DbQueueAdapterOptions.leaseMs → runtime:none (no reclaim of running rows) | consumer: none · dedupe words: DbQueueAdapter running lease reclaim, sys_job_queue stuck running, leaseMs never enforced", "carrier: docs lane · noted, not filed: content/docs/automation/webhooks.mdx 4.x still says an idle tick costs 1 + partitionCount statements (stale since #28383) and describes the idle bound without the due-time wake; still true as an upper bound" ] }Report taken 2026-11-05T02:28Z.
docs(webhooks): the HTTP dispatcher's idle tick is two reads, it wake…
objectstack-fleet commentedon Nov 5, 2026
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
Seat ACCEPT on PR #30225 at head
a1a9fbd3·pr_ready+automerge_enablesent 2026-11-05T03:21Z.Fixes #29919closes this card at merge.