Skip to content

fix(metadata-protocol): a seed that stores many seedFile values no longer starves sign-ins (yield per row, 20-row batches) - #26822

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-26670-seed-file-pool
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-26670-seed-file-pool

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #26670

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:种子加载器在每一行种子文件的存储与认领之后各让出一次事件循环;携带种子文件的数据集改为每批 20 行插入(原为 200 行)。无种子文件的数据集行为不变。
  • 为什么改:实测证伪了卡上「连接池被占用」的推测——连接池最长持有 44 ms;真正被占住的是事件循环:同步的 sqlite 驱动让每个 await 都成了微任务,一批 200 行的认领连成一条不间断的链,开机种子期间的登录请求要等 3–5 秒才被读到。
  • 风险与回滚:风险低,只改变种子文件工作的调度顺序,不改写入内容与结果;回滚即撤销本 PR 的两个 fix 提交。
  • 先看:packages/metadata-protocol/src/seed-files.ts(yieldToEventLoop 及两处调用点,settleRow 的 finally);packages/metadata-protocol/src/seed-loader.ts(insertBatchSize)。
  • 两分钟验证:pnpm --filter @objectstack/metadata-protocol exec vitest run src/seed-loader-seed-files.test.ts,预期 19 passed,其中「a seed of many files yields to waiting requests (A seed that stores many seedFile files holds the sqlite pool: sign-ins during the boot seed time out (Knex: Timeout acquiring a connection) #26670)」三条全绿。
  • 需要你做的:无,席位落地。

What changes

The reading first: which call holds what. The card's seam ("the seed loader holds a pooled connection or a transaction") was marked unverified, so the first measurement was the pool itself. No call on the seed-file path opens a transaction: none in seed-loader.ts, seed-files.ts or field-file-service.ts, nor in the boot runner (app-plugin.ts runInlineSeed). Every step is sequential, and there is no Promise.all. Measured on the real composition (os serve --dev, file-backed sqlite, 1,500 rows each holding one 20 KB seedFile, sign-in every 250 ms while the background seed runs). A preload patched tarn's Pool (acquire, release) and sampled event-loop lag with a 100 ms timer:

reading before (base a485b5a3) after (fix at abc4ef3b; src unchanged since)
pool acquires during the run 37,494 38,430
longest connection hold 44 ms 103 ms
longest acquire wait 21 ms 19 ms
event-loop lag, max per 5 s window, during the seed 2,827–4,996 ms 170–317 ms
sign-ins during the seed: p50 / slowest 358 ms / 5,195 ms 187 ms / 447 ms

The pool was never starved. The event loop was. On a synchronous driver (better-sqlite3) every await of a seed file's roughly 25 engine calls (the pending sys_file row, the bytes, then claimFiles: describe, ownedBySlot, release, commit, each with its write hooks and audit rows) resolves as a microtask. The loader buffers inserts into 200-row batches, and after each flush settleSeedFiles claims the batch's rows back to back. Those claims involve no I/O at all, so a whole batch ran as one unbroken microtask chain, and a sign-in arriving meanwhile was not even read off the socket until it ended. The stalls landed once per batch (about 8 for 1,500 rows), and a CPU profile put the time in better-sqlite3's synchronous _query and prepare. Control on the same boot without file values: 1,500 rows and also 30,000 rows finish inside the 8 s inline budget, with about 2,140 acquires in all, so the cost is the per-row file work and not row count.

The fix, in the producer (packages/metadata-protocol, inside the claim's file surface):

  1. seed-files.ts gets yieldToEventLoop(), one setImmediate turn. This is the cure @objectstack/core's import runner already applies to the same trap. prepareRow ends with it when the row stored or kept files, and settleRow ends with it (in a finally) after every row's claim. A request now waits behind one row's files, never behind a batch's.
  2. seed-loader.ts writes a dataset that carries seed files (SeedDatasetFiles.carriesFiles, the bundled files map is non-empty) in insert batches of SEED_FILE_BATCH_SIZE = 20 instead of 200. With the settle loop broken up, the remaining stall was the single insertMany of 200 file-bearing rows: about 1.5 s, since each row's file-reference hooks cost about 7 ms on sqlite. At 20 rows that is about 150 ms. Datasets without seed files keep the 200-row batch.

⛔ No pool size or timeout was raised. The seed's wall time is unchanged within boot noise (about 25–30 s of seed in each 1,500-file run).

packages/metadata-protocol/src/protocol.ts and packages/services/service-storage/src/field-file-service.ts are untouched: neither holds anything.

Tests

New describe in packages/metadata-protocol/src/seed-loader-seed-files.test.ts. It seeds 60 files on an object with no self-reference, so the loader takes the batched-insert path the stall was measured on. The suite's fake engine and field-file service resolve purely in microtasks, which is the property of better-sqlite3 that matters, so the pin is deterministic without a driver:

  • stores at most one row's files and settles at most one row's claim between two macrotask turns: samples progress on every setImmediate turn while the load runs.
  • serves an unrelated read issued during the seed within one row's file work: a sys_user read issued from a macrotask, standing in for the sign-in's user lookup.
  • writes a dataset that carries seed files in insert batches of at most 20 rows.

Ablations (each mutation landed and restored by scripts/ablation-replace.mjs, which checks the anchor and blob counts and proves the restore against the HEAD blob; the test imports source, so no dist/ is involved):

  • A: yieldToEventLoop resolves at once. Pins 1 and 2 red: no macrotask turn ran while the seed ran: expected 0 to be greater than 1, the read never completed: expected undefined to be defined. 2 failed, 17 passed.
  • B: SEED_FILE_BATCH_SIZE = 200. Pin 3 red: expected 60 to be less than or equal to 20. 1 failed, 18 passed.
  • C: only the settle-side yield removed. Pin 1 red: expected { maxStored: 1, maxClaims: 20 } to deeply equal { maxStored: 1, maxClaims: 1 }, which is a whole batch's claims settled with no turn, the measured shape. The first attempt at C was a no-op (the tool refused: the replacement was a substring of its anchor, so the test never ran); it was re-run with a distinct replacement.
  • A first version of the pins seeded clm_version, whose self-lookup sends the loader down the sequential per-record path. Leg B stayed green against it, which exposed the pins as vacuous for the batched path. They were moved to clm_doc before the legs above.

Runs on the final head 4bf7600183 unless noted:

Readings
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 src/seed-loader-seed-files.test.ts at 4bf7600183: Tests 19 passed (19), lock VERDICT command-exit 0.
  • pnpm --filter @objectstack/metadata-protocol typecheck at 4bf7600183: exit 0 (same && chain). The test file is in the tsc program (--listFiles names it once).
  • pnpm --filter @objectstack/metadata-protocol test at d294c889 (the merge of origin/main; the only later commit types one test literal): Test Files 269 passed | 3 skipped (272), Tests 21619 passed | 19 skipped (21638), after rebuilding the package's dependency closure (pnpm --filter '@objectstack/metadata-protocol^...' build).
  • pnpm exec eslint on the three changed source files: exit 0.
  • Boot measurement: scratch scripts, not committed; numbers in the table above.

Acceptance notes

  • Not reproduced here: the card's Knex: Timeout acquiring a connection refusal. The measured pool waits never exceeded 21 ms, so this run shows the sign-in slowdown (5.2 s at worst on 1,500 small files) and its cause, not the refusal. hotclm's 28 s sign-in and its refusals are consistent with longer per-batch stalls (bigger files, more write hooks), but that is inference, not a reading.
  • A residual stall at the end of the seed, outside this diff: one 3.3 s event-loop stall as the seed settles (a sign-in took 3,548 ms then). The profile slice shows emitSeedSettled, then claimSeedOwnershipOnSettle, then claimSeedOwnership (@objectstack/plugin-security, the ADR-0138 hand-over on app:seeded), with a driver updateMany and per-row insert and audit frames. It re-owns the seeded rows in one unbroken chain, scales with rows rather than files, and runs for any seed that overruns the inline budget. Reported to the seat, not fixed here.
  • The boot measurement used scratch scripts (pool tracer, sign-in probe) that are not committed.

Verification

Gate families derived for this diff by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 4bf7600183 (59 commands), each run on 4bf7600183 with its exit code captured before any pipe. --ran reconciliation: 59 derived, 59 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every line records its exit code).

59 gate commands, exit codes
  • node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0
  • node scripts/check-adr-0087-registration.mjs --self-test: exit 0
  • node scripts/check-changeset-no-major.mjs --base origin/main: exit 0
  • node scripts/check-closing-keyword-parity.mjs: exit 0
  • node scripts/check-closing-keyword-parity.mjs --self-test: exit 0
  • node scripts/check-comment-mask-adoption.mjs: exit 0
  • node scripts/check-comment-mask-adoption.mjs --self-test: exit 0
  • node scripts/check-comment-mask-corpus.mjs: exit 0
  • node scripts/check-dts-emitted.mjs --self-test: exit 0
  • node scripts/check-empty-changeset.mjs --base origin/main: exit 0
  • node scripts/check-empty-changeset.mjs --self-test: exit 0
  • node scripts/check-issue-citations.mjs: exit 0
  • node scripts/check-plugin-teardown-shape.mjs: exit 0
  • node scripts/check-plugin-teardown-shape.mjs --self-test: exit 0
  • node scripts/ci/assert-turbo-remote-cache.mjs --self-test: exit 0
  • node scripts/ci/plan-test-core-shards.mjs --self-test: exit 0
  • node scripts/ci/prune-turbo-cache.mjs --self-test: exit 0
  • node scripts/docs-audit/check-affected-docs.mjs: exit 0
  • node scripts/docs-audit/check-drift-comment.mjs: exit 0
  • node scripts/pm/release-rehearsal-clone.mjs --self-test: exit 0
  • node scripts/release-pending-publish.mjs --self-test: exit 0
  • pnpm --filter @objectstack/spec run check:duration-unit-keys: exit 0
  • pnpm check:changeset-gate-self-tests: exit 0
  • pnpm check:ci-filter-parity: exit 0
  • pnpm check:closed-ceremony-text: exit 0
  • pnpm check:cross-package-test-inputs: exit 0
  • pnpm check:doc-authoring: exit 0
  • pnpm check:driver-memory-census: exit 0
  • pnpm check:dts-closure: exit 0
  • pnpm check:dual-build-cjs-loads: exit 0
  • pnpm check:durability-log-level: exit 0
  • pnpm check:engine-double-contract: exit 0
  • pnpm check:error-status-conformance: exit 0
  • pnpm check:gitlink-declared: exit 0
  • pnpm check:issue-citations: exit 0
  • pnpm check:keyed-text-bounds: exit 0
  • pnpm check:lean-entry-closure: exit 0
  • pnpm check:logger-receiver-detach: exit 0
  • pnpm check:nul-bytes: exit 0
  • pnpm check:objectql-double-limit: exit 0
  • pnpm check:org-identifier: exit 0
  • pnpm check:page-declaration-shape: exit 0
  • pnpm check:platform-object-tenancy-census: exit 0
  • pnpm check:pm-changeset-deadline-census: exit 0
  • pnpm check:published-files: exit 0
  • pnpm check:query-options-erasure: exit 0
  • pnpm check:refd-timer-probe: exit 0
  • pnpm check:registry-log-declared: exit 0
  • pnpm check:rest-log-spy-declared: exit 0
  • pnpm check:slot-lookup: exit 0
  • pnpm check:sourcemap-no-sources-content: exit 0
  • pnpm check:system-context-census: exit 0
  • pnpm check:test-source-alias: exit 0
  • pnpm check:tier-file-adoption: exit 0
  • pnpm check:type-check-coverage: exit 0
  • pnpm check:type-check-debt: exit 0
  • pnpm check:undeclared-dep-imports: exit 0
  • pnpm check:watch-hint-literal: exit 0
  • pnpm check:where-matcher: exit 0
  • The first union (on d294c889) caught one real red: check:query-options-erasure, where the test surface grew 236 to 237 through a stand-in read's as any. It was fixed in 4bf7600183 by typing the literal. That union also read check-plugin-teardown-shape --self-test as a prerequisite miss (shallow clone; its pinned fixture commit was fetched) and check:dual-build-cjs-loads as PREREQUISITE NOT MET. Both are green above on a real measurement.
  • Declared to CI, not run locally: test:repo (the vitest repo project; the diff touches none of its tests), the repo-wide pnpm lint, the type-check lanes, and the artifact-roster and wide-population families the derivation lists outside its 59.
  • Dogfood battery: not owed (no ! in the title, Clause-②: no with no arm).

Generated by Claude Code

… so a large file seed does not starve requests

On a synchronous driver every await of the seed's per-row file work resolves
as a microtask, so the claims settled after each 200-row insert batch ran as
one unbroken chain and requests (sign-ins) waited unread behind it. Each row's
file work, stored and settled, now ends with one macrotask turn.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
…insert path; add changeset

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSeKYmQXM3rHJcPnrEPk4B
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 9 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/validation.mdx (via SeedLoaderService (symbol, a top-level class))
  • content/docs/protocol/objectql/state-machine.mdx (via SeedLoaderService (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via SeedLoaderService (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 — 11 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 0ea90c7a18504e5b67f137aefe7d3e7bb0b7157b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 94015eabff04184baf366d001c3abd59c5d0599f — the merge of head 4bf76001838f9e0d4884a9903c5fba15221aa440 into base 0ea90c7a18504e5b67f137aefe7d3e7bb0b7157b, 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 94015eabff04184baf366d001c3abd59c5d0599f && git checkout 94015eabff04184baf366d001c3abd59c5d0599f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0ea90c7a18504e5b67f137aefe7d3e7bb0b7157b 4bf76001838f9e0d4884a9903c5fba15221aa440 && git checkout -B drift-repro 0ea90c7a18504e5b67f137aefe7d3e7bb0b7157b && git merge --no-ff 4bf76001838f9e0d4884a9903c5fba15221aa440

node scripts/docs-audit/affected-docs.mjs --json 0ea90c7a18504e5b67f137aefe7d3e7bb0b7157b

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0ea90c7a18504e5b67f137aefe7d3e7bb0b7157b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 3 · session_01QSeKYmQXM3rHJcPnrEPk4B · 2026-10-27T17:45Z

Head: 4bf76001838f9e0d4884a9903c5fba15221aa440

  • 形态: draft → main, first line Fixes #26670 (fully delivered). 4 files, +164/−3, all in packages/metadata-protocol + one patch changeset carrying Clause-②: no / Metadata-protocol: none. check-governed-merges --pr 26822: NOT governed. protocol.ts (seat 1's hot file) and field-file-service.ts untouched, as fenced.
  • 前提实测 — the seat's mechanism assumption is falsified, publicly corrected here: the claim and dispatch said the seed loader holds a pooled connection or transaction. The dev measured otherwise: a tarn pool tracer read 37,494 acquires, longest hold 44 ms, longest wait 21 ms, and no transaction on the seed-file path. The starved resource is the event loop: on a synchronous driver each seed file's ~25 awaited engine calls are microtasks, and the post-flush claim loop ran as one chain (2.8–5.0 s of lag per 200-row batch). The card's symptom reproduced (sign-ins up to 5,195 ms vs a 246 ms control); its "Timeout acquiring a connection" log did not. The fix follows the measurement, not the assumption: a setImmediate turn after each row's files and each row's claim, and 20-row insert batches for a dataset that carries seed files. After: lag 170–317 ms per 5 s window, sign-in p50 187 ms / max 447 ms during the seed. No pool or timeout change (the dispatch's fence held).
  • Seat read of the diff: the yield sits in a finally around settleClaims, so a failed claim still yields; carriesFiles keys the smaller batch to datasets that name seedFile, every other dataset keeps BULK_BATCH_SIZE. Same cure as import-runner.ts's row loop, which the docblock cites.
  • 测试 / 抽查: 3 pins in seed-loader-seed-files.test.ts with microtask-only fakes (the synchronous-driver property). Three ablations recorded: yield resolves at once → 2 red; batch 200 → 1 red; settle-side yield removed → 1 red; each restored to the HEAD blob. A first pin version on a self-lookup object was caught vacuous by ablation B and moved, which is the evidence the ablations are real.
  • 门禁: dev's derived set at 4bf7600183: 59/59 exit 0, --ran 0 unrun. CI on this head still converging at this record (10 success, 11 skipped, 13 in progress, 0 red); landing waits on every check green.
  • 范围外去向: the remaining stall in claimSeedOwnership (plugin-security, ADR-0138 hand-over on app:seeded, sign-in 3.5 s measured after this fix): filed plugin-security: the seed-ownership hand-over on app:seeded (claimSeedOwnership) re-owns a large seed in one unbroken run on a synchronous driver, so a sign-in during it waits seconds #26825.
  • 落地条款: ordinary queue landing by the seat once every check on this head is green or an expected skip.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 18:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 652da20 Oct 27, 2026
46 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-26670-seed-file-pool branch October 27, 2026 18:45
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/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A seed that stores many seedFile files holds the sqlite pool: sign-ins during the boot seed time out (Knex: Timeout acquiring a connection)

2 participants