Skip to content

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

Closed

Description

@hotlong

现象

service-messaging 里的 HttpDispatcher / SqlHttpOutbox 与 #17610 修复前的 NotificationDispatcher 完全同形状:

  • 固定 500ms 间隔,无空闲退避
  • 每跳遍历 8 个分区;每个分区的领取先跑一次 reap UPDATE(谓词不含分区),再跑一次候选 SELECT
  • ⇒ outbox 为空时每跳 16 条语句(8 reap + 8 select)

来源:#17610 开发席位用一次性探针实测(ObjectQL + SqlDriver,8 分区,表 sys_http_delivery),见 PR #17622 报告的范围外发现。

为什么要紧

修法(复用 #17610 的模式,不另起设计)

PR #17622 已为 NotificationDispatcher 落地:

  1. outbox 契约加可选 reap(),领取加可选 skipReap——reap 每跳一次、分区锁之外
  2. 空闲指数退避到上限(通知侧默认 30s,插件可调),setTimeout 链,stop() 清除
  3. 入队时唤醒(通知侧经 setOutbox(outbox, { onEnqueued });HTTP 侧对应 enqueueHttp)

注意:PR #17622 的退避实现在 NotificationDispatcher 内部,未抽成共用 helper。实现本卡时判断是否值得抽出,二者择一并说明。

验收

  1. 空 outbox 上每跳语句数有上限的计数测试(每跳 ≤ 1 + 分区数)
  2. 空闲退避 + 入队即唤醒,各有测试
  3. 未实现 reap() 的旧 outbox 行为不变;分区锁、至少一次投递、claim TTL 回收语义不变

顺序

等 PR #17622 合并后再开工:两者会同时改 messaging-service-plugin.ts 与 outbox 契约,并行会产生同文件冲突。

Activity

  1. hotlong commented on Sep 11, 2026

    @hotlong
    ContributorAuthor

    Claim: maintainer direct dispatch (cloud ops session, incident follow-up)
    Session: c5c0ce54-bb9c-478c-9e5b-cf44b80d4569
    Time: 2026-09-11T05:14Z
    Branch: claude/issue-17623-http-dispatcher-idle-cost
    Worktree: ~/Documents/GitHub/objectstack-issue-17623
    Domain: domain:services
    File 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 on enqueueHttp) + tests
    Serial constraint cleared: PR #17622 (same package, messaging-service-plugin.ts / outbox contract) merged at 690f083f8.
    Clause-②: no — dispatcher scheduling / idle behaviour only; no packages/spec/src/** surface expected (path limb re-checked on the actual diff at review)

  2. self-assigned this
    on Sep 11, 2026
  3. hotlong commented on Sep 11, 2026

    @hotlong
    ContributorAuthor

    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

  4. hotlong commented on Sep 11, 2026

    @hotlong
    ContributorAuthor

    状态(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。

  5. hotlong commented on Sep 11, 2026

    @hotlong
    ContributorAuthor

    已合并(UTC 2026-09-11 06:38:48):PR #17632 → 合并提交 a9096af48。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions