|
| 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. |
0 commit comments