Skip to content

SchemaRegistry.resolveObjectKey scans the whole registry per lookup, making kernel boot quadratic in stored metadata #10945

Description

@os-zhuang

SchemaRegistry.resolveObjectKey rescans the ENTIRE object registry on every name lookup, so kernel boot is quadratic in how much metadata an environment has stored.

// packages/objectql/src/registry.ts:2366
private resolveObjectKey(name: string): string | undefined {
    const matches: string[] = [];
    for (const fqn of this.objectContributors.keys()) {   // ← every registered object
      const { shortName } = parseFQN(fqn);                 // ← per entry, per lookup
      if (shortName === name) matches.push(fqn);
    }
    …
}

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 with parseFQN as the inner loop.

Measured

Instrumenting a real per-environment kernel build (ArtifactKernelFactory.create(), full hosted capability slate, real TursoDriver in remote transport over a local libsql file) and sweeping the number of env-wide sys_metadata rows the kernel hydrates at boot (ObjectQLPlugin({ hydrateMetadataFromDb: true }), which the cloud tenant runtime always sets):

sys_metadata rows   bootstrap step   marginal ms/row   database round trips
              0            63 ms            —                  242
            500           232 ms         0.338                 243
          1,000           422 ms         0.380                 243
          2,000         1,036 ms         0.614                 243
          4,000         2,627 ms         0.798                 243
          8,000         8,533 ms         1.496                 315

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-prof of the 8,000-row boot names the loop directly. Self time attributed to parseFQN + resolveObjectKey + getPackagedObjectOwner:

sys_metadata rows    registry-scan self time
          1,000                80 ms
          2,000               192 ms   (2.4x)
          4,000               631 ms   (3.3x)
          8,000             2,325 ms   (3.7x)

Each doubling multiplies the cost by ~3.7 — exponent ≈ 1.89, i.e. quadratic. parseFQN is 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 bootstrap step takes 134 s against a 20 s request waiter, so every request answers kernel_warming and 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

objectContributors is keyed by FQN; the short-name→FQN direction wants its own index maintained alongside it (a Map<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 unregisterObject must 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,8000

Refs objectstack-ai/cloud#1555

Activity

  1. added theissue type on Aug 22, 2026
  2. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Triage first-touch (bare-card sweep): queued pm:queue, Bug + priority:p0, domain:engine (lands in packages/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 Map maintained beside objectContributors in 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, and unregisterObject keeping 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

  3. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    ContributorAuthor

    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.

  4. self-assigned this
    on Aug 22, 2026
  5. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    ContributorAuthor

    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 per node 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 a private method; the accept set is unchanged and no public surface widens. Flagged for the dev: if the repair turns out to require changing what getObject(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.ts has no in-flight claim and no open PR — lane has zero pm:dispatched cards at claim time. Last touch on the file is 9b0172d (unrelated). Sibling cards in this batch (#11020 packages/formula, #10997 driver-sql, #11015 metadata-protocol/runtime) are package-disjoint from this surface; none of them pins registry resolution behaviour.

    Premise re-verified live on origin/main @ 4257e4e before dispatch: resolveObjectKey at registry.ts:2366 still scans this.objectContributors.keys() with parseFQN per entry, and all seven call sites named in the card are present (1842, 2346, 2453, 2476, 2703, 3133, 3365).


    Generated by Claude Code

  6. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    ContributorAuthor

    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

  7. os-zhuang commented on Aug 22, 2026

    @os-zhuang
    ContributorAuthor

    ACCEPT — PR #11102. ⏳ Landing held until CI converges (not a reservation about the work).

    Reviewer of record: domain:engine seat, os-zhuang, session session_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.mjs is "a gate matched on packages/objectql/src/registry.ts by 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.ts cannot 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 (unregisterObject keeps both maps in step) is the one where a second index could silently drift. I checked it directly at 686d0c248 rather 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 objectContributors sits inside a choke point that moves objectKeysByShortName in 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 touched
    clause ② ✅ neither limb — resolveObjectKey is private, no exported signature or public behaviour moved
    ACCEPT path fork (governed) ✅ clean ⇒ ready → merge queue, ⛔ not a human-merge card
    same-day churn ✅ I verified this myself: git log 4257e4e..ab47f697 -- packages/objectql is empty, so main's advance during the run genuinely does not touch this package
    CI ⏳ 20/28 complete, all success, zero failures; Test Core 1–2, three Dogfood shards, Type Check workspace/debt, Lint & Repo Gates still in_progress

    Zone 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 TERM so 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 asserts ratio < 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/cloud and 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: success I verify the Fixes #10945 first-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

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

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions