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>
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