Skip to content

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

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-g8r1ai
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-g8r1ai

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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。
  • 风险与回滚:成功路径结果不变(同样的行、同样的所有者、每行一条审计行)。唯一变化在失败时:一个 5,000 行分块里只有部分批次被拒时,其余批次已归属、剩下的行由下一次认领补上(以前整块都不归属);整块全被拒时与原来完全相同。席位已接受此差异。回滚即撤销本 PR。
  • 先看: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 main at e78d7057. 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 KB seedFile) booted with os serve --dev over file-backed sqlite, OS_INLINE_SEED_BUDGET_MS=1 so 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:

  • Three boots: the event loop stalled 2,763 / 2,762 / 2,742 ms in one stretch that starts at Seed loading complete and ends at handed 1500 seeded record(s). Sign-ins issued inside it took 2.0–3.2 s.
  • A timing marker spliced into plugin-security's dist around the claim's ql.update (removed afterwards; the dist was copied back) read three writes: usr_system arm 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.
  • The CPU profile of that stretch is per-row hook work inside that write: better-sqlite3 _query/prepare, plugin-audit's writeAudit → persistAuditTrailRow (one audit row per row), engine insert, 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.

  1. claim-seed-ownership.ts: the NULL arm's chunk (still CLAIM_PAGE_ROWS = 5,000 ids, the unit of failure reporting) is written CLAIM_HANDOVER_ROWS = 50 ids at a time by the new claimPredicateInBatches, with one setImmediate turn after every write. 50 is measured: about 1.8 ms of hook work per claimed row, so about 90 ms per batch. The owner_id = usr_system arm 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 reads owner_id) yields after every 50 rows; its writes were already one per row.
  2. 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. claimChunk now writes 50 ids at a time with a yield after each, and claimChunkByRow yields every 50 rows.
  3. The yield helper is a module-private copy in each file, by the seat's ruling on this card (option B): the existing copies in metadata-protocol/src/seed-files.ts and core/src/utils/import-runner.ts are 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 same the N seeded row(s) of this write stay owner-less warning. 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; on main one 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

  • New packages/plugins/plugin-security/src/claim-seed-ownership-yield.pin.test.ts, on a real ObjectQL over SqlDriver (better-sqlite3 :memory:), 320 seeded rows, a code-registered afterUpdate hook writing one audit row per updated row (the shape of plugin-audit's writer) and a beforeUpdate hook that can refuse named rows:
    • re-owns at most one batch of rows between two macrotask turns
    • serves a read issued during the hand-over before the hand-over ends
    • hands every seeded row to the admin with one audit row per claimed row
    • a refused batch leaves only its rows unowned, says which, and the next run claims them
    • every batch refused: nothing claimed, and the one warning a refused chunk always gave
  • New packages/plugins/organizations/src/claim-org-seed-ownership-yield.pin.test.ts: the same five on SqliteWasmDriver, plus a row in another organization that stays untouched.
  • Updated shape pins that counted writes per 5,000-id chunk: plugin-security issues ONE predicate write per unowned shape and hand-over batch, the over-ceiling pin (the NULL arm's writes counted apart from the usr_system arm'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 from HEAD and proven by blob hash:

  • plugin-security: 3 failed, 2 passed. 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 (main leaves the whole chunk unowned). The result pin and the every-batch-refused pin are GREEN on main: that is the proof that ownership, audit rows and the total-failure warning are identical.
  • organizations: the same 3 failed, 2 passed.

Ablations (scripts/ablation-replace.mjs, anchor counts and the HEAD-blob restore checked by the tool; the tests import source, so no dist is involved):

  • A, the yield resolves at once: plugin-security 2 failed (the two yield pins); organizations 2 failed (the same two).
  • B, 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).
  • C, no rethrow when every batch is refused: plugin-security 1 failed (the every-batch-refused pin's warning text).

Live re-measure with the fix built into dist, same app and boot: the stretch from Seed loading complete to handed 1500 had 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

  • Sharing rules on a large seeded object. plugin-sharing's afterUpdate recomputes 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; the CLAIM_PAGE_ROWS docblock reasoning about the 1,000 cap still applies to the usr_system arm's pages.
  • The usr_system arm is unchanged, by ruling. It is still one predicate write over every such row, so a seed whose records set owner_id to the seed identity would still be claimed in one run. No producer was found: HotCRM and the examples write no owner_id in seeds, and the measured seed landed every row NULL. Noted, not filed.
  • On InMemoryDriver the NULL arm's cost does not change: an id IN list 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 typecheck under 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 in tsconfig.test.json's program, which check:test-typecheck compiles.
  • 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/objectstack derived 62 commands; each was run: 61 exit 0, pnpm check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET, needs a full build: NOT MEASURED, CI runs it). node scripts/check-plugin-teardown-shape.mjs --self-test first exited 3 (a shallow checkout lacked its positive-control commit) and exit 0 after git fetch --depth=1 of that commit. --ran reconciliation: 62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED, exit 0.
  • Dependency closure built before any reading: 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.
  • Overlap: none of the 41 open PRs touches claim-seed-ownership* or claim-org-seed-ownership* (each PR's /files read).
  • Live boots and the dist timing marker: scratch scripts, not committed; numbers in What changes and Tests.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (eslint on both touched packages, exit 0).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the maintainer summary is in Chinese by the lane's convention).
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 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
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 23 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 57e00f5b995e5b555e64d2f609a1d54af9070d80 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 69798925798d2e2ab598f96902668e2cfd3d56b3 — the merge of head d51a116db45324fff2380ac68c0c2fb6e31e8c1a into base 57e00f5b995e5b555e64d2f609a1d54af9070d80, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T21:17Z

Head d51a116db4, report 6453284573 (on #26825), claim 6452028760, seat rulings 6452151359 (letter B) and 6452414075 (partial-failure divergence). No contract review is owed: there is no packages/spec/** diff, no governed path, no narrowing and no Clause-②: yes.

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

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants