Skip to content

feat(metadata-protocol): cache the getMetaItems overlay read, keyed on the engine write epoch - #12727

Merged
os-zhuang merged 8 commits into
mainfrom
claude/issue-11967-getmetaitems-overlay-cache
Aug 27, 2026
Merged

feat(metadata-protocol): cache the getMetaItems overlay read, keyed on the engine write epoch#12727
os-zhuang merged 8 commits into
mainfrom
claude/issue-11967-getmetaitems-overlay-cache

Conversation

@claude

@claudeclaudeBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes#11967

Leg D (ship-second) of the accepted #11633 cross-request caching design — maintainer acceptance on #11633 comment 5404681591, 2026-08-25, verbatim and untranslated: 「接受你的建议,继续」, with forks 1A / 2B / 3A / TTL-0.

getMetaItems re-read sys_metadata on every authenticated request. That read is now cached across requests, keyed on the engine write epoch the #11968 substrate shipped.

The SchemaRegistry-hydration trap — stated, and designed around

This is the card's hard requirement, so it goes first and in my own words.

The trap.getMetaItems is not a pure read. Its overlay branch registers overlay rows back into the SchemaRegistry through hydrateOverlayIntoRegistry, gated to unscoped (control-plane) kernels. A cache that skips the read also skips that registration, and the symptom is not a stale answer — it is a registry that quietly stops being populated. The design offered two exits: cache the merged post-hydration result, or keep hydration outside the cached path and prove it idempotent.

What I did instead. I took neither exit, because both leave the trap live and manage it. The cached value sits upstream of the hydration branch, not downstream of it. What is cached is the overlay row set — the value the two queryByOrg calls produce — and never the merged answer.

Everything the row set feeds still runs on every single call, hit or miss: the overlay parse, the package-aware merge, hydrateOverlayIntoRegistry, the MetadataService merge, the disabled-package filter, the nav contributions, and the decorations. A cache hit changes exactly one thing — where the rows came from — and nothing about what is done with them. So hydration cannot be skipped by a hit, and its idempotence never has to be proven, because it is not being replayed: it runs once per call, exactly as it does today.

What I measured, not assumed.§2 of the new pin file asserts this directly rather than arguing it. registerItem is recorded per call: after two calls, the engine has been read once (finds.length unchanged on the second) and the registry has been hydrated twice, the same rows in the same order. A third call makes it three. A cache placed below the merge leaves that count frozen at one — which is precisely what ablation 2 demonstrates from the other side.

The trap has a bigger sibling, and the same placement closes it. Measured on this ref, getMetaItems' answer is a function of four mutable sources, and the write epoch observes only one:

sourceepoch sees it?
sys_metadata rows (engine.find)yesSysMetadataRepository writes through engine.insert/update/delete, so every write crosses the middleware seam
SchemaRegistry (listItems, isPackageDisabled, applyNavContributions)no — in-memory registration, no engine operation
MetadataService (metadataService.list)no — loader-driven
artifact protection (lookupArtifactItem)no — in-memory

An epoch-keyed cache of the merged answer would therefore be serving three sources whose changes nothing in its key can observe. Caching the row set keeps the cache's reach exactly co-extensive with what its key can validate. That containment, not the hydration detail alone, is the argument for this shape, and it is the property to protect when editing the file.

Premise re-verification on the current ref

The card says the design was measured at 992161b728 and asked for the seams to be re-verified as they actually shipped. Four findings.

  1. Confirmed — the epoch seam is process-wide and object-agnostic.ObjectQL.executeWithMiddleware calls writeEpoch.bump('write') on every insert/update/delete on any object, ahead of the whole middleware chain. This is the same over-invalidation leg C reported: any business write anywhere retires this cache. It is a hit-rate cost and never a correctness one, and it is the safe direction.

  2. Refuted — metadata.changed is not a live trigger for this leg. The design says leg D's epoch bumps on "any metadata.changed watch event" and that the channel is what makes it multi-node-safe. That channel is published by MetadataManager.notifyWatchers, driven by loader and repository events. The writer whose rows this cache holds is SysMetadataRepository, and packages/metadata-protocol/src/protocol.ts contains no notifyWatchers, subscribe or watchService call at all — a sys_metadata overlay write emits no metadata.changed event. Subscribing to it would have bought this cache nothing while reading to the next maintainer as a live invalidation path that never fires, so I did not. The cross-node story for leg D is the substrate's own authz.invalidated channel — a peer hint calls epoch.bump('remote'), which retires these entries for free — plus the TTL as the bound. That is the substrate's stated contract and it needed no new mechanism.

  3. Corrected — the cited file moved and the hot-path read count is smaller than the design's range. The design cites protocol.ts:5993-6001; the implementation lives at packages/metadata-protocol/src/protocol.ts, getMetaItems at :6009. The design records 2–4 reads. On the hot path it is exactly 2: enforceApiAccess gets there through RestServer.loadObjectItems, which passes noorganizationId, so queryByOrg(orgId) never runs and the cost is queryByOrg(null) plus the alt-type retry that the empty first result fires. That is the case the design calls the bulk of the win, and it is the one the pins measure.

  4. Confirmed and carried — leg C's "no seam, no cache" rule transfers unchanged. A success is cached only when the engine exposes the full { current, bump, subscribe } write-epoch surface, checked structurally because @objectstack/metadata-protocol does not depend on @objectstack/objectql. A partial { current } object is explicitly not a seam and does not licence caching. Every existing test double takes the declining path and keeps its exact query multiset; only a real engine caches.

