Skip to content

Commit 44a2e59

Browse files
committed
feat(metadata-protocol,service-cluster): fan runtime metadata mutations out to peer replicas (#13331)
Publisher at the protocol's post-persistence choke point publishes the mutation's address on the new metadata.mutated cluster channel; peers converge their ObjectQL registry from their OWN sys_metadata read (write- through when an active row exists, the delete heal walk when none does), then replay the event into local onMetadataMutation listeners. The bridge plugin late-binds attachMetadataMutationPubSub as a second independent lane at kernel:ready, guarded off the in-process memory driver. Ruled 2026-09-01 (director batch A, Option A): producer-side fan-out mirroring the shipped AuthzClusterBridgePlugin shape; the payload is a signal, never trusted content. Option B (consumer-side self-heal) is not built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
1 parent 2bff79a commit 44a2e59

7 files changed

Lines changed: 1178 additions & 44 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+
"@objectstack/service-cluster": minor
4+
---
5+
6+
feat(metadata-protocol,service-cluster): fan runtime metadata mutations out to peer replicas — a runtime-authored object no longer answers OBJECT_NOT_FOUND on every replica that did not perform the write (#13331)
7+
8+
Measured on a live 3-replica EE deployment (ADR-0018 compose, redis driver):
9+
an object authored through `PUT /api/v1/meta/object/...` persisted to the
10+
shared `sys_metadata` (so `/api/v1/meta/*` answered 200 fleet-wide) but
11+
registered with the ObjectQL engine registry of the writing replica only —
12+
`/api/v1/data/<object>` answered a hard 404 `OBJECT_NOT_FOUND` on the other
13+
replicas, indefinitely (200 concurrent creates through the LB: 67×201 /
14+
133×404; a boot-loaded control object: 0 errors; the only recovery was a full
15+
fleet restart). The runtime authoring path lives entirely in the metadata
16+
protocol and never touches the metadata service, so the existing
17+
`metadata.changed` bridge — even when attached — never heard these writes.
18+
19+
Maintainer-ruled design (2026-09-01, Option A):
20+
21+
- **Publisher at the producer choke point.** The protocol's post-persistence
22+
mutation funnel (`saveMetaItem` / `publishMetaItem` / `deleteMetaItem` —
23+
the same seam `onMetadataMutation` subscribes) now also publishes the
24+
mutation's ADDRESS on a new cluster channel `metadata.mutated`
25+
(`METADATA_MUTATION_CLUSTER_CHANNEL`, payload
26+
`ClusterMetadataMutationPayload`). Drafts are not published — they never
27+
enter any replica's registry.
28+
- **Peers converge from their own DB read.** On receipt, a replica re-reads
29+
the row from its OWN `sys_metadata` and re-runs the registry write-through
30+
(active row present) or the delete heal walk (no active row). The payload
31+
is a signal, never trusted content — the shared database stays the single
32+
source of truth, and duplicate or out-of-order delivery converges to the
33+
row's current state by construction. After convergence the event replays
34+
into the replica's local `onMetadataMutation` listeners (never
35+
re-published), so boot-cached consumers such as the authored hook/action
36+
re-bind re-sync on peers exactly as they do on the writer.
37+
- **New attach seam, mirrored from the shipped bridges.**
38+
`ObjectStackProtocolImplementation.attachMetadataMutationPubSub(pubsub,
39+
nodeId)` — idempotent on the `(pubsub, nodeId)` pair, with loopback
40+
suppression via `originNode`, shaped after
41+
`MetadataManager.attachClusterPubSub()` and the engine's
42+
`attachAuthzInvalidationPubSub()`. `MetadataClusterBridgePlugin` late-binds
43+
it at `kernel:ready` as a second, independent lane beside the existing
44+
metadata-service lane — the boot shape that lacks a manager-backed
45+
`metadata` service (the TS-config host-config boot, exactly the shipped EE
46+
shape) is the one that needs this lane most. The new lane skips the
47+
in-process memory driver (nothing to fan out to), the guard the authz
48+
sibling already carries.
49+
50+
No shipped driver exceeds at-most-once delivery, so a lost message still
51+
degrades to the pre-existing staleness bound (heal at next boot); this
52+
channel narrows the window from "until restart" to one network hop.

‎packages/metadata-protocol/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ export type { UninstallCleanup, UninstallCleanupOutcome } from './protocol.js';
135135
// against the producer's contract instead of restating it locally.
136136
export type { DeletePackageRequest, DeletePackageResponse } from './protocol.js';
137137
export type { MetadataMutationEvent, MetadataMutationProjector, MutationProjectionOutcome } from './protocol.js';
138+
// [#13331] The cross-node half of the mutation notification: the cluster
139+
// channel the protocol publishes post-persistence mutations on, and its
140+
// address-only payload. Exported for the bridge's tests and for any peer-side
141+
// consumer that must speak the channel by name — the payload is a SIGNAL,
142+
// never trusted content (receipt re-reads `sys_metadata` locally).
143+
export { METADATA_MUTATION_CLUSTER_CHANNEL } from './protocol.js';
144+
export type { ClusterMetadataMutationPayload } from './protocol.js';
138145
// [#10219] The per-item publish notification the host bridges to the
139146
// kernel-wide `metadata:reloaded` announce. Exported for the same reason its
140147
// mutation sibling is: the subscriber lives in another package.

0 commit comments

Comments
 (0)