fix(service-messaging): HttpDispatcher reaps once per tick, backs off while idle, wakes on enqueueHttp - #17632
Conversation
Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
… while idle, wakes on enqueueHttp Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 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 9512003d64003ec68d55ede30a1970a8da77a730 && git checkout 9512003d64003ec68d55ede30a1970a8da77a730
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 690f083f83d4b864a949c0707f79490116bbae23 3c90f49adfc83499ee922cee9ff2ef26e2fac63c && git checkout -B drift-repro 690f083f83d4b864a949c0707f79490116bbae23 && git merge --no-ff 3c90f49adfc83499ee922cee9ff2ef26e2fac63c
node scripts/docs-audit/affected-docs.mjs --json 690f083f83d4b864a949c0707f79490116bbae23
|
PM 复核(maintainer direct dispatch,Session
|
| 项 | 读数 | 结论 |
|---|---|---|
| 改动面 | 15 个文件;packages/spec/src/** 0;受治理面 0 |
Clause-②: no 成立 |
| changeset | @objectstack/service-messaging: minor;IHttpOutbox.reap() 与 HttpClaimOptions.skipReap 均为可选 |
非破坏性 |
| 文档 | content/docs/automation/webhooks.mdx 写明延迟上界 |
非受治理面,合理 |
抽出的 DispatchLoop |
stop():running=false、清 tickRequested、clearTimeout、等待进行中的一跳;进行中的多次唤醒经 tickRequested 合并为一次后续跳 |
安全,停得干净 |
NotificationDispatcher 公开面 |
start / stop / wake / tick 仍在;DEFAULT_MAX_IDLE_INTERVAL_MS 仍从 ./dispatcher.js 再导出;DispatchLoop 未从包入口导出 |
不破坏既有消费者;#17622 的两套测试不改一行照样通过 |
| HTTP reap | reapExpired 条件 claimed_at: { $lt: now - claimTtlMs } |
条件更新,与通知侧同构 |
| 基线红 / 修复绿 | 仅提交测试时 update 80 / find 80(每跳 16);修复后 update 10 / find 80(每跳 9 = 1 + 分区数) |
满足验收 1 |
开放问题裁定
- 插件选项:两个调度器共用
dispatchMaxIdleIntervalMs(选项 A)——dispatchIntervalMs、partitionCount本就共用;该选项尚未发布(17610的 changeset 仍 pending),放宽不影响任何消费者;日后真有部署需要两个上限不同,再加独立选项也不破坏兼容 redeliverHttp()同样唤醒(选项 A)——它是另一条让行回到pending的服务写入;不唤醒的话运维手动重投要从 500ms 变成最多等 30s
崩溃节点认领的回收上界随之变为 claimTtlMs + 30s(默认约 35s,原约 5.5s),与 #17622 已接受的取舍一致。
范围外发现的去向
- 迟到 ack 覆盖重新认领(
IHttpOutbox.ack(id, …)不带认领凭据)→ service-messaging: a late HTTP ack from a REAPED claim overwrites the live re-claim onsys_http_delivery—IHttpOutbox.ack(id, …)carries no claim credential (the notification outbox fixed this shape in #11859) #17634,P2 correctness SINGLE_NODE_CLUSTER/stableNodeOffset两处重复、WebhookAutoEnqueuer60s 刷新:非缺陷,不立卡
下一步:CI 全部结束且无失败 → 转 ready → 入合并队列(以 timeline added_to_merge_queue 为准)。有红按日志回派。
Closes #17623
HttpDispatcher/SqlHttpOutboxget the pattern PR #17622 landed forNotificationDispatcher— no new design: an optionalreap()on the outbox contract withskipReapon the claim, an idle exponential backoff on asetTimeoutchain thatstop()clears, and wake-on-enqueue (setHttpOutbox(outbox, { onEnqueued })→wake()). The loop itself moves out ofNotificationDispatcherintodispatch-loop.ts, shared by both dispatchers (reasoning under item 5).Measurement — base
690f083f8vs this PRReal
ObjectQL+SqlDriver(better-sqlite3:memory:), 8 partitions, emptysys_http_delivery, 10 manually driven ticks.690f083f8sys_http_delivery, 10 ticksqueryevent, 10 ticksThe card's 16-per-tick figure reproduces exactly. The SQL-statement row is a one-off measurement (a throwaway test hooking
driver.knex.on('query'), deleted before commit). The committed test counts at the engine boundary, as #17622's does; on this harness one engine call is one statement.Acceptance, item by item
1. Bounded statement count on an empty outbox, red on base first.
src/http-dispatcher-idle-cost.integration.test.tspins{ update: TICKS, find: TICKS × PARTITIONS, other: 0 }exactly and the total at ≤TICKS × (1 + PARTITIONS). Run on base (the file committed alone as2235f6973, every non-test file byte-identical to690f083f8):On this PR: 5/5. The bound cannot pass vacuously — the same harness proves rows enqueued after an idle stretch go out on the very next tick in every partition, a crashed node's expired claim is recovered and delivered within ONE tick, and a claim still inside its TTL is left alone.
2. Idle backoff and wake-on-enqueue, each tested; latency bound stated.
src/http-dispatcher-idle-backoff.test.ts(fake timers, 11 legs): doubling 1 s → 30 s cap (24 ticks in 10 idle minutes); 30 s default; ceiling at or belowintervalMsdisables it; an unannounced row waits at most one ceiling and its tick resets the backoff; a retry coming due is attempted less thanmin(delay + intervalMs, maxIdleIntervalMs)late;wake()ticks immediately; wakes during a running tick collapse into one follow-up;stop()cancels the pending tick and a laterwake()is a no-op;enqueueHttp()wakes; anenqueueHttp()that PARKS an undeliverable row (deadon arrival) does not;redeliverHttp()wakes. On base 7 of 11 are red, the doubling leg withexpected [ 500, 500, 500, 500, 500, 500 ] to deeply equal [ 1000, 2000, 4000, 8000, 16000, 30000 ].The composed-plugin leg added to
src/plugin-enqueue-wakes-dispatcher.test.tsboots the real plugin withdispatchIntervalMs60 s and a stubbedglobalThis.fetch, proves a row written straight into the table stayspending(negative control on the same boot), then showsenqueueHttp()POSTs it and the unannounced row at once.The wake sits exactly where
enqueueHttpwrites the delivery row — afteroutbox.enqueue()resolves — and also afterredeliverHttp(), the other service write that leaves a rowpending; without it an operator's redeliver would wait up to the ceiling.Latency bound. A delivery enqueued or redelivered in the dispatcher's own process goes out on the tick
wake()starts — sooner than the old fixed 500 ms. Work nobody announces waits at most one backed-off interval: a row written by another process, at mostmaxIdleIntervalMs(30 s); a retry, less thanmin(its delay + intervalMs, maxIdleIntervalMs)late, because the backoff restarts fromintervalMsat the attempt that scheduled it (a ~1 s retry is under ~1.7 s late); a crashed node's claim, recovered withinclaimTtlMs + maxIdleIntervalMs(~35 s at defaults, was ~5.5 s).3. A legacy outbox is unchanged; partition lock, at-least-once and claim-TTL semantics are unchanged. The dispatcher probes
outbox.reap; when it is absent no claim is told to skip, so each claim reaps itself as before (leg "an outbox without reap() keeps working": 8 claims seen, none withskipReap, the expired claim still delivered in the same tick). Directclaim()callers withoutskipReapreap exactly as before. Lock keys, batch processing andack()are untouched. A newreap()leg indelivery-claim-tenant-audit.integration.test.tsrecovers both organizations' rows with no[tenant-audit]finding, and its positive control fires. Full package suite green (below).4. Default and naming. The ceiling default is the notification side's own
DEFAULT_MAX_IDLE_INTERVAL_MS(30 s) — the same constant.HttpDispatcherOptions.maxIdleIntervalMshas the same name as onNotificationDispatcherOptions. No new plugin option:dispatchMaxIdleIntervalMsnow sets the ceiling for both dispatchers, the waydispatchIntervalMsandpartitionCountalready govern both. It is unreleased (.changeset/17610-notification-dispatcher-idle-cost.mdis still pending; npm is at 17.4.0), so widening it costs no consumer anything, and a per-dispatcher split could still be added later without breaking anyone.5. Shared helper — extracted. The loop is about 90 lines of order-sensitive concurrency: delays measured from tick start, wakes collapsing into one follow-up,
stop()clearing a pending request, a rejected tick counted as idle. Two copies would have to be kept in step by hand, and the next fix would land in one of them. The extraction is cheap to prove safe because #17622 pinned that behaviour precisely:dispatcher-idle-backoff.test.tsanddispatcher-idle-cost.integration.test.tspass unmodified on the extracted loop.NotificationDispatcher's public surface (start/stop/wake/tick, andDEFAULT_MAX_IDLE_INTERVAL_MSstill exported from./dispatcher.js) is unchanged, andDispatchLoopis not exported from the package index. The cost is touchingdispatcher.tsdays after it landed; no open PR touches it.Ablations — one-off, trap-restored
Restores are proven by HEAD blob hash plus an empty
git diff HEAD. The tests import the source by relative path, so no dist rebuild is in the resolution path.service.setHttpOutbox(httpOutbox); on disk: removed text 0, injected text 1).plugin-enqueue-wakes-dispatcher.test.ts: the HTTP leg failsError: condition not met within 5000 ms, the notification leg stays green (1 failed, 1 passed).skipReapforced even when the outbox has noreap()(let reapedForTick = true).http-dispatcher-idle-cost.integration.test.ts: the legacy-outbox leg fails (expected false to be true), the other 4 stay green.Contract
No
packages/spec/src/**in the diff. The contract additions are onservice-messaging's ownIHttpOutboxand all optional (reap?,skipReap?), so no implementer or caller must change; the changeset isminorand not declared breaking.Verification — head
3c90f49adpnpm --filter @objectstack/service-messaging test→ 39 files, 407 tests passedpnpm --filter @objectstack/service-messaging typecheck→ exit 0;tsc --listFilesincludes all four touched test files anddispatch-loop.tspnpm --filter @objectstack/service-messaging build→ exit 0,check-dts-emitted: 2/2turbo run build --filter="@objectstack/service-messaging^..." --concurrency=2→ 15/15 (13 cached)scripts/pm/os-verify-lock.shran in declared UNLOCKED mode on this host (no usableflock), so nothing was serialized.node scripts/pm/dispatch-gates.mjs --commands→ 92 commands, every one run on this head;node scripts/pm/dispatch-gates.mjs --ran→92 derived famil(ies) accounted for — 90 run, 2 NOT-MEASURED, 0 unrun. All 90 measured exit 0, includingcheck-tenant-audit-census: OK -- 222 write call sites certified(the count fix(service-messaging): reap once per dispatcher tick, back off while idle, wake on emit #17622 left, so no census rewrite was due),check-adr-0087-registration: this PR adds no declared-breaking changeset,check-changeset-no-major,check-empty-changeset,check:nul-bytesandcheck:cross-package-test-inputs.check:i18nandcheck:skill-examplesfirst refused on unbuilt prerequisites (the workspace CLI;packages/client-react/distwith no.d.ts). After the targeted closure the gates name (turbo run buildover the CLI, its 9 extract packages and@objectstack/client-react...: 58 tasks, 18 cached) both re-ran green:check-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)and✅ 258 prose examples type-check across 3 surface(s).check:dual-build-cjs-loads— exit 3, PREREQUISITE NOT MET: it reads thedist/of every package (hono, account, setup, studio, client, cloud-connection, …), a workspace-wide build this seat does not run. CI measures it.check:type-check-debt— exit 3, PREREQUISITE NOT MET: its prescribed prerequisite isturbo run build --filter='./packages/*' --filter='./packages/*/*', the whole workspace. CI measures it.Docs
content/docs/automation/webhooks.mdx: §4.4 describes the once-per-tick reap, the idle backoff and the wake; the §12 crash-recovery row states theclaimTtlMs + maxIdleIntervalMsbound instead of implying ~5 s.Acceptance notes
ack()from a REAPED claim overwrites the live re-claim onsys_http_delivery—IHttpOutbox.ack(id, result)carries no claim credential, unlike the notification outbox's compare-and-set (INotificationOutbox has no cancellation, andack()on an unclaimedpendingrow silently succeeds in both implementations #11453 /INotificationOutbox.ack()carries nonodeId, so its compare-and-set can prove a claim exists but not whose #11859). Pre-existing; this PR neither widens nor fixes it.SINGLE_NODE_CLUSTERandstableNodeOffsetremain duplicated betweendispatcher.tsandhttp-dispatcher.ts(pre-existing, not a defect; left alone to keep this diff on its card).Generated by Claude Code