Repository navigation
fix(plugin-security): the seed ownership hand-over yields to the event loop between 50-row batches, so a sign-in during it is answered - #26913
Conversation
…t loop between 50-row batches On a synchronous driver every await of the claim's one predicate write resolved as a microtask, so a 1,500-row seed's hand-over held the event loop for 2.6 s and a sign-in waited for all of it. The NULL arm now writes its seeded ids 50 at a time with a setImmediate between writes, and the by-row path yields every 50 rows. The organizations twin gets the same. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mvv8ZvVXKGoTSX3eE
…hes and yield Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mvv8ZvVXKGoTSX3eE
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5mvv8ZvVXKGoTSX3eE
📓 Docs Drift Check7 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 — 23 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 69798925798d2e2ab598f96902668e2cfd3d56b3 && git checkout 69798925798d2e2ab598f96902668e2cfd3d56b3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 57e00f5b995e5b555e64d2f609a1d54af9070d80 d51a116db45324fff2380ac68c0c2fb6e31e8c1a && git checkout -B drift-repro 57e00f5b995e5b555e64d2f609a1d54af9070d80 && git merge --no-ff d51a116db45324fff2380ac68c0c2fb6e31e8c1a
node scripts/docs-audit/affected-docs.mjs --json 57e00f5b995e5b555e64d2f609a1d54af9070d80 |
席位核对 · ACCEPT ·
|
| item | reading |
|---|---|
| Shape | Draft, base main, Fixes #26825 under triage 6449696471 (p2). 7 files: claim-seed-ownership.ts (plugin-security) and its multi-organization twin claim-org-seed-ownership.ts (organizations), which had the same unbroken run; a new yield pin test for each; their existing tests, whose write-count pins now count hand-over batches; and a patch changeset. Clause-②: no and Metadata-protocol: none appear in both the body and the changeset. |
| Fix | The NULL-arm hand-over writes 50 seeded ids per write (CLAIM_HANDOVER_ROWS) and yields one macrotask after each write; the by-row path yields every 50 rows. Under ruling B, each file has a module-private yieldToEventLoop copy whose docblock names the other copies, and nothing new is exported. The usr_system arm is still one write, then yields. Live re-measure on os serve --dev over file-backed sqlite: the longest stall during the hand-over fell from 2.7 s to 190 ms, and sign-ins in that window from p50 1,967 ms / max 2,698 ms to p50 317 ms / max 539 ms. |
| Ownership | On the success path, the same rows end up owned by the same owner, with one audit row per row. A chunk whose every batch is refused rethrows, giving the same single warning as main with the chunk unowned. Under ruling 6452414075, a partly refused chunk keeps its accepted batches owned, and one warning names the count, the first 20 ids and the first error; the next run claims the rest. Both cases are pinned in both packages, and the changeset states it. |
| Evidence | CI green on head d51a116db4 (34 success, 11 skipped, 0 failed). Governed check: NOT governed, 818 changed lines. 61 of 62 derived gates pass; the dual-build CJS gate needs a full build and is NOT MEASURED locally (exit 3), so CI's Type Check jobs are its verdict. --ran exit 0. |
| Noted | (1) The usr_system arm is still one write, since no seed writes that owner. (2) plugin-sharing now recomputes per row for a seeded object with sharing rules and more than 1,000 rows, instead of revoking and queueing a re-grant, because each write is now under its bulk threshold; the end grants are the same. Neither is filed. |
| Landing | 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip. |
Generated by Claude Code
Requested by Leehom · project thread
Fixes #26825
Clause-②: no
Metadata-protocol: none
维护者速读
claimSeedOwnership,以及多组织版claimOrgSeedOwnership)改为每批 50 行写入,每写一批让出一次事件循环;按行写的路径每 50 行让出一次。os serve --dev(文件型 sqlite,1,500 行带种子文件的种子)上,这一步是一次覆盖 1,500 行的写入,耗时 2,641 ms,整个事件循环被占住约 2.7 秒,期间的登录请求要等 2.0–2.9 秒;改后最长卡顿 190 ms,同期登录 p50 317 ms、最慢 539 ms。packages/plugins/plugin-security/src/claim-seed-ownership.ts(claimPredicateInBatches、CLAIM_HANDOVER_ROWS、claimRows里的让出);packages/plugins/organizations/src/claim-org-seed-ownership.ts(claimChunk);两个新的*-yield.pin.test.ts。pnpm --filter @objectstack/plugin-security exec vitest run src/claim-seed-ownership-yield.pin.test.ts,预期 5 passed。What changes
The reading first, on
mainate78d7057. The card's attribution came from a CPU profile whose async stacks are partial, so it was re-measured before any edit. A scratch app (one object with a file field, 1,500 seeded rows, each with a 20 KBseedFile) booted withos serve --devover file-backed sqlite,OS_INLINE_SEED_BUDGET_MS=1so the seed settles in the background, a 20 ms event-loop lag sampler preloaded, and a sign-in (POST /api/v1/auth/sign-in/email) every 250 ms:Seed loading completeand ends athanded 1500 seeded record(s). Sign-ins issued inside it took 2.0–3.2 s.plugin-security'sdistaround the claim'sql.update(removed afterwards; the dist was copied back) read three writes:usr_systemarm 2 ms, the NULL arm's ONE write over 1,500 ids 2,641 ms, then the warm re-pass 2 ms. So the whole stall is one engine write._query/prepare, plugin-audit'swriteAudit→persistAuditTrailRow(one audit row per row), engineinsert, the storage file-reference hooks, plugin-auth and plugin-sharing middleware. Control on the same boot without a file field: the claim ran about 1.1 s with no single stall above 483 ms.Why the cure is a smaller write, not a yield inside one. The engine runs a predicate write and its after-hooks in one transaction (
inAfterAbortUnit), and SQLite's pool holds one connection. A yield inside the write would leave the sign-in waiting on the pool instead of the event loop. So the atomic unit has to shrink.The fix.
claim-seed-ownership.ts: the NULL arm's chunk (stillCLAIM_PAGE_ROWS= 5,000 ids, the unit of failure reporting) is writtenCLAIM_HANDOVER_ROWS= 50 ids at a time by the newclaimPredicateInBatches, with onesetImmediateturn after every write. 50 is measured: about 1.8 ms of hook work per claimed row, so about 90 ms per batch. Theowner_id = usr_systemarm is unchanged (one predicate write, paged only on the per-row hook refusal) and yields after it. The by-row path (claimRows, objects whose stored expression readsowner_id) yields after every 50 rows; its writes were already one per row.claim-org-seed-ownership.ts(the multi-organization twin) had the same unbroken run: one write per 5,000-id chunk, inside the request that creates the organization.claimChunknow writes 50 ids at a time with a yield after each, andclaimChunkByRowyields every 50 rows.metadata-protocol/src/seed-files.tsandcore/src/utils/import-runner.tsare not exported, and exporting one would widen a published surface. Each docblock names the other copies.Failure semantics, as accepted by the seat. Success is unchanged: the same rows, the same owner, one audit row per claimed row. A chunk every batch of which is refused behaves exactly as on
main: plugin-security rethrows the first refusal so the caller logs the same single warning and the chunk stays unowned; organizations logs the samethe N seeded row(s) of this write stay owner-lesswarning. When only some batches of a chunk are refused, the accepted batches stay owned, one warning names the rows left unowned (count, the first 20 ids, the first error), and the next run claims them; onmainone refused write left the whole chunk unowned.Wall time. The hand-over itself is not faster: on the live boot it spanned 3.4 s after the fix against 2.6 s before, because sign-ins now run between its batches.
Why
The first admin who signs in while a demo or package seed is still settling waited several seconds on a fresh boot.
Tests
packages/plugins/plugin-security/src/claim-seed-ownership-yield.pin.test.ts, on a realObjectQLoverSqlDriver(better-sqlite3:memory:), 320 seeded rows, a code-registeredafterUpdatehook writing one audit row per updated row (the shape of plugin-audit's writer) and abeforeUpdatehook that can refuse named rows:re-owns at most one batch of rows between two macrotask turnsserves a read issued during the hand-over before the hand-over endshands every seeded row to the admin with one audit row per claimed rowa refused batch leaves only its rows unowned, says which, and the next run claims themevery batch refused: nothing claimed, and the one warning a refused chunk always gavepackages/plugins/organizations/src/claim-org-seed-ownership-yield.pin.test.ts: the same five onSqliteWasmDriver, plus a row in another organization that stays untouched.issues ONE predicate write per unowned shape and hand-over batch, the over-ceiling pin (the NULL arm's writes counted apart from theusr_systemarm's),writes the seeded ids in hand-over batches; organizations' 10,500-row pin, the unreadable-count pin (now one warning per chunk) and the failed-chunk pin (now refuses every write of the second chunk).Red first. Production source restored to
main(git show e78d7057:FILE), the new pins run, restored fromHEADand proven by blob hash:no macrotask turn ran while the hand-over ran: expected 1 to be greater than 6.4;expected [ 'hand-over', 'read' ] to deeply equal [ 'read', 'hand-over' ]; the partial-failure pin red (mainleaves the whole chunk unowned). The result pin and the every-batch-refused pin are GREEN onmain: that is the proof that ownership, audit rows and the total-failure warning are identical.Ablations (
scripts/ablation-replace.mjs, anchor counts and the HEAD-blob restore checked by the tool; the tests import source, so nodistis involved):CLAIM_HANDOVER_ROWS = CLAIM_PAGE_ROWS: plugin-security 3 failed (expected 5 to be greater than 6.4,expected 320 to be less than 320, partial-failure pin).Live re-measure with the fix built into
dist, same app and boot: the stretch fromSeed loading completetohanded 1500had no stall above 190 ms (before: 2,669–2,763 ms in one stretch), and the 14 sign-ins issued in it answered p50 317 ms, slowest 539 ms (before: p50 1,967 ms, slowest 2,698 ms).Acceptance notes
afterUpdaterecomputes per row up to 1,000 rows per write and above that revokes the object's rule grants and re-grants them in the background (with a warning). A 50-row batch is always under that cap, so on an object with sharing rules and more than 1,000 seeded rows the claim now recomputes per row inline (no revoke-and-re-grant window) instead of one revoke plus a queued re-grant. The end state is the same rule grants; theCLAIM_PAGE_ROWSdocblock reasoning about the 1,000 cap still applies to theusr_systemarm's pages.usr_systemarm is unchanged, by ruling. It is still one predicate write over every such row, so a seed whose records setowner_idto the seed identity would still be claimed in one run. No producer was found: HotCRM and the examples write noowner_idin seeds, and the measured seed landed every row NULL. Noted, not filed.InMemoryDriverthe NULL arm's cost does not change: anid INlist of B ids over T rows costs T×B per write and there are N/B writes, the same T×N as one write.Verification
Readings (final head d51a116)
All at
d51a116d(the final head); each exit code captured before any pipe.pnpm --filter @objectstack/plugin-security test && pnpm --filter @objectstack/plugin-security typecheckunder the verify lock:Test Files 232 passed (232),Tests 5037 passed | 45 skipped (5082),check:test-typecheck: OK, lock VERDICT command-exit 0. The new test files are intsconfig.test.json's program, whichcheck:test-typecheckcompiles.pnpm --filter @objectstack/organizations test && pnpm --filter @objectstack/organizations typecheck:Test Files 18 passed (18),Tests 248 passed (248), lock VERDICT command-exit 0.pnpm exec eslint --no-inline-config packages/plugins/plugin-security packages/plugins/organizations: exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 62 commands; each was run: 61 exit 0,pnpm check:dual-build-cjs-loadsexit 3 (PREREQUISITE NOT MET, needs a full build: NOT MEASURED, CI runs it).node scripts/check-plugin-teardown-shape.mjs --self-testfirst exited 3 (a shallow checkout lacked its positive-control commit) and exit 0 aftergit fetch --depth=1of that commit.--ranreconciliation:62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED, exit 0.pnpm turbo run build --filter=@objectstack/cli... --filter=@objectstack/organizations... --concurrency=2, lock VERDICT command-exit 0; then both touched packages rebuilt for the live re-measure.claim-seed-ownership*orclaim-org-seed-ownership*(each PR's/filesread).Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)pnpm lintpasses (eslint on both touched packages, exit 0).main, one pull request.packages/spectouched: not touched.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code