Leg C's second rule does not transfer, and that is a finding rather than an omission. Leg C retires success entries only, to protect #10221's failure memo from a write restarting its log spam. Leg D has no failure memo to protect: the unprovisioned-store path throws and is answered by rethrowUnlessMetadataStoreUnprovisioned, and nothing is cached when the read throws. Only successful reads populate this cache — including, deliberately, the empty one.

Aliasing — rows are cloned in both directions

Downstream of the read, record.metadata is handed on to a merge chain that mutates it whenever it is already an object rather than a JSON string (Object.assign(data, patch), data._packageId = ..., data._draft = true). So the snapshot is cloned on store, or this call's own merge corrupts it, and cloned on serve, or the first hit's merge corrupts it for every later hit. A fresh engine read hands back fresh rows, so cloning is what keeps a hit byte-equivalent to a miss. A row set that cannot be cloned is not cached. §6 pins it: a caller mutating a returned item does not affect the next read, paired with a hit assertion so the pin is measuring a real cache hit.

Tests

20 new cases in packages/metadata-protocol/src/meta-overlay-cache.test.ts.

Every staleness assertion is paired with a hit assertion on the same engine — a repeat issues zero reads — so neither "invalidation works" nor "the cache hits" can carry a case alone. The acceptance criterion is §1: across an epoch bump (write, epoch change, fresh read) the cached answer is deep-equal to the answer a no-seam engine gives for the same rows, array order included, and the pin asserts the newly published row is present — the end of the chain, not the middle, so a clear-then-repopulate-from-a-stale-read implementation fails it.

Test-path resolution: source-resolved, not dist-mediated — measured, not assumed. Both subjects are imported by relative specifier, so vitest resolves them from this package's source. Both ablations then measured it rather than restating it: each mutated only source, ran vitest with no rebuild of any kind, and the behaviour changed on that run. A dist-mediated path would have stayed green through both, so no rebuild leg applies here. (The sibling protocol.hydrate-overlay-canonical-type.test.ts did need mutate-rebuild-prove, because its target lives in @objectstack/spec and resolves through exports to built output.)

Ablation 1 — the staleness half.readMetaOverlayCache's if (entry.epoch !== epoch) return undefined; neutered to if (false). Predicted in writing and committed before the mutation (5dabef3c): RED, exactly 5 named cases. Observed: RED, 5 failed / 15 passed (20) — the exact count and the exact named set.

Ablation 2 — the hit half. The writeMetaOverlayCache(...) call removed, so the cache is read but never populated. Predicted in writing and committed before the mutation (5dabef3c): RED, exactly 10 cases — every case carrying a "the repeat read nothing" assertion. Observed: RED, 10 failed / 10 passed (20) — again the exact count and the exact named set.

Both predictions were committed ahead of their mutation, and both were re-predicted and re-measured after §9 was added rather than left to stand on the earlier tree.

The two failure sets overlap on exactly four cases, and that overlap is not slack: those are the cases written to carry both kinds of assertion, so each ablation kills a different assertion inside the same case. Outside the overlap the sets are disjoint — ablation 1 alone takes one case, ablation 2 alone takes six — which is what shows the two halves test different things.

Named positive control, green under both ablations: §3 "an engine with no write-epoch seam keeps its exact query multiset". It never stores an entry, so ablation 1 never reaches the neutered comparison and ablation 2 removes a store it never made. Its staying green is what shows each ablation cut the intended half rather than the cache as a whole.

Both mutations were confirmed on disk before each run by grep counts of the injected and deleted text plus a git hash-object comparison against the HEAD blob, and both restores were proven the same way — worktree blob equal to the HEAD blob, zero marker residue, git diff HEAD empty. Each ablation script carried a trap ... EXIT INT TERM restore with absolute paths.

Verification

Union derived from the actual changed paths with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, and re-derived after the final commit. Every exit code captured before any pipe.

  • @objectstack/metadata-protocol full suite: 1984 passed, 10 skipped, 0 failed (144 files).
  • @objectstack/rest, the 76 test files that exercise getMetaItems: 1382 passed, 0 failed.
  • @objectstack/runtime, the 10 such files: 483 passed, 0 failed.
  • @objectstack/objectql, the 16 such files: 301 passed, 0 failed.
  • Gate families green on the final tree, including the four this change's kind moves: check:engine-double-contract, check:where-matcher, check:objectql-double-limit, check:query-options-erasure. Plus check:nul-bytes, check:test-source-alias, check:type-source-resolution, check:cross-package-test-inputs, check:ci-filter-parity, check:comment-mask-adoption, check:durability-log-level, check:filter-alias-parity, check:published-files, check:type-check-coverage, the doc family for the environment-variable table, and the changeset family.
  • check:type-check-debt is NOT MEASURED, not green and not red. Its --re-measure half refuses on this worktree: 27 workspace dependencies of the ledgered packages have no built type entry point on disk. Its structural half (check:type-check-coverage) ran to OK before the throw and is counted above. The substantive question it would have asked was answered directly instead: the ledger records 63 frozen errors for @objectstack/metadata-protocol, tsc --noEmit on the final tree reports exactly 63, and none of them names either file this PR touches — so the ratchet does not drift. --listFiles confirms both new files are inside the package's tsc program, so that is a real reading and not a vacuous one.
  • check:engine-double-contract was red once and is green now. It reported that the new file pinned a findOne double the ledger did not record, and offered --write. I removed the double instead: no case in the file calls findOne, the overlay path issues exactly one verb, and an unexercised double is a pin that cannot fail. The ledger is untouched.

OS_METADATA_OVERLAY_CACHE_TTL_MS is registered in content/docs/deployment/environment-variables.mdx, the canonical table. Default 30s, 0 is a real off path that restores the pre-change query multiset exactly (§5 pins it), malformed folds to off — the same arm and the same reason as OS_LOCALIZATION_CACHE_TTL_MS.

Storage is bounded

The epoch is process-wide, so when it moves every entry is stale at once. A write at a new epoch therefore drops the whole bucket rather than leaving orphans behind — without it a long-lived process accumulates one never-evicted entry per distinct (type, packageId, organizationId) ever requested, which is bounded in principle by the tenant count and not a bound worth shipping. The per-entry epoch comparison remains the validity rule and is not redundant with this: eviction happens in the store, which only runs on a miss, so between a write and the next miss it is the read-side comparison alone that refuses a stale entry. §9 pins the eviction through a package-internal diagnostic, because it has no behavioural signature and would otherwise be an unpinned optimisation.

Scope

packages/spec untouched. packages/core/src/security/index.ts untouched. content/docs/releases/ untouched (all four named pages read, none edited). #12623 (SchemaRegistry.registerObject's packageId) sits in the same registry family and is deliberately not touched here. The previewDrafts read is left entirely uncached — the cached value is the active-overlay row set, whose WHERE clause does not depend on it — so draft preview keeps today's exact behaviour. The overlay read carries no user context, so its result is fully determined by the key.

Docs drift — closed out

Re-derived independently with node scripts/docs-audit/affected-docs.mjs --json d29e42f8b1b44b92ea58c1d05e619f27c027c3a9: 29 pages, 25 hand-written plus 4 release-owned, from 25 anchors.

The WHAT-vs-WHEN discriminator held, and it earned its keep — it put exactly one page in scope. A cache whose acceptance criterion is cached answer is identical to uncached answer cannot falsify a page documenting whatgetMetaItems returns, because that identity is what the pins assert (deep-equal including array order, plus key separation and clone isolation). It can falsify a page documenting when a change becomes visible.

There is a second, stronger reason most of the 25 are out of scope, and it is worth separating from the invariant: 22 of them matched on tokens my diff never touches.protocol.ts is a 20k-line file, and the anchor extractor works per changed FILE, not per changed hunk — so organizationId, packageId, expiresAt and the routes /forms/:slug, /book/:name/tree, /:type/:name/publish were named because they appear somewhere in a file I edited, not because they appear in my change. expiresAt is a pure name collision: mine is a cache-entry field, theirs is session expiry.

Pages opened and read, with verdicts:

pagewhy namedverdict
concepts/metadata-lifecycle.mdxnot named by the bot — found by handIN SCOPE — corrected in this PR
kernel/services-checklist.mdxgetMetaItemsread; row 134 states the request/response shape (WHAT — the identity pin covers it), row 150 scopes its claim to metadata.changed invalidating peer registry caches, which stays true. No edit.
kernel/contracts/metadata-service.mdxpackageId, publish routeread; documents metadataService.watch — a different seam, untouched. No edit.
permissions/authorization.mdxpackageIdread; line 221 claims the sys_permission_set projection re-derives on every metadata mutation "awaited — no staleness window". Still true: the epoch bump precedes the write's own middleware chain, so any read after a write sees a moved epoch. Read-your-writes is exact on the writing node, and that is pinned. No edit.
permissions/permission-sets.mdxpackageIdread; line 309's Discard Overlay "resyncs … immediately" — the discard is an engine delete, so the epoch moves and the next read re-reads. No edit.
protocol/kernel/config-resolution.mdxorganizationIdread (leg C's near-miss precedent, so read deliberately); its freshness prose is about a settings snapshot refreshing on settings:changed — leg C's territory, not this read. No edit.
api/client-sdk.mdxexpiresAt, sdk namesread; ETag conditional metadata caching. The ETag derives from the response body, which the identity invariant holds equal, so the ETag stays correct. The page makes no freshness guarantee to falsify. No edit.
kernel/events.mdxorganizationIdread; data lifecycle hooks only, no metadata-cache or visibility claim. No edit.
18 remaining hand-written pagesorganizationId / packageId / /forms/:slug / expiresAtscanned for visibility-timing language; none states a freshness or query-count claim about the metadata read. Out of scope on both grounds above.

The page the bot could not see — and it was a real one

content/docs/concepts/metadata-lifecycle.mdx is the canonical "Repository → Change Log → Cache → Registry" page and is absent from the bot's 29, because it names MetadataRepository, MetadataCache, MetadataManager and MetadataClusterBridgePlugin — not one symbol from my diff. Exactly the standing blind spot: a rule stated by its inputs shares no identifier with the emitter.

Its cross-replica note ended: "peer nodes invalidate their caches. Note this replays the invalidation event, not the overlay row — peers re-read from the shared database." After this change that last clause is no longer unconditional. metadata.changed invalidates the MetadataManager caches the note is about, but it does not retire the leg-D overlay-read cache — so a peer's re-read can be served from that cache until either an authz.invalidated hint bumps the local epoch or the TTL expires. I added a bounded qualification saying exactly that, naming both retirement paths and the env var, and pointing at the environment-variable table. The local-write case is called out as still exact, because the epoch bump precedes the write's own middleware chain.

Release-owned pages — read only, not edited

content/docs/releases/** is release-owned; all four were read and none was touched.

pagewhy namedwhat it claimsowes anything?
releases/v17.mdx/:type/:name/publishline 2717: "a just-saved overlay is dispatchable immediately rather than after the next listing (#4521)"No.#4521 is the write-side write-through (applyRegistryWriteThrough), which this PR does not touch; and the save is an engine write, so the epoch moves and the next read re-reads. Both mechanisms still hold.
releases/v16.mdxorganizationIdline 504: unscoped GET /meta/:type dedupes package-aware so two packages' same-name items are not collapsedNo. A WHAT claim; the identity pin plus the packageId key-separation case cover it.
releases/v14.mdx/book/:name/treeline 136: /meta/book/:name/tree enforces authorizationNo. Route-name collision; unrelated surface.
releases/index.mdxorganizationIdno metadata, overlay or /meta mention at allNo.

No routing item for the maintainer: no release page describes behaviour this change modifies.

Changeset: minor, argued in the file rather than defaulted.

Gate union derived from the actual changed paths at 4482c848 with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack; the union was re-derived after the final commit and named no new family.


Generated by Claude Code


Generated by Claude Code

@github-actions

github-actionsBot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json d29e42f8b1b44b92ea58c1d05e619f27c027c3a9.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (4 routes)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 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.

Coarse fallback — 7 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 d29e42f8b1b44b92ea58c1d05e619f27c027c3a9packageMentionDocs.

Which tree this was computed on

This run read content/docs from a417163356349e837026578055831a0ee613ae04 — the merge of head 4482c848ab605ee200d1a364e3f1d7b3112c7215 into base d29e42f8b1b44b92ea58c1d05e619f27c027c3a9, 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 a417163356349e837026578055831a0ee613ae04 && git checkout a417163356349e837026578055831a0ee613ae04
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 4482c848ab605ee200d1a364e3f1d7b3112c7215 && git checkout -B drift-repro d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 && git merge --no-ff 4482c848ab605ee200d1a364e3f1d7b3112c7215
node scripts/docs-audit/affected-docs.mjs --json d29e42f8b1b44b92ea58c1d05e619f27c027c3a9

⚠️ 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 d29e42f8b1b44b92ea58c1d05e619f27c027c3a9 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 27, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

authz caching leg D: getMetaItems overlay cache keyed on the registry epoch — resolve the SchemaRegistry-hydration trap explicitly

2 participants

@os-zhuang@claude