perf(objectql): index short name to FQN so registry lookups stop scanning the whole registry - #11102
Conversation
…ing the whole registry `SchemaRegistry.resolveObjectKey` answered the short-name direction by walking every `objectContributors` key and calling `parseFQN` on each. Reached from seven call sites including `getObject`, so a kernel boot that registers N objects and resolves O(N) names did O(N^2) string work — the mechanism behind a hosted environment whose bootstrap outgrew its request waiter and became permanently unservable with no error anywhere. Maintain a short-name -> FQN index Map beside `objectContributors`, mutated only through two private choke points (`openObjectEntry` / `closeObjectEntry`) so the two containers cannot drift. Resolution is unchanged: the index array is the scan's `matches` list with the same members in the same order, so an ambiguous short name still resolves to the first key registered under it and the ambiguity warning still names every match. Refs #10945 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
…ions, add changeset Ablation on this container: healthy ratio 5.7-11.1, full-registry scan 62.5. Threshold placed between them (30x) rather than close to either, and PASSES raised so the small window clears timer noise. Refs #10945 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 14 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 f27a54338fd892051b534161f22d8af611de75e3 && git checkout f27a54338fd892051b534161f22d8af611de75e3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ab47f6974a275586355f06abd83a0975b8637d12 686d0c24832a50d57de885bf860ab5a82e99ea13 && git checkout -B drift-repro ab47f6974a275586355f06abd83a0975b8637d12 && git merge --no-ff 686d0c24832a50d57de885bf860ab5a82e99ea13
node scripts/docs-audit/affected-docs.mjs --json ab47f6974a275586355f06abd83a0975b8637d12
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32586913630 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue-failure disposition: ⛔ NOT re-queuing — known deterministic red, not this PR'sReviewer of record / dispatching seat, Signature (queue build 32586913630): job Triage checklist item 2 — unrelated to this PR. This diff is Known, and deterministic rather than flaky. #10857 established the mechanism: #10695 retired the Not a required check. #11099 carried the identical signature in its queue build (32586810427) at 17:20Z and merged anyway — now Current state, verified rather than assumed: this PR is still What would change this disposition: an ejection notice, or a different failure signature. Either returns this PR to the drive-to-green loop. A repeat of this exact signature does not.
Generated by Claude Code |
Fixes #10945
SchemaRegistry.resolveObjectKeyanswered the short-name direction by walking every key ofobjectContributorsand callingparseFQNon each. It is reached from seven call sites —getObjectamong them — so a kernel boot that registers N objects and resolves O(N) names did O(N²) string work.The consequence was a silence, not a failure: boot got slower purely by an environment accumulating metadata, and once bootstrap outgrew the request waiter every request answered
kernel_warmingand the environment could never be opened, with no error anywhere. Refs objectstack-ai/cloud#1555.The change
resolveObjectKeynow reads a short-name to FQN indexMapmaintained besideobjectContributors.Both containers are mutated only through two private choke points —
openObjectEntry/closeObjectEntry— so they cannot drift apart by construction rather than by discipline. After this changeobjectContributors.set/.deleteappear only inside those two methods, plus the paired.clear()inreset(). A future caller cannot add a contributor list and forget the index half.Resolution is deliberately unchanged. The index array holds the same members in the same order as the list the scan built, so:
ns__namefallback and the explicit-full-key form still work.Behaviour change check (dispatch Zone 2, item 2)
The pre-fix scan returned
matches[0], i.e. first-registered-wins for an ambiguous bare name. An index could as easily have become last-writer-wins with nothing failing, so that is not asserted by hand — the pin re-implements the old loop as an oracle and asserts exact equivalence with it across all 24 permutations of a name set mixing a plain key, two legacy keys colliding on one short name, and a plain key colliding with a legacy one. Any future drift in which FQN wins reds there.Net: no observable behaviour change, and the winner is now pinned rather than incidental.
Verification
Three-leg ablation, with the mutation confirmed on disk at every leg by marker counts (not by an editor's exit code):
objectKeysByShortNameoccurrencesgetAllObjects, legitimate)registry.tsfromorigin/main)No rebuild is involved in that ablation and none is owed: the pin imports its subject as
./registry.js, a same-package relative specifier, which never resolves through packageexportsand therefore never readsdist/. Independent proof that vitest read the edited source: the structural assertions compareobjectKeysByShortName, a field that exists only in this diff and in no built artifact, and they pass.Measured on this container, resolving one name per registered object (32,000 lookups over 4,000 objects vs 4,000 over 500):
The scaling pin asserts a ceiling of 30x, placed between the two measured populations (~2.7x above the worst healthy sample, ~2x below the ablated one) rather than close to either.
objectstack-ai/cloud(scripts/dev-local/bootstrap-curve.mjs) and was not re-run — it is not reachable from this container. The numbers above are the local scaling substitute, which triage named as an acceptable alternative.Gates
pnpm --filter @objectstack/objectql test— 228 files / 4034 tests passed, at the final commit.pnpm --filter @objectstack/objectql typecheck— exit 0.18 gate families green, derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs(which named five the dispatch list did not:check:query-options-erasure,check:type-check-coverage,check:type-check-debt,check:engine-double-contract,check:where-matcher).check:type-check-debtis the one family not run in its CI form — its--re-measureneeds the whole ledgered dependency closure built. It is probed directly instead, which answers the only question this diff can move: tsc over objectql with tests included reports 0 diagnostics naming the new test file and 0 namingregistry.ts. The probe's total (355) is the ledger's 354 plus exactly oneTS6059rootDir diagnostic, which is an artifact of probing through the package's ownrootDir: srcconfig rather than the gate's generated project — the same one the ledger note records leaving when it went 355 to 354.Repo-wide
pnpm lintis CI's run. Locally it is narrowed to the two changed files and the narrowing is measured, not assumed: population taken from eslint's own config, file count read from--format json(2 files, 0 errors, 0 warnings), and the config declares noparserOptions.project— type-aware linting is off, so this diff cannot move the verdict on any file it does not touch.Generated by Claude Code