Repository navigation
SchemaRegistry.resolveObjectKey scans the whole registry per lookup, making kernel boot quadratic in stored metadata #10945
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p0Critical: blocker, must ship before MVPCritical: blocker, must ship before MVP
on Aug 22, 2026 huangyiirene commented
on Aug 22, 2026 CollaboratorMore actionsTriage first-touch (bare-card sweep): queued
pm:queue, Bug +priority:p0,domain:engine(lands inpackages/objectql/src/registry.ts).p0 rationale: this is the measured mechanism behind cloud#1555 — a hosted environment whose bootstrap outgrows the 20s request waiter becomes permanently unservable with no error anywhere, purely by accumulating metadata; production is at 134s today. Quadratic curve pinned to R²=0.994 with a CPU profile naming the loop. Queue-jump is priority, not exemption — claim protocol and the registry file's in-flight neighbours still apply.
Fix per the card's direction: a short-name→FQN index
Mapmaintained besideobjectContributorsin register/unregister, preserving the ambiguity warning via per-name match arrays. The two properties the card names are the required pins: lookup independent of registration order, andunregisterObjectkeeping both maps in step (the registry.ts:2350 extraction note is the contract — a second index is exactly where read/removal could disagree). Acceptance: the seven call sites go O(1); a scaling test (or the cloud rig re-run, stated which) shows the bootstrap curve flatten.
Generated by Claude Code
Maintainer ruling (2026-08-22, verbatim): 「#10945 判了 p0:注册表短名查找全表扫描 ⇒ boot 随存量元数据二次方增长,就是 cloud#1555『134s bootstrap vs 20s waiter、环境永久打不开』的机制卡。修法一张索引 Map,engine 席下一轮就能派。」
Mechanism link for the record: cloud#1555's incident math — every registered object triggers short-name resolutions that each scan the whole registry, so boot cost ~O(n²) in stored metadata items; jack's environment crossed the 20s kernel waiter at ~134s bootstrap and 503'd until placement+seed fixes masked the constant factor. This card removes the QUADRATIC factor itself (one keyed index Map alongside the registry), which also shrinks every environment's cold-boot as metadata accumulates — the placement work (cloud#1521) only fixed the per-leg constant. Queued for the engine seat's next round per the ruling.
Claim: PM loop round 1
Session:session_01RfyXxZ2WPjcjhuXpiQQc3y
Branch:claude/issue-10945-registry-shortname-index
Worktree:objectstack-issue-10945
Domain:domain:engine
File surface:packages/objectql/src/registry.ts(region:resolveObjectKey+ the register/unregister sites that must maintain the second index) (stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus(tier pernode scripts/pm/dispatch-gates.mjs --tier packages/objectql/src/registry.ts— no path-derived mandate, floor sonnet/default opus; raised to opus, not left at floor, because the ordering/unregister invariants are a judgment face, not a gate-decided one)
Clause-②: no — an internal short-name→FQN index behind aprivatemethod; the accept set is unchanged and no public surface widens. Flagged for the dev: if the repair turns out to require changing whatgetObject(name)resolves to for an ambiguous short name in a way callers can observe, stop and report rather than deciding it.
Serial constraints cleared:packages/objectql/src/registry.tshas no in-flight claim and no open PR — lane has zeropm:dispatchedcards at claim time. Last touch on the file is9b0172d(unrelated). Sibling cards in this batch (#11020packages/formula, #10997driver-sql, #11015metadata-protocol/runtime) are package-disjoint from this surface; none of them pins registry resolution behaviour.Premise re-verified live on
origin/main@4257e4ebefore dispatch:resolveObjectKeyatregistry.ts:2366still scansthis.objectContributors.keys()withparseFQNper entry, and all seven call sites named in the card are present (1842, 2346, 2453, 2476, 2703, 3133, 3365).
Generated by Claude Code
os-dev-report
{ "issue": 10945, "status": "done", "branch": "claude/issue-10945-registry-shortname-index", "pr": "https://github.com/objectstack-ai/objectstack/pull/11102", "premise_still_valid": true, "summary": "Premise re-verified on origin/main @ 4257e4e before building: resolveObjectKey still scanned this.objectContributors.keys() calling parseFQN per entry, and all seven named call sites were present (1842, 2346, 2453, 2476, 2703, 3133, 3365). Added a short-name to FQN index Map (objectKeysByShortName) beside objectContributors, making resolveObjectKey O(1). Both maps are mutated ONLY through two private choke points (openObjectEntry / closeObjectEntry) plus the paired clear() in reset() - after the change objectContributors.set/.delete appear nowhere else in the file, so the read path and the name-addressed removal path cannot drift (Zone 3's single choke point, verified by grep). ZONE 2 ITEM 2 - NO OBSERVABLE BEHAVIOUR CHANGE: the ambiguous-short-name winner is still first-registered-wins. The index array is appended to in lockstep with objectContributors.set, so it holds the same members in the same order as the scan's matches list; matches[0] is unchanged, the ambiguity warning still fires and still names every match in registry order. That is pinned as an EQUIVALENCE against a re-implementation of the old loop over all 24 permutations of a mixed name set, not as a hand-written expectation, so a future drift to last-writer-wins reds. Zone 2 item 1 confirmed, item 3 confirmed (resolveObjectKey is private, reached via getObject; no exported signature or public behaviour changed - not a contract-widening card). File surface honoured: packages/objectql/src/registry.ts only, plus one new test file and one changeset. No sibling package touched. Main advanced 4257e4e -> ab47f6974 during the run; no commit in that range touches packages/objectql, so the branch is not merged with it and the queue rebuild will be the first join.", "tests": "All heavy commands ran through scripts/pm/os-verify-lock.sh; exit codes captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?), and each verdict quoted is the gate's own printed line. Union run at git rev-parse --short HEAD = 686d0c248, which IS the final commit. SUITE: pnpm --filter @objectstack/objectql test -> exit 0, 'Test Files 228 passed (228) / Tests 4034 passed (4034)'. TYPECHECK: pnpm --filter @objectstack/objectql typecheck -> exit 0. DEP CLOSURE built first: pnpm --workspace-concurrency=2 --filter '@objectstack/objectql^...' build -> lock VERDICT command-exit 0. ABLATION (3 legs, script carried trap ... EXIT INT TERM so a foreground-cap SIGTERM could not leave the tree mutated; the trap fired and logged 'TRAP: restored'). NO REBUILD NEEDED OR OWED, and why: the pin imports its subject as './registry.js', a same-package relative specifier that never resolves through package exports and so never reads dist/. Independent proof vitest read the edited source: the structural assertions compare objectKeysByShortName, a field present only in this diff and in no built artifact, and they pass. MUTATION CONFIRMED ON DISK each leg by marker counts anchored on the text being changed (never an editor's exit code): leg FIXED objectKeysByShortName=10, old-scan-loop=1 (the survivor is getAllObjects, which legitimately walks all keys) -> vitest exit 0, 12 passed. leg ABLATED (git restore --source=origin/main -- registry.ts) objectKeysByShortName=0, old-scan-loop=2 (the scan is back), porcelain ' M packages/objectql/src/registry.ts' -> vitest exit 1, '7 failed | 5 passed (12)'. leg RESTORED objectKeysByShortName=10, old-scan-loop=1, porcelain empty -> vitest exit 0, 12 passed. Direction observed = expected (red), and broad: both the scaling pin and the structural in-step assertions fail without the index. SCALING (the card's 'curve flattens' criterion) - LOCAL SUBSTITUTE; the card's own rig lives in objectstack-ai/cloud (scripts/dev-local/bootstrap-curve.mjs) and was NOT re-run, being unreachable from this container. Resolving one name per registered object, min-of-12 back-to-back ratio, registries built outside the clock, JIT warmed; 4,000 lookups over 500 objects vs 32,000 over 4,000 (8x input). FIXED small=0.163ms big=2.326ms RATIO=5.69. ABLATED small=76.285ms big=4888.419ms RATIO=62.50 - 8x input, 64x cost, the quadratic signature exactly. RESTORED small=0.151ms big=2.174ms RATIO=11.14. Big window 4,888ms -> 2.3ms. Committed pin asserts ratio < 30, placed between the two measured populations (~2.7x above the worst healthy sample, ~2x below the ablated one) rather than near either; PASSES raised to 40 after measuring so the small window clears timer noise. GATES - 18 families, exit 0 each. From the dispatch list: check:durability-log-level, check:slot-lookup, check:test-source-alias, check:type-source-resolution, check-ci-filter-parity.mjs ('OK: all 83 declared cross-package glob(s)...'), check-engine-split-ratio.mjs, check-plugin-teardown-shape.mjs ('63 Plugin implementation(s) across 4457 source(s)... 0 known-unreached'), docs-audit/check-affected-docs.mjs, check:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration.mjs ('adds no declared-breaking changeset'), check-changeset-no-major.mjs ('introduces no major bump'), check-empty-changeset.mjs ('No empty-frontmatter changeset introduced'). Plus check-nul-bytes.mjs ('OK (scanned 6416 text file(s)... no raw ASCII control bytes)') and a manual control-byte grep over the PR body. RE-DERIVED with node scripts/pm/dispatch-gates.mjs (no paths passed - it takes its own changeset from the merge-base), which NAMED FIVE THE DISPATCH LIST DID NOT, all run and green: check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher, check:type-check-debt. ENGINE-SPLIT-RATIO - correcting the dispatch's characterisation: it is NOT a line-count ratchet on registry.ts, it is the ADR-0076 D7 commit-ratio metric (engine-core commits also touching other packages, 90-day window), 277/284 = 97.5%, exit 0 - adding lines to registry.ts cannot move it. It DID refuse once, loudly and correctly, for an unrelated reason: shallow clone whose oldest visible commit sat inside the window ('A ratio derived here would be real, plausible and WRONG'). Cleared with the remedy it printed: git fetch --shallow-since=2026-05-17 origin. Any seat opening a fresh worktree on a card carrying this gate will hit the same refusal. DECLARED NARROWINGS (2, both measured rather than skipped): (1) repo-wide pnpm lint is CI's run; narrowed to the two changed files with all three evidence items - population taken from eslint's own config, count read from --format json = 2 files / 0 errors / 0 warnings, and the config declares no parserOptions.project (its own comment: 'no parserOptions.project, no typed @typescript-eslint rules'), so type-aware linting is off and this diff cannot move the verdict on any untouched file. (2) check:type-check-debt in its CI form (--re-measure) needs the whole ledgered dependency closure built; probed directly instead, answering the only question this diff can move - objectql sits in TEST_DEBT at errors:354 and its tsconfig excludes the test glob, so a NEW test file is exactly what could raise that count. tsc over objectql WITH tests included: 0 diagnostics naming registry-shortname-index.test.ts, 0 naming registry.ts. The probe totals 355 vs the ledger's 354 and the delta is fully attributed rather than waved off: exactly one TS6059 ('File .../scripts/dry-run-hash-compat.ts is not under rootDir'), an artifact of probing through the package's own rootDir:src config instead of the gate's generated pure project - the same diagnostic the ledger note records leaving when the entry went 355 -> 354. Class histogram matches the ledger note term for term (TS2339 x115, TS2554 x113, TS7006 x36, TS2345 x24, TS2749 x14, TS2322 x14, TS6133 x9, TS2550 x8, TS18048 x8, TS2353 x4).", "open_questions": [], "out_of_scope_findings": [ "already filed as #10991 (searched before filing; no duplicate created): create-objectstack's declared bin is tracked non-executable, so pnpm install dirties every fresh worktree. Observed here as a stray ' M packages/create-objectstack/bin/create-objectstack.js' mode change after install; left unstaged and excluded from both commits by staging explicit paths." ] }
Generated by Claude Code
ACCEPT — PR #11102. ⏳ Landing held until CI converges (not a reservation about the work).
Reviewer of record:
domain:engineseat,os-zhuang, sessionsession_01RfyXxZ2WPjcjhuXpiQQc3y, round 1. Reviewed against the branch and against GitHub, not against the report.⛔ First, a correction I owe — my dispatch stated a fact that was simply wrong
I told the dev that
node scripts/check-engine-split-ratio.mjsis "a gate matched onpackages/objectql/src/registry.tsby exact path — this card adds lines to precisely the file that ratchet watches," and to treat a failure as "a real constraint on how you land the change."That is false. It is the ADR-0076 D7 commit-ratio metric — engine-core commits that also touch other packages, over a 90-day window — currently 277/284 = 97.5%. Adding lines to
registry.tscannot move it. The dev caught it and said so.This is the failure mode the dispatch protocol warns about in the other direction: a confidently-worded wrong fact in a dispatch order is exactly what a downstream agent inherits without re-checking. It is corrected here rather than only in chat so the next reader of this card does not pick it up.
⭐ The dev also produced a genuinely reusable platform fact from it: that gate refuses to run in a fresh worktree — a shallow clone whose oldest visible commit sits inside the 90-day window, where "a ratio derived here would be real, plausible and WRONG." Cleared with the remedy it prints:
git fetch --shallow-since=<window start> origin. Any seat opening a fresh worktree on a card carrying this gate will hit the same refusal.The property everything else rests on — verified structurally, by me, on the branch
Triage named two required pins; the second (
unregisterObjectkeeps both maps in step) is the one where a second index could silently drift. I checked it directly at686d0c248rather than reading the tests:objectContributors.set → :1379 (only site — inside openObjectEntry) objectContributors.delete → :1401 (only site — inside closeObjectEntry) objectContributors.clear → :3797 (only site — index .clear() paired at :3801)Every mutation of
objectContributorssits inside a choke point that movesobjectKeysByShortNamein the same statement. The two maps cannot drift — that is a structural property of the diff, not an assertion about it. This is the strongest form the pin could have taken.The rest of the checklist
item verdict changed files within surface ✅ 3 — registry.ts, one new test, one changeset. No sibling package touchedclause ② ✅ neither limb — resolveObjectKeyisprivate, no exported signature or public behaviour movedACCEPT path fork (governed) ✅ clean ⇒ ready → merge queue, ⛔ not a human-merge card same-day churn ✅ I verified this myself: git log 4257e4e..ab47f697 -- packages/objectqlis empty, so main's advance during the run genuinely does not touch this packageCI ⏳ 20/28 complete, all success, zero failures; Test Core 1–2, three Dogfood shards, Type Check workspace/debt, Lint & Repo Gates still in_progressZone 2 — my assumptions, and what measurement did to them
All three confirmed, but assumption 2 is the one that mattered and it was answered better than I asked. I flagged that
matches[0]is registration-order-dependent and that an index could silently change which FQN an ambiguous short name resolves to, and asked for the choice to be pinned and called out.The dev pinned it as an equivalence against a re-implementation of the pre-fix scan loop, over all 24 permutations of a name set mixing plain, legacy
<ns>__<name>, and colliding shapes — rather than against a hand-written expectation. That is materially stronger than what I asked for: a hand-written expectation only pins what the fix's author believed, whereas the oracle reds on any drift to last-writer-wins. First-registered-wins is preserved, the ambiguity warning still names every match in registry order, and the loser stays addressable by its full key.Evidence quality
The ablation is three legs with on-disk marker counts each leg (never an editor's exit code), a
trap … EXIT INT TERMso a foreground-cap SIGTERM could not leave the tree mutated, and a restore leg confirmed the same way. Ablated:7 failed | 5 passed— and broad, both the scaling pin and the structural in-step assertions, which is the right shape.Scaling: ablated ratio 62.5 at 8× input (8× more lookups each scanning an 8× longer registry — the quadratic signature exactly) versus 5.7–11.1 with the index; big window 4,888 ms → 2.3 ms.
⚠️ One watch item, recorded not blocking. The committed pin assertsratio < 30, placed between the two measured populations (~2.7× above the worst healthy sample, ~2× below the ablated one). That reasoning is sound and cites an existing precedent — but it is a timing assertion running on shared CI runners, which are noisier than the container it was calibrated on. If it ever flakes, ⛔ the fix is not to delete or weaken it past the point of detection: it is the only assertion that tests the shape rather than the mechanism. Re-calibrate deliberately or move it out of the default suite.The card's own reproduction rig lives in
objectstack-ai/cloudand was not re-run — correctly declared as unreachable from the container rather than claimed. The local substitute measures the same curve.Next step
⛔ Not flipping to ready yet. When the remaining jobs report
completed: successI verify theFixes #10945first-line myself, flip ready → merge queue, and follow to MERGED. Any red returns this to the drive-to-green loop, not to the maintainer.
Generated by Claude Code
- added a commit that references this issue
on Aug 23, 2026
SchemaRegistry.resolveObjectKeyrescans the ENTIRE object registry on every name lookup, so kernel boot is quadratic in how much metadata an environment has stored.It is called from at least seven sites in the same file (1842, 2346, 2453, 2476, 2703, 3133, 3365) —
getObject(name)among them — so a boot that registers N objects and looks up O(N) names does O(N²) string work withparseFQNas the inner loop.Measured
Instrumenting a real per-environment kernel build (
ArtifactKernelFactory.create(), full hosted capability slate, realTursoDriverin remote transport over a local libsql file) and sweeping the number of env-widesys_metadatarows the kernel hydrates at boot (ObjectQLPlugin({ hydrateMetadataFromDb: true }), which the cloud tenant runtime always sets):Round trips are FLAT — this is not I/O. Log-log fit of (bootstrap − baseline) against row count: exponent 1.413, R² = 0.994.
A
--cpu-profof the 8,000-row boot names the loop directly. Self time attributed toparseFQN+resolveObjectKey+getPackagedObjectOwner:Each doubling multiplies the cost by ~3.7 — exponent ≈ 1.89, i.e. quadratic.
parseFQNis the single largest non-database entry in the profile.Why it matters
This is the mechanism behind objectstack-ai/cloud#1555: a hosted environment whose
bootstrapstep takes 134 s against a 20 s request waiter, so every request answerskernel_warmingand the environment can never be opened. The build never fails — it is just slower than anything will wait for. Boot cost that is quadratic in stored metadata means an environment gets permanently unservable purely by being used, with no error anywhere.Direction
objectContributorsis keyed by FQN; the short-name→FQN direction wants its own index maintained alongside it (aMap<shortName, fqn[]>updated in register/unregister), turning every one of these lookups into O(1). The ambiguity warning the current loop emits is preserved by keeping the array of matches per short name.Two properties worth pinning with the fix: a lookup must not depend on registration order, and
unregisterObjectmust keep both maps in step — the extraction note at registry.ts:2350 says the read and the name-addressed removal must not disagree about which contributor a bare name addresses, and a second index is exactly where that could drift.Reproducing
objectstack-ai/cloud:node scripts/dev-local/bootstrap-curve.mjs --hosted --meta-rows 0,1000,4000,8000Refs objectstack-ai/cloud#1555