Skip to content
Merged
52 changes: 52 additions & 0 deletions .changeset/metadata-overlay-read-cache.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
---
"@objectstack/metadata-protocol": minor
---

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

Leg D (ship-second) of the accepted #11633 cross-request caching design
(maintainer acceptance 2026-08-25, forks 1A / 2B / 3A / TTL-0).

`getMetaItems` re-read `sys_metadata` on every authenticated request. On the hot
path — `enforceApiAccess` → `loadObjectItems` → `getMetaItems({ type: 'object' })`,
once per REST request — that costs **two** sequentially awaited engine queries
whenever the environment holds no overlay rows for the type, because the empty
first result fires the alt-type retry. An app whose objects are all code-authored
paid both on every request. That read is now cached behind invalidation that is
synchronous and in-process rather than TTL-bound.

- **Primary trigger — the #11968 engine write epoch**, read structurally rather
than imported: `@objectstack/metadata-protocol` does not depend on
`@objectstack/objectql`, and the substrate declared `WriteEpochLike` separately
for exactly this kind of consumer. Every `sys_metadata` write reaches it, because
`SysMetadataRepository` writes through `engine.insert/update/delete`.
- **Residual bound — `OS_METADATA_OVERLAY_CACHE_TTL_MS`**, default 30s, `0` = off
and a real path. It bounds one thing only: a peer replica's write on a deployment
with no `authz.invalidated` bridge attached.
- **A success is cached ONLY when the engine exposes the write epoch** — leg C's
rule, re-measured and unchanged here. No seam means the cache declines rather
than degrading to a TTL-only shape, which for this leg would make
publish-visibility a timer. Every existing test double keeps its exact query
multiset; only a real engine caches.

**Grade: `minor`, argued.** Not `patch`: this adds a supported deployment knob
(`OS_METADATA_OVERLAY_CACHE_TTL_MS`, registered in the canonical environment-variable
table) and introduces a bounded staleness window that a multi-node operator must be
able to read about before upgrading — a release note that said only "internal
performance" would under-describe it. Not `major`: no public API changes, no export
is added or removed, `getMetaItems`' request and response contracts are untouched,
and the pins assert the cached answer is identical to the uncached one, so no caller
can observe the difference except in query count. Same grade and same reasoning as
leg C (#11966), which shipped `minor` for the same knob-plus-window shape.

**The SchemaRegistry-hydration trap (#11633 §4 leg D) is resolved structurally, not
by care.** `getMetaItems` registers overlay rows back into the SchemaRegistry as a
side effect of the read, so a cache that skips the read would quietly stop populating
the registry. What is cached here is the overlay **row set** — the value *upstream* of
the hydration branch — never the merged answer downstream of it. Every consumer of
those rows still runs on every 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. That containment also keeps the
cache's reach co-extensive with what its key can validate — the SchemaRegistry, the
MetadataService and the artifact table are all mutable sources the write epoch cannot
observe, and none of them is being cached.
13 changes: 13 additions & 0 deletions content/docs/concepts/metadata-lifecycle.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -183,6 +183,19 @@ The hash is `sha256:` + 64-hex of a canonical (sorted-keys, no-undefined) JSON s
> configuring a distributed driver; and if no `cluster` (or `metadata`) service is
> registered the bridge logs and skips, leaving each replica seeing only its own
> writes.
>
> ⚠️ **"Peers re-read from the shared database" has one bound worth naming.** The
> `sys_metadata` overlay read inside `protocol.ts:getMetaItems` is cached across
> requests, keyed on the engine write epoch. A **local** write advances that epoch
> before its own middleware chain runs, so read-your-writes is exact on the node
> that made the write. A **peer's** write does not: `metadata.changed` invalidates
> the MetadataManager caches this note is about, but it does not retire the
> overlay-read cache. What retires that on a peer is the `authz.invalidated`
> channel — a hint from another node bumps the local write epoch — or, failing
> that, `OS_METADATA_OVERLAY_CACHE_TTL_MS` (default 30s, `0` disables the cache
> outright). So on a deployment with no distributed cluster driver attached, a
> peer's overlay re-read can lag a remote publish by up to that TTL. See
> [Environment variables](/docs/deployment/environment-variables).

---

Expand Down
1 change: 1 addition & 0 deletions content/docs/deployment/environment-variables.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -360,6 +360,7 @@ the hosted ObjectOS Cloud control plane.
| `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). |
| `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. |
| `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. |
| `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. |
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |

Expand Down
Loading
Loading