You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
service-messaging: HttpDispatcher issues 16 statements per tick on an EMPTY sys_http_delivery outbox on a fixed 500 ms interval — same idle shape as #17610, never backs off #17623
{
"issue": 17623,
"status": "done",
"branch": "claude/issue-17623-http-dispatcher-idle-cost",
"pr": "https://github.com/objectstack-ai/objectstack/pull/17632",
"premise_still_valid": true,
"clause2_spec_touched": false,
"summary": "HttpDispatcher / SqlHttpOutbox now carry the PR #17622 pattern with no new design: optional IHttpOutbox.reap() plus HttpClaimOptions.skipReap (both built-in stores), the reap run once per tick outside the partition locks, an idle exponential backoff on a setTimeout chain that stop() clears (default ceiling 30 s), and a wake after enqueueHttp() writes a pending row and after redeliverHttp() resets one. The card's premise reproduced exactly on base 690f083f8: 16 statements per idle tick (engine update 80 / find 80 over 10 ticks; knex query events 160 = 80 UPDATE + 80 SELECT); on head 3c90f49ad it is 9 = 1 + partitionCount (engine update 10 / find 80; knex 90). The timer loop was extracted from NotificationDispatcher into dispatch-loop.ts and is shared by both dispatchers; the #17610 notification tests pass unmodified on it. Draft PR only: not marked ready, not queued, not merged; no labels written by this seat (size/xl came from the size-labeler); assignee was already set when picked up.",
"acceptance": {
"1_bounded_count_red_on_base": "src/http-dispatcher-idle-cost.integration.test.ts pins engine calls exactly ({update: TICKS, find: TICKS x PARTITIONS, other: 0}) and total at most TICKS x (1 + PARTITIONS). Base run (commit 2235f6973: test only, non-test files byte-identical to 690f083f8): 'expected { update: 80, find: 80, other: +0 } to deeply equal { update: 10, find: 80, other: +0 }', 1 failed | 4 passed. Head: 5/5. Non-vacuous: the same harness delivers rows in every partition on the next tick, recovers an expired claim within one tick, and leaves an unexpired claim alone.",
"2_backoff_and_wake": "src/http-dispatcher-idle-backoff.test.ts, 11 fake-timer legs: doubling 1 s to 30 s (24 ticks in 10 idle minutes), 30 s default equal to DEFAULT_MAX_IDLE_INTERVAL_MS, ceiling at or below intervalMs disables it, an unannounced row waits at most one ceiling and resets the backoff, a retry is attempted less than min(delay + intervalMs, maxIdleIntervalMs) late, wake() ticks immediately, wakes during a running tick collapse to one follow-up, stop() cancels and a later wake() is a no-op, enqueueHttp() wakes, a parked (undeliverable) enqueueHttp() does not, redeliverHttp() wakes. Base: 7 of 11 red (doubling leg 'expected [ 500, 500, 500, 500, 500, 500 ] to deeply equal [ 1000, 2000, 4000, 8000, 16000, 30000 ]'). Composed plugin leg in plugin-enqueue-wakes-dispatcher.test.ts: real plugin, dispatchIntervalMs 60 s, stubbed globalThis.fetch, a row inserted straight into sys_http_delivery stays pending (negative control), enqueueHttp() then POSTs both at once. Wake placement: after outbox.enqueue() resolves inside MessagingService.enqueueHttp, and after redeliverHttp(). Latency bound (in PR body, changeset, HttpDispatcherOptions doc, webhooks.mdx): in-process enqueue/redeliver goes out on the woken tick; unannounced work at most maxIdleIntervalMs; a retry less than min(its delay + intervalMs, maxIdleIntervalMs) late; crashed-node claim recovered within claimTtlMs + maxIdleIntervalMs (about 35 s at defaults, was about 5.5 s).",
"3_legacy_outbox_and_semantics": "The dispatcher probes outbox.reap; without it no claim receives skipReap, so each claim reaps as before (leg 'an outbox without reap() keeps working': 8 claims, none told to skip, expired claim delivered in the same tick). Direct claim() callers without skipReap unchanged. Lock keys, batching and ack untouched. New reap() leg in delivery-claim-tenant-audit.integration.test.ts: both organizations' rows recovered, no [tenant-audit] finding, positive control fires. Full package suite: 39 files, 407 tests passed.",
"4_default_and_naming": "Ceiling default is the notification side's DEFAULT_MAX_IDLE_INTERVAL_MS (30 s), the same constant. HttpDispatcherOptions.maxIdleIntervalMs mirrors NotificationDispatcherOptions.maxIdleIntervalMs. No new plugin option: dispatchMaxIdleIntervalMs now governs both dispatchers, as dispatchIntervalMs and partitionCount already do; it is unreleased (.changeset/17610-notification-dispatcher-idle-cost.md still pending, npm at 17.4.0), so widening it costs no consumer anything.",
"5_helper_extraction": "Extracted (dispatch-loop.ts, class DispatchLoop, not exported from the package index). Reason: about 90 lines of order-sensitive concurrency (start-measured delays, wake collapse, stop clearing a pending request, rejected tick counted as idle) would otherwise be kept in step by hand in two files. Proof of safety: #17622's dispatcher-idle-backoff.test.ts and dispatcher-idle-cost.integration.test.ts pass unmodified on the extracted loop; NotificationDispatcher public surface unchanged (start/stop/wake/tick; DEFAULT_MAX_IDLE_INTERVAL_MS still exported from ./dispatcher.js). No open PR touches dispatcher.ts."
},
"tests": "All on head 3c90f49ad unless noted; heavy commands went through scripts/pm/os-verify-lock.sh, which ran in declared UNLOCKED mode (no usable flock on this macOS host). BASELINE (non-test files byte-identical to 690f083f8; probe test committed alone as 2235f6973): vitest run src/http-dispatcher-idle-cost.integration.test.ts → 'expected { update: 80, find: 80, other: +0 } to deeply equal { update: 10, find: 80, other: +0 }', 1 failed | 4 passed; one-off knex query-event counter → total 160, 16 per tick (80 update, 80 select); vitest run src/http-dispatcher-idle-backoff.test.ts → 7 failed | 4 passed (doubling leg: 'expected [ 500, 500, 500, 500, 500, 500 ] to deeply equal [ 1000, 2000, 4000, 8000, 16000, 30000 ]'). FIX: same counter → total 90, 9 per tick (10 update, 80 select), throwaway file deleted before commit; 8 targeted files → 49 passed; pnpm --filter @objectstack/service-messaging test → Test Files 39 passed, Tests 407 passed; pnpm --filter @objectstack/service-messaging typecheck → exit 0, tsc --listFiles includes the 4 touched test files and dispatch-loop.ts; pnpm --filter @objectstack/service-messaging build → exit 0, check-dts-emitted 2/2; dependency closure turbo build → 15/15 (13 cached). ABLATIONS (trap-restored; restore proven by HEAD blob hash and empty git diff HEAD; tests import src by relative path, so no dist is in the resolution path): A, plugin HTTP wake wiring removed → plugin-enqueue-wakes-dispatcher.test.ts HTTP leg 'Error: condition not met within 5000 ms', notification leg green (1 failed | 1 passed); B, skipReap forced for an outbox without reap() → http-dispatcher-idle-cost legacy leg 'expected false to be true', other 4 green. GATES: node scripts/pm/dispatch-gates.mjs --commands → 92 commands, all run; --ran → '92 derived famil(ies) accounted for — 90 run, 2 NOT-MEASURED', 0 UNRUN; all 90 measured exit 0, including check-tenant-audit-census 'OK -- 222 write call sites certified' (unchanged, no census rewrite due), check-adr-0087-registration 'this PR adds no declared-breaking changeset', check-changeset-no-major, check-empty-changeset, check:nul-bytes, check:cross-package-test-inputs. check:i18n (exit 3) and check:skill-examples (exit 1: packages/client-react/dist had no .d.ts) first refused on prerequisites; after the targeted closure build (CLI + its 9 extract packages + @objectstack/client-react..., 58 tasks, 18 cached) they 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)'. NOT MEASURED: check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET, it reads the dist of every package including the apps (hono, account, setup, studio, client, cloud-connection), a workspace-wide build forbidden by the dispatch. NOT MEASURED: check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET, its prescribed prerequisite is turbo run build --filter='./packages/*' --filter='./packages/*/*' (the whole workspace). CI on PR 17632: required checks pending at report time; not waited on (in_progress). Worktree removed after verifying it was clean and the remote head equalled 3c90f49ad.",
"mcp_calls": "0 — every GitHub read and write went through the gh CLI; no MCP GitHub tool was called",
"open_questions": [
{
"question": "Plugin option: reuse dispatchMaxIdleIntervalMs for both dispatchers (done) or add a separate HTTP ceiling option?",
"options": [
"A reuse dispatchMaxIdleIntervalMs for both, matching dispatchIntervalMs and partitionCount (implemented)",
"B add httpDispatchMaxIdleIntervalMs so the two ceilings can differ"
],
"recommendation": "A, because the base interval and partition count are already shared, the option is unreleased so widening it breaks nobody, and B can still be added later without breaking anyone if a deployment needs different ceilings."
},
{
"question": "The card names enqueueHttp as the wake point; redeliverHttp also wakes in this PR. Keep it?",
"options": [
"A keep the redeliverHttp wake (implemented, tested)",
"B wake on enqueueHttp only"
],
"recommendation": "A, because redeliverHttp is the other service write that leaves a row pending, and without the wake an operator's redeliver (POST /api/v1/webhooks/redeliver) would wait up to the 30 s ceiling instead of the old 500 ms."
}
],
"out_of_scope_findings": [
"noted, not filed (for PM to file; reproducible, category (a)): a late HTTP ack from a REAPED claim overwrites the live re-claim on sys_http_delivery. IHttpOutbox.ack(id, result) carries no claim credential; SqlHttpOutbox.ack writes where {id} (dispatcherAckOptions), MemoryHttpOutbox.ack is unconditional. One-off probe against the built dist (MemoryHttpOutbox): node-A's claim expired, node-B re-claimed (in_flight, claimedBy node-B), then A's late ack left the row dead, claimedBy null, attempts 1 while B was still sending. The notification outbox closed this shape with its claim-credential compare-and-set (#11453/#11859); the HTTP outbox never got it. Pre-existing; this PR neither widens nor fixes it. Dedup: one targeted open-issue search ('sys_http_delivery ack claim') returned 0 rows; the control query in the same session returned #17623.",
"noted, not filed: SINGLE_NODE_CLUSTER and stableNodeOffset remain duplicated between dispatcher.ts and http-dispatcher.ts (pre-existing, not a defect). Owner: none.",
"noted, not filed: plugin-webhooks WebhookAutoEnqueuer still runs a fixed setInterval refresh of sys_webhook (default 60 s): one read per minute per warm kernel, far below the dispatcher cost, not a defect. Owner: none.",
"noted: scripts/pm/os-verify-lock.sh ran in declared UNLOCKED mode on this macOS host (no usable flock); declared in the PR body."
]
}
Activity
hotlong commentedon Sep 11, 2026
Claim: maintainer direct dispatch (cloud ops session, incident follow-up)
Session:
c5c0ce54-bb9c-478c-9e5b-cf44b80d4569Time: 2026-09-11T05:14Z
Branch:
claude/issue-17623-http-dispatcher-idle-costWorktree:
~/Documents/GitHub/objectstack-issue-17623Domain:
domain:servicesFile surface:
packages/services/service-messaging/src/http-dispatcher.ts,sql-http-outbox.ts,http-outbox.ts/memory-http-outbox.ts(contract + in-memory twin),messaging-service-plugin.ts/messaging-service.ts(wake onenqueueHttp) + testsSerial constraint cleared: PR #17622 (same package,
messaging-service-plugin.ts/ outbox contract) merged at690f083f8.Clause-②: no — dispatcher scheduling / idle behaviour only; no
packages/spec/src/**surface expected (path limb re-checked on the actual diff at review)hotlong commentedon Sep 11, 2026
os-dev-report
{ "issue": 17623, "status": "done", "branch": "claude/issue-17623-http-dispatcher-idle-cost", "pr": "https://github.com/objectstack-ai/objectstack/pull/17632", "premise_still_valid": true, "clause2_spec_touched": false, "summary": "HttpDispatcher / SqlHttpOutbox now carry the PR #17622 pattern with no new design: optional IHttpOutbox.reap() plus HttpClaimOptions.skipReap (both built-in stores), the reap run once per tick outside the partition locks, an idle exponential backoff on a setTimeout chain that stop() clears (default ceiling 30 s), and a wake after enqueueHttp() writes a pending row and after redeliverHttp() resets one. The card's premise reproduced exactly on base 690f083f8: 16 statements per idle tick (engine update 80 / find 80 over 10 ticks; knex query events 160 = 80 UPDATE + 80 SELECT); on head 3c90f49ad it is 9 = 1 + partitionCount (engine update 10 / find 80; knex 90). The timer loop was extracted from NotificationDispatcher into dispatch-loop.ts and is shared by both dispatchers; the #17610 notification tests pass unmodified on it. Draft PR only: not marked ready, not queued, not merged; no labels written by this seat (size/xl came from the size-labeler); assignee was already set when picked up.", "acceptance": { "1_bounded_count_red_on_base": "src/http-dispatcher-idle-cost.integration.test.ts pins engine calls exactly ({update: TICKS, find: TICKS x PARTITIONS, other: 0}) and total at most TICKS x (1 + PARTITIONS). Base run (commit 2235f6973: test only, non-test files byte-identical to 690f083f8): 'expected { update: 80, find: 80, other: +0 } to deeply equal { update: 10, find: 80, other: +0 }', 1 failed | 4 passed. Head: 5/5. Non-vacuous: the same harness delivers rows in every partition on the next tick, recovers an expired claim within one tick, and leaves an unexpired claim alone.", "2_backoff_and_wake": "src/http-dispatcher-idle-backoff.test.ts, 11 fake-timer legs: doubling 1 s to 30 s (24 ticks in 10 idle minutes), 30 s default equal to DEFAULT_MAX_IDLE_INTERVAL_MS, ceiling at or below intervalMs disables it, an unannounced row waits at most one ceiling and resets the backoff, a retry is attempted less than min(delay + intervalMs, maxIdleIntervalMs) late, wake() ticks immediately, wakes during a running tick collapse to one follow-up, stop() cancels and a later wake() is a no-op, enqueueHttp() wakes, a parked (undeliverable) enqueueHttp() does not, redeliverHttp() wakes. Base: 7 of 11 red (doubling leg 'expected [ 500, 500, 500, 500, 500, 500 ] to deeply equal [ 1000, 2000, 4000, 8000, 16000, 30000 ]'). Composed plugin leg in plugin-enqueue-wakes-dispatcher.test.ts: real plugin, dispatchIntervalMs 60 s, stubbed globalThis.fetch, a row inserted straight into sys_http_delivery stays pending (negative control), enqueueHttp() then POSTs both at once. Wake placement: after outbox.enqueue() resolves inside MessagingService.enqueueHttp, and after redeliverHttp(). Latency bound (in PR body, changeset, HttpDispatcherOptions doc, webhooks.mdx): in-process enqueue/redeliver goes out on the woken tick; unannounced work at most maxIdleIntervalMs; a retry less than min(its delay + intervalMs, maxIdleIntervalMs) late; crashed-node claim recovered within claimTtlMs + maxIdleIntervalMs (about 35 s at defaults, was about 5.5 s).", "3_legacy_outbox_and_semantics": "The dispatcher probes outbox.reap; without it no claim receives skipReap, so each claim reaps as before (leg 'an outbox without reap() keeps working': 8 claims, none told to skip, expired claim delivered in the same tick). Direct claim() callers without skipReap unchanged. Lock keys, batching and ack untouched. New reap() leg in delivery-claim-tenant-audit.integration.test.ts: both organizations' rows recovered, no [tenant-audit] finding, positive control fires. Full package suite: 39 files, 407 tests passed.", "4_default_and_naming": "Ceiling default is the notification side's DEFAULT_MAX_IDLE_INTERVAL_MS (30 s), the same constant. HttpDispatcherOptions.maxIdleIntervalMs mirrors NotificationDispatcherOptions.maxIdleIntervalMs. No new plugin option: dispatchMaxIdleIntervalMs now governs both dispatchers, as dispatchIntervalMs and partitionCount already do; it is unreleased (.changeset/17610-notification-dispatcher-idle-cost.md still pending, npm at 17.4.0), so widening it costs no consumer anything.", "5_helper_extraction": "Extracted (dispatch-loop.ts, class DispatchLoop, not exported from the package index). Reason: about 90 lines of order-sensitive concurrency (start-measured delays, wake collapse, stop clearing a pending request, rejected tick counted as idle) would otherwise be kept in step by hand in two files. Proof of safety: #17622's dispatcher-idle-backoff.test.ts and dispatcher-idle-cost.integration.test.ts pass unmodified on the extracted loop; NotificationDispatcher public surface unchanged (start/stop/wake/tick; DEFAULT_MAX_IDLE_INTERVAL_MS still exported from ./dispatcher.js). No open PR touches dispatcher.ts." }, "tests": "All on head 3c90f49ad unless noted; heavy commands went through scripts/pm/os-verify-lock.sh, which ran in declared UNLOCKED mode (no usable flock on this macOS host). BASELINE (non-test files byte-identical to 690f083f8; probe test committed alone as 2235f6973): vitest run src/http-dispatcher-idle-cost.integration.test.ts → 'expected { update: 80, find: 80, other: +0 } to deeply equal { update: 10, find: 80, other: +0 }', 1 failed | 4 passed; one-off knex query-event counter → total 160, 16 per tick (80 update, 80 select); vitest run src/http-dispatcher-idle-backoff.test.ts → 7 failed | 4 passed (doubling leg: 'expected [ 500, 500, 500, 500, 500, 500 ] to deeply equal [ 1000, 2000, 4000, 8000, 16000, 30000 ]'). FIX: same counter → total 90, 9 per tick (10 update, 80 select), throwaway file deleted before commit; 8 targeted files → 49 passed; pnpm --filter @objectstack/service-messaging test → Test Files 39 passed, Tests 407 passed; pnpm --filter @objectstack/service-messaging typecheck → exit 0, tsc --listFiles includes the 4 touched test files and dispatch-loop.ts; pnpm --filter @objectstack/service-messaging build → exit 0, check-dts-emitted 2/2; dependency closure turbo build → 15/15 (13 cached). ABLATIONS (trap-restored; restore proven by HEAD blob hash and empty git diff HEAD; tests import src by relative path, so no dist is in the resolution path): A, plugin HTTP wake wiring removed → plugin-enqueue-wakes-dispatcher.test.ts HTTP leg 'Error: condition not met within 5000 ms', notification leg green (1 failed | 1 passed); B, skipReap forced for an outbox without reap() → http-dispatcher-idle-cost legacy leg 'expected false to be true', other 4 green. GATES: node scripts/pm/dispatch-gates.mjs --commands → 92 commands, all run; --ran → '92 derived famil(ies) accounted for — 90 run, 2 NOT-MEASURED', 0 UNRUN; all 90 measured exit 0, including check-tenant-audit-census 'OK -- 222 write call sites certified' (unchanged, no census rewrite due), check-adr-0087-registration 'this PR adds no declared-breaking changeset', check-changeset-no-major, check-empty-changeset, check:nul-bytes, check:cross-package-test-inputs. check:i18n (exit 3) and check:skill-examples (exit 1: packages/client-react/dist had no .d.ts) first refused on prerequisites; after the targeted closure build (CLI + its 9 extract packages + @objectstack/client-react..., 58 tasks, 18 cached) they 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)'. NOT MEASURED: check:dual-build-cjs-loads, reason: exit 3 PREREQUISITE NOT MET, it reads the dist of every package including the apps (hono, account, setup, studio, client, cloud-connection), a workspace-wide build forbidden by the dispatch. NOT MEASURED: check:type-check-debt, reason: exit 3 PREREQUISITE NOT MET, its prescribed prerequisite is turbo run build --filter='./packages/*' --filter='./packages/*/*' (the whole workspace). CI on PR 17632: required checks pending at report time; not waited on (in_progress). Worktree removed after verifying it was clean and the remote head equalled 3c90f49ad.", "mcp_calls": "0 — every GitHub read and write went through the gh CLI; no MCP GitHub tool was called", "open_questions": [ { "question": "Plugin option: reuse dispatchMaxIdleIntervalMs for both dispatchers (done) or add a separate HTTP ceiling option?", "options": [ "A reuse dispatchMaxIdleIntervalMs for both, matching dispatchIntervalMs and partitionCount (implemented)", "B add httpDispatchMaxIdleIntervalMs so the two ceilings can differ" ], "recommendation": "A, because the base interval and partition count are already shared, the option is unreleased so widening it breaks nobody, and B can still be added later without breaking anyone if a deployment needs different ceilings." }, { "question": "The card names enqueueHttp as the wake point; redeliverHttp also wakes in this PR. Keep it?", "options": [ "A keep the redeliverHttp wake (implemented, tested)", "B wake on enqueueHttp only" ], "recommendation": "A, because redeliverHttp is the other service write that leaves a row pending, and without the wake an operator's redeliver (POST /api/v1/webhooks/redeliver) would wait up to the 30 s ceiling instead of the old 500 ms." } ], "out_of_scope_findings": [ "noted, not filed (for PM to file; reproducible, category (a)): a late HTTP ack from a REAPED claim overwrites the live re-claim on sys_http_delivery. IHttpOutbox.ack(id, result) carries no claim credential; SqlHttpOutbox.ack writes where {id} (dispatcherAckOptions), MemoryHttpOutbox.ack is unconditional. One-off probe against the built dist (MemoryHttpOutbox): node-A's claim expired, node-B re-claimed (in_flight, claimedBy node-B), then A's late ack left the row dead, claimedBy null, attempts 1 while B was still sending. The notification outbox closed this shape with its claim-credential compare-and-set (#11453/#11859); the HTTP outbox never got it. Pre-existing; this PR neither widens nor fixes it. Dedup: one targeted open-issue search ('sys_http_delivery ack claim') returned 0 rows; the control query in the same session returned #17623.", "noted, not filed: SINGLE_NODE_CLUSTER and stableNodeOffset remain duplicated between dispatcher.ts and http-dispatcher.ts (pre-existing, not a defect). Owner: none.", "noted, not filed: plugin-webhooks WebhookAutoEnqueuer still runs a fixed setInterval refresh of sys_webhook (default 60 s): one read per minute per warm kernel, far below the dispatcher cost, not a defect. Owner: none.", "noted: scripts/pm/os-verify-lock.sh ran in declared UNLOCKED mode on this macOS host (no usable flock); declared in the PR body." ] }Generated by Claude Code
hotlong commentedon Sep 11, 2026
状态(UTC 2026-09-11T06:11Z):PR #17632 CI 全绿(31 pass · 4 skipping · 0 fail)→ PM 复核通过(两个开放问题均裁 A,见 PR 评论)→ 已转 ready → 已开启自动合并,条件满足后自动入队(timeline
auto_merge_enabled,尚未见added_to_merge_queue)。合并后本卡随Closes关闭。范围外发现的迟到 ack 缺陷另见 #17634。hotlong commentedon Sep 11, 2026
已合并(UTC 2026-09-11 06:38:48):PR #17632 → 合并提交
a9096af48。HttpDispatcher空闲每跳语句数 16 → 9(1 + 分区数),空闲指数退避(上限 30s,与通知侧共用dispatchMaxIdleIntervalMs),enqueueHttp()/redeliverHttp()即唤醒DispatchLoop(dispatch-loop.ts,未从包入口导出),fix(service-messaging): reap once per dispatcher tick, back off while idle, wake on emit #17622 的通知侧测试在其上不改一行通过sys_http_delivery—IHttpOutbox.ack(id, …)carries no claim credential (the notification outbox fixed this shape in #11859) #17634(本 PR 合并解除了它的同文件串行约束)