Repository navigation
fix(service-queue,platform-objects): the claim path's sort keys join the declared index, and due-ness becomes a SQL predicate - #18105
Conversation
…ll in one declared index, and due-ness is a SQL predicate `sys_job_queue` declared `['queue','status','scheduled_for']` while `DbQueueAdapter.claimBatch` sorts by `priority, scheduled_for` — the sort's FIRST key was in no declared index, so every 1s poll built a temp B-tree over every pending row in the queue. Two halves: - the declared index becomes `['queue','status','priority','scheduled_for','id']`, the shortest form that serves the whole ORDER BY the planner actually sees (the deterministic-paging contract appends `id` to every paged read); - the due bound moves from a post-`LIMIT` JS filter into the SQL `where`, so `LIMIT` applies to the DUE set instead of letting future-dated high-priority rows starve already-due work. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…d the due-set LIMIT - driver-turso parity: the job-claim SELECT is CAPTURED from the driver rather than retyped, so the `id` tie-breaker the deterministic-paging contract appends is visible in the pin; the plan must carry no sorter on either face, with the pre-#17612 index set as the negative control. - service-queue: the starvation reproduction (30 future-dated priority-1 rows + 1 due priority-100 row) and its 29-row control, a NULL `scheduled_for` leg, and a pin that `priority` still orders the due set. - both fake engines in service-queue learn `$or`/`$lte`/`IS NULL`; every other `$` key stays a loud failure. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…b-queue-claim-index
…g it on the unbounded `id` is what MySQL refuses `check:keyed-text-bounds` measured the five-column form as the repo's ONLY declared index keying a text-family column with no `maxLength`: `driver-sql` emits `id` as TEXT and MySQL then rejects `ALTER TABLE ... ADD INDEX` with ER_BLOB_KEY_WITHOUT_LENGTH, leaving the object registered with its index silently absent. No platform object here bounds its `id`, so closing the paging tie-breaker is a primary-key column-type migration, not a rider on a sort fix. The full-queue sort — the defect — is gone either way; what remains is a sorter bounded to rows tying on the whole indexed prefix, now measured on both faces and named in the declaration. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 eccf8f8d585690b1b9b7a894777f1682765f9e12 && git checkout eccf8f8d585690b1b9b7a894777f1682765f9e12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 66aa2d98d6d1ea1b12d179e3ec82e9f53697e6b8 499f44a9a01b572b900a1b68af9ef8b17a3d3ae0 && git checkout -B drift-repro 66aa2d98d6d1ea1b12d179e3ec82e9f53697e6b8 && git merge --no-ff 499f44a9a01b572b900a1b68af9ef8b17a3d3ae0
node scripts/docs-audit/affected-docs.mjs --json 66aa2d98d6d1ea1b12d179e3ec82e9f53697e6b8 |
|
CI status on
⛔ It is not being dismissed as someone else's.
Also recorded, since both were repaired from the seat side rather than by the implementer (
Generated by Claude Code |
…`$or`, the predicate #17612 pushed into SQL `DbQueueAdapter.claimBatch` now sends `$or: [{ scheduled_for: null }, { scheduled_for: { $lte: now } }]`, and plugin-email drives that adapter through three fake engines of its own whose matchers threw on any `$` key. Throwing was correct behaviour, not a bug — `check:where-matcher`'s criterion is answer-correctly-or-refuse, and refusing is what kept them honest — but the predicate is now a real query, so they must answer it. The consumer radius of `claimBatch` is seven files: four in service-queue (already done) and these three. The fourth service-queue file never reaches `claimBatch`, and no double outside that radius sees the predicate, so none of them is touched. `$or` is answered from INSIDE the `Object.entries(where).every(...)` callback so it is still ANDed with its sibling keys; an early return there would answer a narrower query than it was handed, which is shape (a) of the defect class `check:where-matcher` exists for. `$lt`'s existing semantics are unchanged. Claude-Session: https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj Co-authored-by: Claude <noreply@anthropic.com>
…e shared DispatchLoop (objectstack-ai#18134) Fixes objectstack-ai#17612 Scope item 3 (空闲轮询) — the last open item on this card. Items 1, 2 and 4 landed in objectstack-ai#18105 and are re-verified untouched on `origin/main` here. Clause-②: yes ## What the card asked, and what was measured first The card says item 3 should **reuse** objectstack-ai#17610's backoff rather than write a second one, and the dispatching seat pre-ruled out the two cheap shapes: option D (a second backoff inside `service-queue`) by the card's own text, option B (exporting the loop from `service-messaging` and depending on it from `service-queue`) as an inverted dependency direction. The remaining direction is A — lift the loop somewhere both can depend on. The escape hatch was to stop and report if A turned out disproportionate to the remaining benefit, since items 1/2 already removed the per-tick **cost** and only the statement **count** while idle was left. Both sides were measured before any code moved: | | reads per idle hour, one registered queue | |---|---| | before (flat 1 s `setInterval`) | **3601** | | after (`DispatchLoop`, 1 s base / 30 s ceiling) | **124** | That is a **29x** reduction, linear in the number of registered queues, and on a remote driver every one of those reads is an HTTP round trip. The cost of A turned out to be small: `service-queue` and `service-messaging` **already both depend on `@objectstack/core`**, so the lift adds **zero new dependency edges** — it is one file move, one export, and two import re-points. A is not disproportionate to a 29x cut, so A was built rather than reported as a stop. ## What changed - **`packages/services/service-messaging/src/dispatch-loop.ts` moves to `packages/core/src/dispatch-loop.ts`** and is exported from core's index. It is a timing primitive owned by neither the messaging domain nor the queue domain; core is the package all three consumers already depend on. `service-messaging` publishes only its `index`, which never carried the loop, so **nothing published moved** — its two dispatchers just import from `@objectstack/core` now. - **`DbQueueAdapter.start()` runs that loop** instead of a bare `setInterval`. The loop also subsumes the old `running` re-entrancy flag, which was doing tick coalescing by hand. - **New option `DbQueueAdapterOptions.maxIdleIntervalMs`** (default 30 s). At or below `pollIntervalMs` it disables the backoff and restores the flat poll exactly. - **`publish()` and `replay()` wake the loop**, so the ceiling is never on the latency path for work this process was told about. A *deferred* `publish()` deliberately does not wake it — that tick would claim nothing and would throw the backoff away for free. ## Evidence Tests: `service-queue` 78 passed (70 before + 8 new legs), `service-messaging` 460, `core` 1316, and the downstream consumers `plugin-email` 468 and `plugin-audit` 334 — all on the final head. `typecheck` green for all three edited packages, and it demonstrably reaches `*.test.ts` (it caught a real error there during this work). **The flat-poll leg is a negative control, not decoration.** An upper bound on statements is satisfied by a worker that stopped ticking altogether — the one failure a low number cannot distinguish. So the same clock, engine and counter are run with the backoff disabled and pinned at 3601; that is what makes 124 a reading about the backoff rather than about a dead loop. **Reverse verification** — two legs, each mutated on disk (anchor count 1 before, injected marker 1 / removed anchor 0 after), run, restored with `git checkout HEAD --`, and proved byte-identical by blob hash with `git diff HEAD` empty: | ablation | predicted | observed | |---|---|---| | the backoff itself, in `core/src/dispatch-loop.ts` | RED | RED — 1 failed / 7 passed, `expected 3601 to be 124` | | the wake seam in `DbQueueAdapter.publish()` | RED on the wake leg | RED — 2 failed / 6 passed (wake leg + the drain leg) | The first ablation reads **3601** — independently reproducing the before number from a second direction. It also proves the new vitest alias really reaches core's **source**: no rebuild happened anywhere in that leg, and the verdict still moved. **Clause-② re-derivation, from the delivered diff.** Reachability was taken as the two discriminating reads, not the word `export` and not a bundle grep: - `.d.ts` export list — core's `dist/index.d.ts` (the file `exports['.'].types` names) carries `DispatchLoop`, `type DispatchLoopOptions` and `DEFAULT_MAX_IDLE_INTERVAL_MS` inside its single `export { ... }` statement. - Runtime `in await import(entry)` through the package entry (resolved to `packages/core/dist/index.js` via the exports map), with controls both ways: | symbol | role | result | |---|---|---| | `DispatchLoop` | subject | REACHABLE | | `DEFAULT_MAX_IDLE_INTERVAL_MS` | subject | REACHABLE | | `DispatchLoopOptions` | subject, type-only | not reachable at runtime, by design; present in the `.d.ts` export list | | `ObjectKernel`, `LiteKernel` | positive controls, published before this diff | REACHABLE | | `DispatchLoopTimer` | negative control, plausible sibling | not reachable | | `dispatchLoop` | negative control, wrong case | not reachable | `files` is `["dist", ...]`, so that entry ships. A new symbol is reachable from a shared package's published entry ⇒ **Clause-②** is yes, and the changeset grades `@objectstack/core` **minor** accordingly (never `patch`). **Gate denominator**, reconciled against `scripts/pm/dispatch-gates.mjs --ran` on the final head `95070c921`: **73 derived, 70 run, 3 NOT MEASURED, 0 UNRUN**. The three are `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`, each of which exited **3** — the code those gates use for "prerequisite not met, nothing was measured", all three wanting a whole-repo `pnpm build`. They are declared NOT MEASURED rather than read as green; CI builds fresh and runs them. Three gates went red against my own diff during this work and were fixed, not routed around: `check:engine-double-contract` (the new double's `update()` now opens with `assertEngineUpdateDispatch`), `check:objectql-double-limit` (the double applies the caller's `limit` by presence, so `limit: 0` is not silently widened to the whole table), and `check:test-source-alias` (the package now aliases `@objectstack/core` to source). `scripts/engine-double-contract.pinned.json` grew by **2 rows** — one `delete`, one `update`, both for the new test file — from 800 entries / 875 pinned-sum on `main` to 802 / 877. ## Acceptance notes - The consumer radius was swept by construction site rather than by edited package: every `new DbQueueAdapter` in the repo was enumerated, and every test site passes `autoStart: false` and drives `pollOnce()` by hand, so the timer change reaches none of them. The one production construction is `QueueServicePlugin`, whose `destroy()` already awaits `stop()`. - `start()` now runs its first tick immediately, where the old `setInterval` waited one interval. That is strictly lower latency and no test depended on the delay. - Observation, not filed: the sibling engine doubles in `db-queue-adapter.test.ts` and `job-queue-retention.test.ts` apply `limit` by truthiness (`if (opts.limit)`), the same shape `check:objectql-double-limit` rejected in the new file. They are inside that gate's existing baseline, so it already knows; a fixture that asked for `limit: 0` would read the whole table. --- _Generated by [Claude Code](https://claude.ai/code/session_01URLHobLUJB9K1ABV6ofdjj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #17612
Clause-②: no
sys_job_queue's claim path sorted by a key no declared index carried, and decided due-ness afterLIMIThad already chosen the rows. Both are fixed; the fork the card wrote is decided below with its costs measured, not asserted.Premise, re-verified on
origin/mainBoth readings the card carries still hold, checked at the merge base
a26a114d7:One thing the card could not have known, and it changes the acceptance criterion. The claim query the planner sees is not the one the adapter writes.
SqlDriver.orderKeysForappends the unique tie-breaker of the deterministic-paging contract (ADR-0053 D-A1 / objectstack#4363) to every paged read, so the real ORDER BY ends…, "id" ASC:That term is in no index either, and no platform object in this repo bounds its
id— which puts the card's acceptance 4 ("EXPLAIN QUERY PLANno longer showsUSE TEMP B-TREE FOR ORDER BY") one step further away than it reads. The statement pinned in the new test is captured from the driver, never retyped, precisely so this term stays visible.The fork, measured on the real tree
The matrix below is
EXPLAIN QUERY PLANof the driver-emitted claim statement, run through a realTursoDriveron both faces (libsql://overfile::memory:and local:memory:), one index set per row.queue,status,scheduled_for(today)SEARCH … (queue=? AND status=?)USE TEMP B-TREE FOR ORDER BY— the whole queuequeue,status,priority,scheduled_for(taken)SEARCH … (queue=? AND status=?)… FOR RIGHT PART OF ORDER BY(remote) /… FOR LAST TERM OF ORDER BY(local) — theidterm onlyqueue,status,priority,scheduled_for,idSEARCH … (queue=? AND status=?)priorityfrom the sort, index unchangedSEARCH … (queue=? AND status=?)… FOR RIGHT PART OF ORDER BY— theidterm onlyqueue,status,scheduled_for,idSEARCH … (queue=? AND status=?)Option 1 taken —
priorityjoins a declared index. Three reasons, any one sufficient:priorityfrom the sort does not avoid touching the index — with the index left alone it lands on exactly the same partial sorter option 1 reaches, and to do better it needs its own four-column index. So the choice is not "widen the index" against "change behaviour"; it is "widen the index" against "change behaviour and widen a different index".priorityis a declared, documented, authorable field whose description isLower = higher priority, and the claim sort is its only runtime effect. Dropping it leaves the field inert — a capability the runtime advertises and does not deliver, which Prime Directive 10 names directly.priority, taken to avoid a cost measured below at one index column.What option 1 costs, measured. One index, widened from three key columns to four, on one table. The table still declares three indexes: the new one REPLACES
['queue','status','scheduled_for']rather than joining it, which is safe because the equality prefixqueue, statusis unchanged (getQueueSizeandpurgekeep the identical seek) and no reader in this repo usesscheduled_foras an index RANGE. Write cost is one extra key column per row insert/update onsys_job_queue, not an extra index.Why the sorter is not closed all the way. Row three of the matrix is reachable and it is the shape I first delivered.
pnpm check:keyed-text-boundsrejected it, and it is right to:That gate's allowlist is empty and 147 of 147 keyed text columns are bounded — mine would have been the repo's only exception, and the failure mode is the index vanishing on MySQL, which is the very defect this card is about. Bounding a primary key's column type on provisioned tables is its own piece of work. So the delivery stops at four columns, the declaration says why in as many words, and the residual sorter is bounded to rows tying on the whole indexed prefix rather than covering the queue.
Head-of-line starvation (ruled into this card in its comments)
The due bound was applied in JS to the rows SQL had already chosen:
So a candidate window full of not-yet-due high-priority rows hid already-due work behind it — not for one tick, indefinitely. It now reads
$or: [{ scheduled_for: null }, { scheduled_for: { $lte: now } }]in thewhere, the same shapeSqlOutboxStore.claimuses;nullneeds its own leg because a NULL column never satisfies a less-than-or-equal comparison in SQL — the comparison answers NULL, never true.Measured on this package's own fake engine at the default
batchSize: 10(candidate window 30):pollOnce()beforepriority: 1+ 1 duepriority: 100priority: 1+ 1 duepriority: 100The second row is the control: it read 1 before the change too, which is what makes the first row a measurement rather than a coincidence.
Reverse verification
Every leg: commit first, mutate on disk, prove the mutation landed by occurrence count, rebuild where the consumer resolves through
dist/, run, restore withgit checkout HEAD -- path, prove the blob hash returns and the whole tree is clean.trap … EXIT INT TERMon absolute paths throughout.--absentmarker was the source spelling'queue', 'status', …with single quotes; esbuild emits double quotes, so the marker was never indist/and the pre-flight passed vacuously.scripts/ablation-dist-preflight.mjscaught it on the next leg by failing its present-check. Both legs were redone with the marker that is actually in the built bytes, and a sanity leg now asserts the fixeddist/carries it before anything is mutated.dist/✓ marker present in 6 built files['queue','status','scheduled_for']dist/(rebuilt;✓ marker absent from all 66 built files)1 failed / 69 passed, and the one failure is the drift guard$orleg deleted from thewhere2 failed / 68 passed: the starvation pin and the candidate-count pin3 failed / 5 passed, the plan pin printing+ USE TEMP B-TREE FOR ORDER BYWhy that failing set is the right one. Leg 1 reddens exactly one test, the guard that reads the SHIPPED declaration against the claim the adapter actually emits — nothing else in
service-queuedepends on the index, which is precisely the drift that let this defect exist. Leg 2 reddens the two pins aboutLIMIT's input set and nothing about ordering, because the predicate changes which rows are candidates and not their order. Leg 3 reddens the two pre-existing#17609index-set pins plus the new plan pin, because the fixture is the input to all three.Restores (whole-tree
git status --porcelainempty after each):Clause-2 re-derivation, from the delivered diff
The claim predicted
no. Re-derived:no— no new exported symbol is reachable from a published entry, and no new key lands on an already-published payload. Theindexeskey already existed onSysJobQueue; only its value changed. Measured, not reasoned:The four negatives are identifiers this diff introduces; all four are test-local and none reaches a published artifact. The changeset is graded
patchaccordingly.Gates — with the denominator
Derived from the change set by
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatd9e2830ba(6 paths vs merge base, three-dot):The two NOT MEASURED both exit 3, the "PREREQUISITE NOT MET" code both scripts define as explicitly not a pass and not a finding:
pnpm check:dual-build-cjs-loads—no packages/adapters/hono/dist … and 47 more. Needs a whole-repopnpm build.pnpm check:type-check-debt—Build the closure first, exactly as lint.yml does. Same.pnpm check:i18nwas a third until its named closure was built; it then readOK (9 packages — all bundles in sync, no undeclared authoring keys)and is counted in the 63.pnpm check:keyed-text-boundsis the gate that redirected the fix, and now reads147 keyed text-family columns judged, 147 bounded. Allowlist: 0 pending, 0 unboundable.Package-level, at
533e41385:The lint number is the full repo run, not a narrowing — it completed inside the foreground budget, so no scope claim is needed for it.
Operational note for an existing database
The retrofit adds
idx_sys_job_queue_queue_status_priority_scheduled_forand does not drop the supersededidx_sys_job_queue_queue_status_scheduled_for(measured: 3 indexes before, 4 after, the row untouched). A provisioned table therefore carries one redundant index until an operator drops it through the migrate-plan path; a freshly created table gets three.Acceptance notes
DispatchLoopinpackages/services/service-messaging/src/dispatch-loop.ts(PR fix(service-messaging): reap once per dispatcher tick, back off while idle, wake on emit #17622) and it is not exported from that package'sindex.ts, so reusing it means publishing a new symbol fromservice-messagingand adding aservice-queue→service-messagingdependency — a queue service depending on a messaging service. That is an architecture decision this card does not authorize, and writing a second copy is what its own scope item prohibits. Named here for the routing seat; the per-tick cost this card was filed about is gone either way.EXPLAIN QUERY PLANrenders the same partial-sort plan asRIGHT PART OF ORDER BY(remote,@libsql/client) andLAST TERM OF ORDER BY(local, better-sqlite3). The existing delivery-claim pin compares the two faces byEXPLAINtext equality and passes only because its plan happens to have no sorter line; the new job-claim pin compares by index and by sort class instead, and says why in the file. Noted, not filed — no PR or person is heading for that file with a partial-sort plan.countingClientin the parity test now records bound args beside the statement text, additively; the existing pins read.sqland are untouched.scripts/engine-double-contract.pinned.json: the two fake engines inservice-queuewere EXTENDED (both learn$or/$lte/IS NULL; every other$key stays a loud throw) rather than replaced, so the ledger stays at 1493 and no contender needs sequencing.Generated by Claude Code
Generated by Claude Code