Skip to content

Commit 457ff75

Browse files
claude[bot]claude
andauthored
feat(metadata-protocol): cache the getMetaItems overlay read, keyed on the engine write epoch (#12727)
* wip(metadata-protocol): leg D overlay cache — implementation before any gate * wip(metadata-protocol): leg D pins, ablation results not yet measured * test(metadata-protocol): record both ablation predictions BEFORE mutating * feat(metadata-protocol): leg D overlay cache + env var + changeset * test(metadata-protocol): drop the unexercised findOne double instead of growing the pinned ledger * test(metadata-protocol): bound cache storage; re-predict both ablations BEFORE re-measuring * test(metadata-protocol): record both re-measured ablation outcomes * docs(concepts): bound the cross-replica overlay re-read claim the leg-D cache moves --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72f9165 commit 457ff75

6 files changed

Lines changed: 1067 additions & 23 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/metadata-protocol": minor
3+
---
4+
5+
feat(metadata-protocol): cache the `getMetaItems` overlay read, keyed on the engine write epoch (#11967)
6+
7+
Leg D (ship-second) of the accepted #11633 cross-request caching design
8+
(maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0).
9+
10+
`getMetaItems` re-read `sys_metadata` on every authenticated request. On the hot
11+
path — `enforceApiAccess` → `loadObjectItems` → `getMetaItems({ type: 'object' })`,
12+
once per REST request — that costs **two** sequentially awaited engine queries
13+
whenever the environment holds no overlay rows for the type, because the empty
14+
first result fires the alt-type retry. An app whose objects are all code-authored
15+
paid both on every request. That read is now cached behind invalidation that is
16+
synchronous and in-process rather than TTL-bound.
17+
18+
- **Primary trigger — the #11968 engine write epoch**, read structurally rather
19+
than imported: `@objectstack/metadata-protocol` does not depend on
20+
`@objectstack/objectql`, and the substrate declared `WriteEpochLike` separately
21+
for exactly this kind of consumer. Every `sys_metadata` write reaches it, because
22+
`SysMetadataRepository` writes through `engine.insert/update/delete`.
23+
- **Residual bound — `OS_METADATA_OVERLAY_CACHE_TTL_MS`**, default 30s, `0` = off
24+
and a real path. It bounds one thing only: a peer replica's write on a deployment
25+
with no `authz.invalidated` bridge attached.
26+
- **A success is cached ONLY when the engine exposes the write epoch** — leg C's
27+
rule, re-measured and unchanged here. No seam means the cache declines rather
28+
than degrading to a TTL-only shape, which for this leg would make
29+
publish-visibility a timer. Every existing test double keeps its exact query
30+
multiset; only a real engine caches.
31+
32+
**Grade: `minor`, argued.** Not `patch`: this adds a supported deployment knob
33+
(`OS_METADATA_OVERLAY_CACHE_TTL_MS`, registered in the canonical environment-variable
34+
table) and introduces a bounded staleness window that a multi-node operator must be
35+
able to read about before upgrading — a release note that said only "internal
36+
performance" would under-describe it. Not `major`: no public API changes, no export
37+
is added or removed, `getMetaItems`' request and response contracts are untouched,
38+
and the pins assert the cached answer is identical to the uncached one, so no caller
39+
can observe the difference except in query count. Same grade and same reasoning as
40+
leg C (#11966), which shipped `minor` for the same knob-plus-window shape.
41+
42+
**The SchemaRegistry-hydration trap (#11633 §4 leg D) is resolved structurally, not
43+
by care.** `getMetaItems` registers overlay rows back into the SchemaRegistry as a
44+
side effect of the read, so a cache that skips the read would quietly stop populating
45+
the registry. What is cached here is the overlay **row set** — the value *upstream* of
46+
the hydration branch — never the merged answer downstream of it. Every consumer of
47+
those rows still runs on every call, hit or miss: the overlay parse, the package-aware
48+
merge, `hydrateOverlayIntoRegistry`, the MetadataService merge, the disabled-package
49+
filter, the nav contributions and the decorations. That containment also keeps the
50+
cache's reach co-extensive with what its key can validate — the SchemaRegistry, the
51+
MetadataService and the artifact table are all mutable sources the write epoch cannot
52+
observe, and none of them is being cached.

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,19 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
183183
> configuring a distributed driver; and if no `cluster` (or `metadata`) service is
184184
> registered the bridge logs and skips, leaving each replica seeing only its own
185185
> writes.
186+
>
187+
> ⚠️ **"Peers re-read from the shared database" has one bound worth naming.** The
188+
> `sys_metadata` overlay read inside `protocol.ts:getMetaItems` is cached across
189+
> requests, keyed on the engine write epoch. A **local** write advances that epoch
190+
> before its own middleware chain runs, so read-your-writes is exact on the node
191+
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
192+
> the MetadataManager caches this note is about, but it does not retire the
193+
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
194+
> channel — a hint from another node bumps the local write epoch — or, failing
195+
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
196+
> outright). So on a deployment with no distributed cluster driver attached, a
197+
> peer's overlay re-read can lag a remote publish by up to that TTL. See
198+
> [Environment variables](/docs/deployment/environment-variables).
186199
187200
---
188201

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ the hosted ObjectOS Cloud control plane.
360360
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default **CPU-time** budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
361361
| `OS_SANDBOX_WALL_CEILING_MS` | number | `30000` | Wall-clock ceiling (ADR-0102) — the backstop that cuts a hook/action body stuck on a host call that never settles (which burns no CPU, so the CPU budget alone would never fire). The effective ceiling is `max(this, cpuBudget)`, so it can never cut a body still inside its CPU budget. Positive integer only; unset keeps 30s. |
362362
| `OS_LOCALIZATION_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of a workspace's reference localization (`timezone` / `locale` / `currency`, read from `sys_setting`) — leg C of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Unlike `OS_AUTHZ_GRANTS_CACHE_TTL_MS` (which is off by default) this one ships **on**, because its invalidation is synchronous and in-process rather than TTL-bound: a `localization` settings change and any engine write both retire a cached answer immediately, so the TTL only bounds what neither seam can see — a write made on another replica with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off), the opposite arm from the grants variable and deliberately so: there `0` is also the default, whereas here folding `3OOO` (letter O) into the default would hand you a **longer** window than the one you were setting. Deployment config only — never a settings row, because `sys_setting` is the table this cache caches. |
363+
| `OS_METADATA_OVERLAY_CACHE_TTL_MS` | number | `30000` | Staleness bound, in milliseconds, for the cross-request cache of the `sys_metadata` overlay read inside `getMetaItems` — leg D of #11633. `0` means **off**, a real path that restores the uncached query pattern exactly. Ships **on**, for the same reason as `OS_LOCALIZATION_CACHE_TTL_MS`: invalidation is synchronous and in-process, because every `sys_metadata` write goes through the engine and so advances the write epoch that retires the entry before the next read. What is cached is the overlay ROW SET only — never the merged answer — so the SchemaRegistry, the MetadataService and the artifact table are re-consulted on every call, cached or not, and the read-side registry hydration keeps running on a cache hit. The TTL therefore bounds one thing: a write made on **another replica** with no `authz.invalidated` bridge attached. ⚠️ A malformed value reads as `0` (off) — same arm and same reason as `OS_LOCALIZATION_CACHE_TTL_MS`. Deployment config only, never a settings row. |
363364
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
364365
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |
365366

0 commit comments

Comments
 (0)