Skip to content

core DispatchLoop: an idle loop re-runs its fast backoff ramp after every boot, so a fresh tenant kernel spends 30 statements in its first minute on empty delivery outboxes and job queues #29919

Closed

Description

@objectstack-fleet

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:

  1. 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; queue scheduled_for / locked_until), and sleep until min(that, maxIdleIntervalMs). Nothing deferred means the ceiling.
  2. 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

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

  1. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p2 kept · domain:server kept · pm:queue · 2026-11-03T12:54Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

    Claim: PM loop round 1
    Session: session_01Utp6CYVV6fcAQrNTDQRvPb (seat) · dev = project card thread session_01Utp6CY2ugcVZ5KKbTbWpXw
    Account: os-bill
    Branch: claude/project-thread-ue4hxr (harness-pinned thread branch; platform exception to claude/issue-<n>-<slug>)
    Worktree: objectstack-issue-29919
    Domain: domain:server
    Seat: domain:server#5
    File surface: packages/core/src/dispatch-loop.ts and tests; packages/services/service-messaging/src/** (dispatcher due-time read); packages/services/service-queue/src/db-queue-adapter.ts and 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 to no if no exported type changes
    Metadata-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#3413
    Thread-read: 6604337552
    Premise: holds — at 0fd363d1, DispatchLoop doubles the idle delay from intervalMs to maxIdleIntervalMs and resets idleTicks on start and wake(); no loop reads a next due time
    Serial 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.

  3. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb
    os-dev-report

    Card thread session_01Utp6CY2ugcVZ5KKbTbWpXw · branch claude/project-thread-ue4hxr · draft PR #30225 · head def0361dc

    {
      "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.

  4. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

    Seat ACCEPT on PR #30225 at head a1a9fbd3 · pr_ready + automerge_enable sent 2026-11-05T03:21Z. Fixes #29919 closes this card at merge.

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions