diff --git a/.changeset/metadata-bridge-in-process-guard.md b/.changeset/metadata-bridge-in-process-guard.md new file mode 100644 index 0000000000..5213a3a74f --- /dev/null +++ b/.changeset/metadata-bridge-in-process-guard.md @@ -0,0 +1,49 @@ +--- +"@objectstack/service-cluster": patch +--- + +fix(service-cluster): stop `MetadataClusterBridgePlugin` reporting "bridged metadata.changed" over an in-process bus that fans out to nobody (#14021) + +`Runtime` registers the `memory` cluster driver by default, so a `cluster` +service is present on an ordinary single-process boot. Lane 1 of the metadata +bridge attached and then logged, unconditionally: + +``` +MetadataClusterBridgePlugin: bridged metadata.changed → cluster.pubsub (node=) +``` + +There was no driver check. On the memory driver that claim is a false positive: +the bus keeps its state inside one process, so the fan-out the line announces +reaches nobody. An operator reading it believes cross-node cache invalidation is +on when it is not. + +Lane 1 now consults `isInProcessClusterDriver(cluster.driver)` before attaching, +and states the in-process case at `debug` instead: + +``` +MetadataClusterBridgePlugin: cluster driver "memory" is in-process; metadata.changed fan-out has no peers to reach, skipping +``` + +This is the shape already in the tree twice — `AuthzClusterBridgePlugin` (#11968) +and this same plugin's lane 2, which was born with the guard (#13331). Both skip +the attach rather than softening the log, and so does this. Nothing observable is +lost by skipping: the only subscriber of `metadata.changed` anywhere in the tree +is the same `MetadataManager` that publishes it, and its loopback guard discards +every message whose `originNode` matches its own node id — which, on an +in-process bus, is every message. + +Deliberately unchanged: + +- **The seam-missing warn still fires first.** `metadata service does not + expose attachClusterPubSub(); cross-node cache invalidation disabled` is + #13331's original boot symptom and other measurements match it byte-for-byte; + the driver guard is evaluated after it, exactly as in lane 2, so an in-process + boot with a fallback metadata slot still warns. +- **The level policy stays as ruled.** The authz bridge's header holds the two + bridges to different bars on purpose: this bridge may stay quiet when a + cluster service is *absent*, because a missed `metadata.changed` costs a stale + schema and loses no data. That exemption is about silence and does not licence + asserting "bridged" when a service is present-but-in-process. The in-process + arm is therefore `debug`, matching lane 2 — no level is raised. +- **A cross-process driver still claims `bridged`, verbatim**, pinned by a + reverse control alongside the new in-process pin. diff --git a/content/docs/kernel/cluster.mdx b/content/docs/kernel/cluster.mdx index d4899a7b1a..743fb77b23 100644 --- a/content/docs/kernel/cluster.mdx +++ b/content/docs/kernel/cluster.mdx @@ -342,7 +342,9 @@ The transport is handed to the manager by `MetadataClusterBridgePlugin` (`@objectstack/service-cluster`), which calls `metadata.attachClusterPubSub(cluster.pubsub, cluster.nodeId)` on `kernel:ready`. `Runtime` registers that bridge automatically alongside the -cluster service. +cluster service. It skips that call when the resolved driver is in-process +(`memory`) — such a bus fans out to no peer, so the bridge states that at +`debug` rather than reporting itself as "bridged" (#14021). On receipt a peer suppresses its own messages by `originNode`, then — **first, synchronously** — invalidates its local caches for that type: it drops the diff --git a/content/docs/kernel/services-checklist.mdx b/content/docs/kernel/services-checklist.mdx index 9d42d30132..dce5ce4489 100644 --- a/content/docs/kernel/services-checklist.mdx +++ b/content/docs/kernel/services-checklist.mdx @@ -147,7 +147,7 @@ never reaches disk, which is exactly what it self-declares (`degraded`). | Former gap | Where it landed | |:----|:------------| | **DB Persistence** | `MetadataPlugin` (`@objectstack/metadata`) persists to `sys_metadata`; only the kernel's in-memory fallback is non-persistent | -| **Multi-instance Sync** | `MetadataClusterBridgePlugin` (`@objectstack/service-cluster`) bridges the `metadata.changed` channel onto the cluster pub/sub bus, so a mutation on one node invalidates peer registry caches. Needs a `cluster` service and a metadata service exposing `attachClusterPubSub()` | +| **Multi-instance Sync** | `MetadataClusterBridgePlugin` (`@objectstack/service-cluster`) bridges the `metadata.changed` channel onto the cluster pub/sub bus, so a mutation on one node invalidates peer registry caches. Needs a `cluster` service on a cross-process driver (the in-process `memory` driver is skipped — it fans out to no peer) and a metadata service exposing `attachClusterPubSub()` | | **Migration / Versioning** | `os diff ` compares two configurations and flags breaking changes; `os migrate plan` / `os migrate apply` reconcile the physical database against metadata | | **Hot Reload** | `MetadataPlugin` watches its sources (`watch`) and the compiled artifact (`artifactWatch`) with chokidar; `os dev` rebuilds and the server reloads without a restart | diff --git a/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.test.ts b/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.test.ts index 9a5bb0b48e..e1258145a8 100644 --- a/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.test.ts +++ b/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.test.ts @@ -13,11 +13,15 @@ * objects, indefinitely. The mutation lane must attach exactly there, without * the metadata-service lane's absence taking it down. * - * ⚠️ Lane 1's in-process-driver behaviour (it attaches and logs "bridged" on - * the memory driver that fans out to nobody) is #14021's card, NOT pinned - * here — these cases drive lane 1 only through its warn/absence paths so that - * card stays free to fix it. Lane 2 carries the `isInProcessClusterDriver` - * guard from birth, and that IS pinned here. + * ⚠️ Lane 1's in-process-driver behaviour (it attached and logged "bridged" + * on the memory driver that fans out to nobody) was #14021's card, and the + * #13331 cases below deliberately do NOT pin it — they drive lane 1 only + * through its warn/absence paths so that card stayed free to fix it. + * + * [#14021] It is fixed: lane 1 now carries the same `isInProcessClusterDriver` + * guard lane 2 was born with. The block at the BOTTOM of this file pins it, + * together with the cross-process control that keeps the guard honest — + * without that control a guard is indistinguishable from "never say bridged". */ import { describe, it, expect, vi } from 'vitest'; @@ -110,6 +114,8 @@ const infoLines = (h: ReturnType) => h.logger.info.mock.calls.map((c) => String(c[0])); const warnLines = (h: ReturnType) => h.logger.warn.mock.calls.map((c) => String(c[0])); +const debugLines = (h: ReturnType) => + h.logger.debug.mock.calls.map((c) => String(c[0])); describe('[#13331] ⭐ the shipped EE shape — fallback metadata slot, real protocol', () => { it('warns for lane 1 AND attaches lane 2 in the same boot', async () => { @@ -212,3 +218,65 @@ describe('[#13331] both lanes present, and both released on shutdown', () => { expect(h.logger.error).toHaveBeenCalled(); }); }); + +describe('[#14021] lane 1 — an in-process bus must not be reported as “bridged”', () => { + it('skips the attach and never claims “bridged” on the memory driver', async () => { + const h = makeHarness({ driver: 'memory', metadata: 'manager', protocol: 'real' }); + await new MetadataClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + // A cluster service IS registered here — `Runtime` registers the memory + // driver by default — but it fans out to nobody. Reporting this as + // “bridged” is the exact misreading the posture statement exists to + // prevent: a false positive, not a quiet negative. The attach is + // skipped rather than merely relabelled, which is what BOTH in-tree + // exemplars do (`AuthzClusterBridgePlugin`, and lane 2 below). + expect(h.attachMetadata).not.toHaveBeenCalled(); + expect(infoLines(h).some((l) => l.includes('bridged metadata.changed'))).toBe(false); + expect( + debugLines(h).some( + (l) => l.includes('is in-process') && l.includes('metadata.changed'), + ), + ).toBe(true); + }); + + it('⭐ reverse control — a cross-process driver STILL attaches and STILL claims “bridged”', async () => { + const h = makeHarness({ driver: 'redis', metadata: 'manager', protocol: 'real' }); + await new MetadataClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + // Without this arm the guard above is indistinguishable from a bridge + // that never says “bridged” at all. The line is asserted VERBATIM + // because its wording is what an operator reads as “fan-out is on”. + expect(h.attachMetadata).toHaveBeenCalledTimes(1); + expect(h.attachMetadata).toHaveBeenCalledWith(h.pubsub, 'node-a'); + expect(infoLines(h)).toContain( + 'MetadataClusterBridgePlugin: bridged metadata.changed → cluster.pubsub (node=node-a)', + ); + }); + + it('the in-process guard does not swallow #13331’s boot warn', async () => { + const h = makeHarness({ driver: 'memory', metadata: 'fallback', protocol: 'real' }); + await new MetadataClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + + // Ordering pin: the seam-missing warn is evaluated BEFORE the driver + // guard, exactly as in lane 2, so #13331's original boot symptom keeps + // firing byte-for-byte on an in-process boot. Fixing a false positive + // must not cost a true negative. + expect(warnLines(h)).toContain( + 'MetadataClusterBridgePlugin: metadata service does not expose attachClusterPubSub(); cross-node cache invalidation disabled', + ); + expect(h.attachMetadata).not.toHaveBeenCalled(); + }); + + it('leaves nothing to detach when the in-process guard skipped the attach', async () => { + const h = makeHarness({ driver: 'memory', metadata: 'manager', protocol: 'real' }); + await new MetadataClusterBridgePlugin().init(h.ctx); + await h.fire('kernel:ready'); + await h.fire('kernel:shutdown'); + + expect(h.detachMetadata).not.toHaveBeenCalled(); + expect(h.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts b/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts index 45652e3097..b7d4d18b41 100644 --- a/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts +++ b/packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts @@ -92,13 +92,35 @@ export class MetadataClusterBridgePlugin implements Plugin { } /** - * Lane 1 — the metadata SERVICE's `metadata.changed` bridge, exactly as - * it has always behaved (its log lines are measured facts other cards - * lean on — the warn below is #13331's original boot symptom, and it - * remains TRUE on the host-config boot: the fallback metadata slot has - * no cluster seam, so metadata-SERVICE cache invalidation stays off - * there. The data-plane registry gap that warn used to imply is what - * lane 2 closes.) + * Lane 1 — the metadata SERVICE's `metadata.changed` bridge. + * + * The warn below is a measured fact other cards lean on: it is #13331's + * original boot symptom and it remains TRUE and VERBATIM on the + * host-config boot, where the fallback metadata slot has no cluster + * seam, so metadata-SERVICE cache invalidation stays off there. The + * data-plane registry gap that warn used to imply is what lane 2 closes. + * + * [#14021] Guarded on {@link isInProcessClusterDriver} — the shape + * `AuthzClusterBridgePlugin` uses and the one lane 2 was born with. A + * cluster service IS registered — `Runtime` registers the memory driver + * by default — but it fans out to nobody. Reporting this as "bridged" is + * the exact misreading the posture statement exists to prevent. + * + * The authz bridge's header exempts THIS bridge from having to speak + * when a cluster service is ABSENT, because a missed `metadata.changed` + * costs a stale schema and loses no data. That exemption is about + * SILENCE; it does not licence asserting "bridged" over a bus that + * crosses no process boundary, which is a false positive rather than a + * quiet negative. So the in-process arm is stated at `debug`, matching + * lane 2 — the deliberate level difference between the two bridges + * (#11968) is not what this card touches. + * + * Skipping the attach — rather than attaching and softening the log — is + * what both in-tree exemplars do, and here it reaches nothing: the only + * subscriber of `metadata.changed` in the tree is the same + * `MetadataManager` that publishes it, and its loopback guard drops + * every message whose `originNode` equals its own node id. On an + * in-process bus that is every message. */ private attachMetadataServiceLane(ctx: PluginContext, cluster: IClusterService): void { let md: unknown; @@ -120,6 +142,13 @@ export class MetadataClusterBridgePlugin implements Plugin { return; } + if (isInProcessClusterDriver(cluster.driver)) { + ctx.logger.debug( + `MetadataClusterBridgePlugin: cluster driver "${cluster.driver}" is in-process; metadata.changed fan-out has no peers to reach, skipping`, + ); + return; + } + try { this.detach = (attach as ( pubsub: IClusterService['pubsub'],