Skip to content

test(objectql): bulk-write-per-row-hooks runs under the 10 s budget (#23182 row 5) - #23779

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23182-bulk-write-hooks-budget
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-23182-bulk-write-hooks-budget

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #23182 (row 5)

Row 5 of the test-budget card: packages/objectql/src/bulk-write-per-row-hooks.test.ts, project local, 16.1 s in the hourly battery's reading (run 38325245250). This PR is that file's fix and nothing else; the card stays open for its other rows. Test-only: one file, +37 / -15, no production file, no changeset (skip-changeset applied).

Where the time went (measured, not guessed)

The file's test bodies cost 3.1 to 4.0 s on a quiet local box, and the module line is the number the budget reads. Instrumenting seedTasks inside the real file gave, in file order, 453, 392, 636, 585 and 687 ms for the five calls that push 10 001 (or 10 000) rows through engine.insert: 2.75 s of 3.69 s, about 74 percent. Those five calls belong to the ceiling cases (section 4 and D6). The last three are slower than the first two as the heap climbs from 481 to 604 MB across them. The remaining cost is the case that admits a batch exactly at the ceiling (about 0.5 to 0.7 s: 10 000 rows, so 20 000 real per-row hook dispatches) and about 0.25 s for the other 58 cases. There is no sleep, no timer, no wait and no per-test kernel boot in this file; the cost is row volume through engine.insert.

What changed

The ceiling is judged on what the driver's find returns, so how those rows got into the stub store is not what the five cases pin. The stub driver gains a synchronous seed(object, rows) that stores the very rows create() stores (create and seed now share one put), and a helper seedCeilingRows(driver, count, status) lays down the matched set. The five ceiling cases seed through it instead of engine.insert. Every other seed in the file (2 to 4 rows) still goes through engine.insert.

Why the rows are the same ones: the engine adds nothing to a row on insert (a two-row probe gave {title, status, id} from engine.insert and from the driver alike). One-off check, not committed: for 10 001 todo rows, 10 000 todo rows and 10 001 stale rows, driver.find after engine.insert and after seedCeilingRows returned deep-equal arrays, and engine.count read the same total.

Before / after, wall of the file (module line, default reporter, file alone, --maxWorkers=2, shared box, three runs each)

reading base file this branch
re-taken after a container restart, head eb84ae2 3149 / 3368 / 3426 ms 963 / 871 / 837 ms
before the restart 3987 / 3905 / 3598 ms (and 3635 ms in a one-off) 962 / 971 / 940 ms
CI-shaped package run, before the restart: whole local project, 429 files, --maxWorkers=8 on 4 cores, the base file copied under a temporary name into the same run 8212 ms 1780 ms

Same 59 tests, same titles, same order. Slowest case after: "ADMITS a batch exactly AT the ceiling, in both phases", 682 ms (its 20 000 dispatches are the behaviour it pins, so it cannot shrink without weakening the pin); the next slowest is 50 ms. The card's 16.1 s was the slowest of 20 CI runs, about 4.4 times this box's 3.6 s reading; the same ratio puts this branch near 4 s in CI. That is a projection, not a measurement: NOT MEASURED until the next refresh PR's list is read. The single-file run's own Duration line (12 to 17 s) is dominated by the cold import and transform phase (about 11 s), which is on no module line, the same shape rows 1 and 4 noted.

Pins preserved

No test deleted, no .skip / .todo, no timeout touched (the 60_000 on the at-ceiling case is unchanged), no project change, every assertion and every row count unchanged (10 001 and 10 000 still come from MAX_BULK_PER_ROW_HOOK_ROWS). Per-row hooks on bulk writes are still proven per row: the before and after hook counts at the ceiling are still asserted at 10 000 each.

Ablation (on-disk proof via scripts/ablation-replace.mjs, wrap mode, restore proven: blob equals HEAD and git diff HEAD empty)

Each leg ran the branch file and a temporary copy of the base file in one vitest invocation under the same mutation, and compared red sets by full test name.

leg mutation base file red branch file red red sets
control none 0 / 59 0 / 59 identical
A engine.ts ceiling check never refuses (if (verdict.kind) return;) 3 / 59 3 / 59 identical: the section 4 refusal, the D6 before-update refusal, the D6 before-delete refusal
B engine.ts ceiling check also refuses at the ceiling (a guard that refuses once matched reaches the limit) 1 / 59 1 / 59 identical: the at-ceiling admit case
C engine.ts predicate-update before-hooks never dispatched (rows.length > 99999999) 13 / 59 13 / 59 identical
D the new helper seeds one row short (count - 1) n/a 5 / 59 the five ceiling cases, so none of them is satisfied by an empty or short set

The subject (engine.ts) is imported by relative source path, so there is no dist leg; the mutation reached disk (anchor 1 to 0, blob changed) and every restore was verified.

Verification (head eb84ae2)

  • pnpm --filter @objectstack/objectql typecheck: exit 0, including check:test-typecheck (39 files / 233 errors / 64 pinned signatures, unchanged; 0 errors in this file; the file is in tsconfig.test.json's program, which is the one that covers it, and not in the default tsconfig.json program). Re-taken after the final edit.
  • eslint --no-inline-config on the changed file: 0 errors, 0 warnings (1 file).
  • Whole local project of the package, before the restart, on this change: 429 files / 8222 tests passed (that count includes the temporary base copy of this file).
  • node scripts/pm/dispatch-gates.mjs --commands: 53 derived, 53 run, all exit 0; --ran reconciliation: 53 derived, 53 run, 0 NOT-MEASURED, 0 UNRUN. Beyond that, the 11 declared wide-population families and the 52 further artifact-roster families (one of the 53 roster entries is also in the commands list): 63 commands run, 59 exit 0. Four roster gates need a pull-request or workflow-event context and exited non-zero with their own "NOT MEASURED / NOT WIRED, no PR context" text, so they judged nothing in that run: check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths, pm/check-governed-queue-guard. check-partof-closing-keyword was then run on this body's own text (PR_BODY set): exit 0. The other three stay NOT MEASURED locally; CI supplies their context. check:dual-build-cjs-loads and check:lean-entry-closure first exited 3 (no dist); after a full workspace build (75 of 75 tasks) both exited 0.

Acceptance notes

  • The 60_000 timeout on the at-ceiling case could now be lowered; it is left alone on purpose (the card forbids touching timeouts, and a lowered one is a separate decision).
  • engine.insert costs about 40 microseconds a row on a batch of 10 000 (about 0.4 s), growing as the heap fills. That is production cost, an optimisation and not a defect, so no card.
  • The pin for this row is the next refresh PR's list no longer naming the file; that read is not available yet.

Readings by provenance

A container restart killed the first run mid-flow (the worktree and commit survived). Re-taken after it: the before/after module-line runs in the first table row, typecheck, eslint, the full workspace build and the whole gate run on head eb84ae2. From before the restart and still valid because the head did not move: the first three-run set, the in-file seed instrumentation, the CI-shaped package run, the equivalence check and all ablation legs (their JSON and restore proofs are on disk).


Generated by Claude Code

…23182 row 5)

The five ceiling cases (section 4 and D6) pushed 10 001 rows through
engine.insert each, about 40 us a row and 2.75 s of the file's 3.6 s of
test wall. The ceiling is judged on what the driver's find returns, so
how the rows got into the store is not what those cases pin: the stub
driver gains a synchronous seed() that lays down the very rows create()
stores (the engine adds nothing to a row on insert), and the five cases
seed through it. Every assertion, hook, row count and the 60 s case
timeout are unchanged; small seeds still go through engine.insert.

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 16, 2026
@github-actions github-actions Bot added the tests label Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6362c66ca89bb4211e9a9a750cec4de3b62b2dcf → packageMentionDocs.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 02:00
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 02:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit d3df3ba Oct 16, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23182-bulk-write-hooks-budget branch October 16, 2026 02:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants