Uh oh!
There was an error while loading. Please reload this page.
feat(metadata-protocol): cache the getMetaItems overlay read, keyed on the engine write epoch - #12727
Conversation
…of growing the pinned ledger
…ns BEFORE re-measuring
📓 Docs Drift CheckThis PR changes 1 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 7 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 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
|
Uh oh!
There was an error while loading. Please reload this page.
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.getMetaItemsre-readsys_metadataon 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.
getMetaItemsis not a pure read. Its overlay branch registers overlay rows back into the SchemaRegistry throughhydrateOverlayIntoRegistry, 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
queryByOrgcalls 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.
§2of the new pin file asserts this directly rather than arguing it.registerItemis recorded per call: after two calls, the engine has been read once (finds.lengthunchanged 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:sys_metadatarows (engine.find)SysMetadataRepositorywrites throughengine.insert/update/delete, so every write crosses the middleware seamlistItems,isPackageDisabled,applyNavContributions)metadataService.list)lookupArtifactItem)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
992161b728and asked for the seams to be re-verified as they actually shipped. Four findings.Confirmed — the epoch seam is process-wide and object-agnostic.
ObjectQL.executeWithMiddlewarecallswriteEpoch.bump('write')on everyinsert/update/deleteon 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.Refuted —
metadata.changedis not a live trigger for this leg. The design says leg D's epoch bumps on "anymetadata.changedwatch event" and that the channel is what makes it multi-node-safe. That channel is published byMetadataManager.notifyWatchers, driven by loader and repository events. The writer whose rows this cache holds isSysMetadataRepository, andpackages/metadata-protocol/src/protocol.tscontains nonotifyWatchers,subscribeorwatchServicecall at all — asys_metadataoverlay write emits nometadata.changedevent. 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 ownauthz.invalidatedchannel — a peer hint callsepoch.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.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 atpackages/metadata-protocol/src/protocol.ts,getMetaItemsat:6009. The design records 2–4 reads. On the hot path it is exactly 2:enforceApiAccessgets there throughRestServer.loadObjectItems, which passes noorganizationId, soqueryByOrg(orgId)never runs and the cost isqueryByOrg(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.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-protocoldoes 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.metadatais 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.§6pins 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.tsdid need mutate-rebuild-prove, because its target lives in@objectstack/specand resolves throughexportsto built output.)Ablation 1 — the staleness half.
readMetaOverlayCache'sif (entry.epoch !== epoch) return undefined;neutered toif (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
§9was 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-objectcomparison against the HEAD blob, and both restores were proven the same way — worktree blob equal to the HEAD blob, zero marker residue,git diff HEADempty. Each ablation script carried atrap ... EXIT INT TERMrestore 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-protocolfull suite: 1984 passed, 10 skipped, 0 failed (144 files).@objectstack/rest, the 76 test files that exercisegetMetaItems: 1382 passed, 0 failed.@objectstack/runtime, the 10 such files: 483 passed, 0 failed.@objectstack/objectql, the 16 such files: 301 passed, 0 failed.check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:query-options-erasure. Pluscheck: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-debtis NOT MEASURED, not green and not red. Its--re-measurehalf 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 toOKbefore 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 --noEmiton the final tree reports exactly 63, and none of them names either file this PR touches — so the ratchet does not drift.--listFilesconfirms both new files are inside the package's tsc program, so that is a real reading and not a vacuous one.check:engine-double-contractwas red once and is green now. It reported that the new file pinned afindOnedouble the ledger did not record, and offered--write. I removed the double instead: no case in the file callsfindOne, 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_MSis registered incontent/docs/deployment/environment-variables.mdx, the canonical table. Default 30s,0is a real off path that restores the pre-change query multiset exactly (§5pins it), malformed folds to off — the same arm and the same reason asOS_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.§9pins the eviction through a package-internal diagnostic, because it has no behavioural signature and would otherwise be an unpinned optimisation.Scope
packages/specuntouched.packages/core/src/security/index.tsuntouched.content/docs/releases/untouched (all four named pages read, none edited). #12623 (SchemaRegistry.registerObject'spackageId) sits in the same registry family and is deliberately not touched here. ThepreviewDraftsread 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 what
getMetaItemsreturns, 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.tsis a 20k-line file, and the anchor extractor works per changed FILE, not per changed hunk — soorganizationId,packageId,expiresAtand the routes/forms/:slug,/book/:name/tree,/:type/:name/publishwere named because they appear somewhere in a file I edited, not because they appear in my change.expiresAtis a pure name collision: mine is a cache-entry field, theirs is session expiry.Pages opened and read, with verdicts:
concepts/metadata-lifecycle.mdxkernel/services-checklist.mdxgetMetaItemsmetadata.changedinvalidating peer registry caches, which stays true. No edit.kernel/contracts/metadata-service.mdxpackageId, publish routemetadataService.watch— a different seam, untouched. No edit.permissions/authorization.mdxpackageIdsys_permission_setprojection 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.mdxpackageIdprotocol/kernel/config-resolution.mdxorganizationIdsettings:changed— leg C's territory, not this read. No edit.api/client-sdk.mdxexpiresAt, sdk nameskernel/events.mdxorganizationIdorganizationId/packageId//forms/:slug/expiresAtThe page the bot could not see — and it was a real one
content/docs/concepts/metadata-lifecycle.mdxis the canonical "Repository → Change Log → Cache → Registry" page and is absent from the bot's 29, because it namesMetadataRepository,MetadataCache,MetadataManagerandMetadataClusterBridgePlugin— 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.changedinvalidates 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 anauthz.invalidatedhint 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.releases/v17.mdx/:type/:name/publishapplyRegistryWriteThrough), 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.mdxorganizationIdGET /meta/:typededupes package-aware so two packages' same-name items are not collapsedpackageIdkey-separation case cover it.releases/v14.mdx/book/:name/tree/meta/book/:name/treeenforces authorizationreleases/index.mdxorganizationId/metamention at allNo 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
4482c848withnode 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