Repository navigation
Commit 771b2db
fix(core): an idle DispatchLoop wakes at the next due time, and a sole writer skips the cold-start ramp (#30225)
<!-- claude-projects-attribution: {"github_login":"os-bill"} -->
_Requested by **Bill** · [project
thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgFmm31mRxusPCgeS2bBQHgg)_
domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
## 维护者速读
-
改了什么:投递调度器和任务队列共用的轮询循环,空闲时改为一次读出"下一条到期时间"并睡到那时;宿主声明自己是唯一写入方时(云端租户内核),开机不再从
0.5 秒起一倍倍退避。
- 为什么改:全新内核第一分钟里三个循环对着空表白轮询约 30 条语句;同进程写入本来就会立即唤醒循环。现在单写者内核每个循环第一分钟只跑一次。
- 风险与回滚:默认不开单写者,老宿主只多一条"到期时间"读(每段空闲一次);重试、静默时段、摘要窗口改为准点发出。回滚即撤本
PR,无数据迁移。
- 先看:`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/core` `dist/index.d.ts` gains
`DispatchLoopOptions.nextDueAt`, `DispatchLoopOptions.soleWriter` and
`DispatchLoop.wakeAt`; `@objectstack/service-messaging` gains optional
`INotificationOutbox.nextDueAt` / `IHttpOutbox.nextDueAt`, the
`NextDueOptions` / `HttpNextDueOptions` types, `soleWriter` on both
dispatchers' options and
`MessagingServicePluginOptions.dispatchSoleWriter`;
`@objectstack/service-queue` gains `DbQueueAdapterOptions.soleWriter`.
Every addition is optional; nothing is removed or narrowed.
Metadata-protocol: none
## What changes
**`DispatchLoop` wakes at the next due time (card item 1).** A loop
given `nextDueAt()` asks for the earliest instant work becomes claimable
once per idle streak: after the first empty tick following `start()`, a
tick that claimed work, a `wake()`, 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 new `wakeAt(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` (default `false`) on `DispatchLoopOptions`, on both
dispatchers, as `MessagingServicePlugin` `dispatchSoleWriter`, and on
`DbQueueAdapterOptions` (reachable as `QueueServicePlugin`
`db.soleWriter`) makes an empty tick sleep straight to the next due time
or the ceiling instead of re-running the ramp from `intervalMs`. 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:
| Loop | Read | Sources |
|---|---|---|
| `NotificationDispatcher` | `SqlNotificationOutbox.nextDueAt`, 2
SELECTs | earliest future `next_attempt_at` of a `pending` row (retry,
quiet hours, digest window close), oldest `in_flight` `claimed_at` +
`claimTtlMs` + 1 (the reaper takes a claim strictly older than the TTL)
|
| `HttpDispatcher` | `SqlHttpOutbox.nextDueAt`, 2 SELECTs | earliest
future `next_retry_at`, oldest `in_flight` claim + TTL + 1 |
| `DbQueueAdapter` | 1 SELECT over the subscribed queues | earliest
future `scheduled_for` |
The queue has no lease expiry to read: every write that returns a row to
`pending` clears `locked_until`, so no lease gates a `pending` row. The
in-flight read uses the reaper's `status, claimed_at` index; the
deferred reads sort the deferred `pending` rows, 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 calls `wakeAt`, so the row goes out at its
`scheduled_for` even 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 whatever `partitionCount` is (the old `1 + partitionCount`
figure 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); `dispatchSoleWriter` and the queue's `db.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.mdx` and `api/environment-routing.mdx` were 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 `query` events on
the real `ObjectQL` + `SqlDriver` better-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.
| Loop, fresh kernel, empty store, first minute, 5-minute ceiling |
before (`main`) | after, `soleWriter` | after, default |
|---|---|---|---|
| `NotificationDispatcher` | 6 ticks × 2 = **12** | 1 tick × 2 + due
read 2 = **4** | 6 ticks × 2 + 2 = 14 |
| `HttpDispatcher` | 6 × 2 = **12** | 2 + 2 = **4** | 14 |
| `DbQueueAdapter` (one queue, 1 s base) | 5 ticks × 1 = **5** | 1 + 1 =
**2** | 6 |
| Total | **29** | **10** | 34 |
Steady state at the ceiling is unchanged: an idle hour at 30 s reads the
due time once (`steady state costs no extra read` pin, 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
<details><summary>New pins (fake timers, exact instants)</summary>
- `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). `soleWriter` alone
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. `wakeAt` costs 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 for
`wakeAt`.
- `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 #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) and
`http-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) and
`http-dispatcher-idle-cost.integration.test.ts` (+2), on a real
`ObjectQL` + `SqlDriver`: `nextDueAt` on 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.
</details>
<details><summary>Ablations: each mutation made through
scripts/ablation-replace.mjs, landed on disk, red, then restored with
blob == HEAD</summary>
All three suites resolve `@objectstack/core` to its source through their
vitest alias, so no `dist/` rebuild is involved.
| Mutation | Suite | Red |
|---|---|---|
| A1: put the ramp back (delete `if (this.soleWriter) return
this.maxIdleIntervalMs;`) | core | 3 (ticks ONCE; soleWriter alone;
steady state) |
| A1 | queue | 1 (sole writer first minute) |
| A1 | messaging | 3 (both dispatchers' first minute, and the deferred /
claim legs) |
| A2: ignore the due time in `nextDelay` | core 5 · queue 2 · messaging
3 | every due-time leg |
| A3: drop the guard that discards a due time at or before the tick's
start | core 2 | the not-chased leg and the rejected-after-due leg spin
|
| A4: drop the queue's `wakeAt` on a deferred publish | queue 1 |
deferred in-process publish |
</details>
## Acceptance notes
- Item 2 lands as `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-minute `dispatchMaxIdleIntervalMs`, each dispatcher ticks once in the
first minute. The queue ticks once only after cloud also raises
`db.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.
- Default hosts: the ramp is unchanged.
`content/docs/automation/webhooks.mdx` is updated in this PR (see Docs
above); its stale `1 + partitionCount` figure is corrected here.
- No `packages/spec` key was added. Seat 4's #29918 adds no host signal,
so nothing is shared with it.
## Verification
<details><summary>Local gates and suites (head sha in the
os-dev-report)</summary>
Head `def0361dc` (after merging `origin/main` at `dfafae813`; no
incoming commit touches `packages/core`, `service-messaging` or
`service-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 typecheck` on the same three: exit 0 (core includes
`check:test-typecheck`).
- `pnpm lint`: exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands`: 95 derived families.
The 65 code families ran at `def0361d` (65 × exit 0; the docs commit
touches no code), and the 30 families the docs page adds ran at
`a1a9fbd3` (30 × exit 0). `--ran` reconciliation: 95 derived, 95 run, 0
NOT-MEASURED. Readings: project file
`server-seat5/gates-29919-ran-r2.txt`.
- Before the merge the same list ran on `2d31b756` with three exit 3
(prerequisites: `check-plugin-teardown-shape --self-test` on a shallow
clone, `check:dual-build-cjs-loads` and `check:i18n` without the built
CLI closure). On this head all three ran after `git fetch` of the
fixture commit and the gate's own named build: exit 0.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Utp6CY2ugcVZ5KKbTbWpXw)_
<!-- signed: domain:server#5 · os-bill ·
session_01Utp6CYVV6fcAQrNTDQRvPb -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent bb86ff9 commit 771b2db
21 files changed
Lines changed: 1026 additions & 55 deletions
File tree
- .changeset
- content/docs/automation
- packages
- core/src
- services
- service-messaging/src
- service-queue/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
452 | 452 | | |
453 | 453 | | |
454 | 454 | | |
455 | | - | |
456 | | - | |
457 | | - | |
458 | | - | |
459 | | - | |
460 | | - | |
461 | | - | |
462 | | - | |
463 | | - | |
464 | | - | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
465 | 488 | | |
466 | 489 | | |
467 | 490 | | |
| |||
697 | 720 | | |
698 | 721 | | |
699 | 722 | | |
700 | | - | |
| 723 | + | |
701 | 724 | | |
702 | 725 | | |
703 | 726 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
0 commit comments