Skip to content

Commit a59f78d

Browse files
claude[bot]claude
andauthored
fix(service-cluster): stop the metadata bridge reporting “bridged” over an in-process cluster bus (#14228)
* fix(service-cluster): guard lane 1 of the metadata bridge on the in-process cluster driver `MetadataClusterBridgePlugin`'s `metadata.changed` lane attached and logged "bridged metadata.changed -> cluster.pubsub" unconditionally. `Runtime` registers the `memory` driver by default, so on an ordinary single-process boot a cluster service IS present and that claim is a false positive: the bus keeps its state inside one process and fans out to nobody. Lane 1 now consults `isInProcessClusterDriver(cluster.driver)` before attaching and states the in-process case at `debug` -- the shape `AuthzClusterBridgePlugin` uses and the one this plugin's own lane 2 was born with. Both skip the attach rather than relabel the log, and so does this. Skipping reaches nothing: the only subscriber of `metadata.changed` in the tree is the same `MetadataManager` that publishes it, behind an `originNode` loopback guard that discards every in-process message. The seam-missing warn keeps its position ahead of the driver guard, so #13331's original boot symptom still fires verbatim on an in-process boot, and no log level is raised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * docs(kernel): state the in-process skip where the bridge's fan-out is described Both places that describe `MetadataClusterBridgePlugin`'s precondition said it calls `attachClusterPubSub()` on `kernel:ready` full stop, which after the lane-1 driver guard would assert the very fan-out the guard exists to stop claiming. Same defect class, one sentence each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a39b02a commit a59f78d

5 files changed

Lines changed: 162 additions & 14 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/service-cluster": patch
3+
---
4+
5+
fix(service-cluster): stop `MetadataClusterBridgePlugin` reporting "bridged metadata.changed" over an in-process bus that fans out to nobody (#14021)
6+
7+
`Runtime` registers the `memory` cluster driver by default, so a `cluster`
8+
service is present on an ordinary single-process boot. Lane 1 of the metadata
9+
bridge attached and then logged, unconditionally:
10+
11+
```
12+
MetadataClusterBridgePlugin: bridged metadata.changed → cluster.pubsub (node=<id>)
13+
```
14+
15+
There was no driver check. On the memory driver that claim is a false positive:
16+
the bus keeps its state inside one process, so the fan-out the line announces
17+
reaches nobody. An operator reading it believes cross-node cache invalidation is
18+
on when it is not.
19+
20+
Lane 1 now consults `isInProcessClusterDriver(cluster.driver)` before attaching,
21+
and states the in-process case at `debug` instead:
22+
23+
```
24+
MetadataClusterBridgePlugin: cluster driver "memory" is in-process; metadata.changed fan-out has no peers to reach, skipping
25+
```
26+
27+
This is the shape already in the tree twice — `AuthzClusterBridgePlugin` (#11968)
28+
and this same plugin's lane 2, which was born with the guard (#13331). Both skip
29+
the attach rather than softening the log, and so does this. Nothing observable is
30+
lost by skipping: the only subscriber of `metadata.changed` anywhere in the tree
31+
is the same `MetadataManager` that publishes it, and its loopback guard discards
32+
every message whose `originNode` matches its own node id — which, on an
33+
in-process bus, is every message.
34+
35+
Deliberately unchanged:
36+
37+
- **The seam-missing warn still fires first.** `metadata service does not
38+
expose attachClusterPubSub(); cross-node cache invalidation disabled` is
39+
#13331's original boot symptom and other measurements match it byte-for-byte;
40+
the driver guard is evaluated after it, exactly as in lane 2, so an in-process
41+
boot with a fallback metadata slot still warns.
42+
- **The level policy stays as ruled.** The authz bridge's header holds the two
43+
bridges to different bars on purpose: this bridge may stay quiet when a
44+
cluster service is *absent*, because a missed `metadata.changed` costs a stale
45+
schema and loses no data. That exemption is about silence and does not licence
46+
asserting "bridged" when a service is present-but-in-process. The in-process
47+
arm is therefore `debug`, matching lane 2 — no level is raised.
48+
- **A cross-process driver still claims `bridged`, verbatim**, pinned by a
49+
reverse control alongside the new in-process pin.

content/docs/kernel/cluster.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,9 @@ The transport is handed to the manager by `MetadataClusterBridgePlugin`
342342
(`@objectstack/service-cluster`), which calls
343343
`metadata.attachClusterPubSub(cluster.pubsub, cluster.nodeId)` on
344344
`kernel:ready`. `Runtime` registers that bridge automatically alongside the
345-
cluster service.
345+
cluster service. It skips that call when the resolved driver is in-process
346+
(`memory`) — such a bus fans out to no peer, so the bridge states that at
347+
`debug` rather than reporting itself as "bridged" (#14021).
346348

347349
On receipt a peer suppresses its own messages by `originNode`, then — **first,
348350
synchronously** — invalidates its local caches for that type: it drops the

content/docs/kernel/services-checklist.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ never reaches disk, which is exactly what it self-declares (`degraded`).
147147
| Former gap | Where it landed |
148148
|:----|:------------|
149149
| **DB Persistence** | `MetadataPlugin` (`@objectstack/metadata`) persists to `sys_metadata`; only the kernel's in-memory fallback is non-persistent |
150-
| **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()` |
150+
| **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()` |
151151
| **Migration / Versioning** | `os diff <before> <after>` compares two configurations and flags breaking changes; `os migrate plan` / `os migrate apply` reconcile the physical database against metadata |
152152
| **Hot Reload** | `MetadataPlugin` watches its sources (`watch`) and the compiled artifact (`artifactWatch`) with chokidar; `os dev` rebuilds and the server reloads without a restart |
153153

packages/services/service-cluster/src/metadata-cluster-bridge-plugin.test.ts

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,15 @@
1313
* objects, indefinitely. The mutation lane must attach exactly there, without
1414
* the metadata-service lane's absence taking it down.
1515
*
16-
* ⚠️ Lane 1's in-process-driver behaviour (it attaches and logs "bridged" on
17-
* the memory driver that fans out to nobody) is #14021's card, NOT pinned
18-
* here — these cases drive lane 1 only through its warn/absence paths so that
19-
* card stays free to fix it. Lane 2 carries the `isInProcessClusterDriver`
20-
* guard from birth, and that IS pinned here.
16+
* ⚠️ Lane 1's in-process-driver behaviour (it attached and logged "bridged"
17+
* on the memory driver that fans out to nobody) was #14021's card, and the
18+
* #13331 cases below deliberately do NOT pin it — they drive lane 1 only
19+
* through its warn/absence paths so that card stayed free to fix it.
20+
*
21+
* [#14021] It is fixed: lane 1 now carries the same `isInProcessClusterDriver`
22+
* guard lane 2 was born with. The block at the BOTTOM of this file pins it,
23+
* together with the cross-process control that keeps the guard honest —
24+
* without that control a guard is indistinguishable from "never say bridged".
2125
*/
2226

2327
import { describe, it, expect, vi } from 'vitest';
@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
110114
h.logger.info.mock.calls.map((c) => String(c[0]));
111115
const warnLines = (h: ReturnType<typeof makeHarness>) =>
112116
h.logger.warn.mock.calls.map((c) => String(c[0]));
117+
const debugLines = (h: ReturnType<typeof makeHarness>) =>
118+
h.logger.debug.mock.calls.map((c) => String(c[0]));
113119

114120
describe('[#13331] ⭐ the shipped EE shape — fallback metadata slot, real protocol', () => {
115121
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', () => {
212218
expect(h.logger.error).toHaveBeenCalled();
213219
});
214220
});
221+
222+
describe('[#14021] lane 1 — an in-process bus must not be reported as “bridged”', () => {
223+
it('skips the attach and never claims “bridged” on the memory driver', async () => {
224+
const h = makeHarness({ driver: 'memory', metadata: 'manager', protocol: 'real' });
225+
await new MetadataClusterBridgePlugin().init(h.ctx);
226+
await h.fire('kernel:ready');
227+
228+
// A cluster service IS registered here — `Runtime` registers the memory
229+
// driver by default — but it fans out to nobody. Reporting this as
230+
// “bridged” is the exact misreading the posture statement exists to
231+
// prevent: a false positive, not a quiet negative. The attach is
232+
// skipped rather than merely relabelled, which is what BOTH in-tree
233+
// exemplars do (`AuthzClusterBridgePlugin`, and lane 2 below).
234+
expect(h.attachMetadata).not.toHaveBeenCalled();
235+
expect(infoLines(h).some((l) => l.includes('bridged metadata.changed'))).toBe(false);
236+
expect(
237+
debugLines(h).some(
238+
(l) => l.includes('is in-process') && l.includes('metadata.changed'),
239+
),
240+
).toBe(true);
241+
});
242+
243+
it('⭐ reverse control — a cross-process driver STILL attaches and STILL claims “bridged”', async () => {
244+
const h = makeHarness({ driver: 'redis', metadata: 'manager', protocol: 'real' });
245+
await new MetadataClusterBridgePlugin().init(h.ctx);
246+
await h.fire('kernel:ready');
247+
248+
// Without this arm the guard above is indistinguishable from a bridge
249+
// that never says “bridged” at all. The line is asserted VERBATIM
250+
// because its wording is what an operator reads as “fan-out is on”.
251+
expect(h.attachMetadata).toHaveBeenCalledTimes(1);
252+
expect(h.attachMetadata).toHaveBeenCalledWith(h.pubsub, 'node-a');
253+
expect(infoLines(h)).toContain(
254+
'MetadataClusterBridgePlugin: bridged metadata.changed → cluster.pubsub (node=node-a)',
255+
);
256+
});
257+
258+
it('the in-process guard does not swallow #13331’s boot warn', async () => {
259+
const h = makeHarness({ driver: 'memory', metadata: 'fallback', protocol: 'real' });
260+
await new MetadataClusterBridgePlugin().init(h.ctx);
261+
await h.fire('kernel:ready');
262+
263+
// Ordering pin: the seam-missing warn is evaluated BEFORE the driver
264+
// guard, exactly as in lane 2, so #13331's original boot symptom keeps
265+
// firing byte-for-byte on an in-process boot. Fixing a false positive
266+
// must not cost a true negative.
267+
expect(warnLines(h)).toContain(
268+
'MetadataClusterBridgePlugin: metadata service does not expose attachClusterPubSub(); cross-node cache invalidation disabled',
269+
);
270+
expect(h.attachMetadata).not.toHaveBeenCalled();
271+
});
272+
273+
it('leaves nothing to detach when the in-process guard skipped the attach', async () => {
274+
const h = makeHarness({ driver: 'memory', metadata: 'manager', protocol: 'real' });
275+
await new MetadataClusterBridgePlugin().init(h.ctx);
276+
await h.fire('kernel:ready');
277+
await h.fire('kernel:shutdown');
278+
279+
expect(h.detachMetadata).not.toHaveBeenCalled();
280+
expect(h.logger.error).not.toHaveBeenCalled();
281+
});
282+
});

packages/services/service-cluster/src/metadata-cluster-bridge-plugin.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,35 @@ export class MetadataClusterBridgePlugin implements Plugin {
9292
}
9393

9494
/**
95-
* Lane 1 — the metadata SERVICE's `metadata.changed` bridge, exactly as
96-
* it has always behaved (its log lines are measured facts other cards
97-
* lean on — the warn below is #13331's original boot symptom, and it
98-
* remains TRUE on the host-config boot: the fallback metadata slot has
99-
* no cluster seam, so metadata-SERVICE cache invalidation stays off
100-
* there. The data-plane registry gap that warn used to imply is what
101-
* lane 2 closes.)
95+
* Lane 1 — the metadata SERVICE's `metadata.changed` bridge.
96+
*
97+
* The warn below is a measured fact other cards lean on: it is #13331's
98+
* original boot symptom and it remains TRUE and VERBATIM on the
99+
* host-config boot, where the fallback metadata slot has no cluster
100+
* seam, so metadata-SERVICE cache invalidation stays off there. The
101+
* data-plane registry gap that warn used to imply is what lane 2 closes.
102+
*
103+
* [#14021] Guarded on {@link isInProcessClusterDriver} — the shape
104+
* `AuthzClusterBridgePlugin` uses and the one lane 2 was born with. A
105+
* cluster service IS registered — `Runtime` registers the memory driver
106+
* by default — but it fans out to nobody. Reporting this as "bridged" is
107+
* the exact misreading the posture statement exists to prevent.
108+
*
109+
* The authz bridge's header exempts THIS bridge from having to speak
110+
* when a cluster service is ABSENT, because a missed `metadata.changed`
111+
* costs a stale schema and loses no data. That exemption is about
112+
* SILENCE; it does not licence asserting "bridged" over a bus that
113+
* crosses no process boundary, which is a false positive rather than a
114+
* quiet negative. So the in-process arm is stated at `debug`, matching
115+
* lane 2 — the deliberate level difference between the two bridges
116+
* (#11968) is not what this card touches.
117+
*
118+
* Skipping the attach — rather than attaching and softening the log — is
119+
* what both in-tree exemplars do, and here it reaches nothing: the only
120+
* subscriber of `metadata.changed` in the tree is the same
121+
* `MetadataManager` that publishes it, and its loopback guard drops
122+
* every message whose `originNode` equals its own node id. On an
123+
* in-process bus that is every message.
102124
*/
103125
private attachMetadataServiceLane(ctx: PluginContext, cluster: IClusterService): void {
104126
let md: unknown;
@@ -120,6 +142,13 @@ export class MetadataClusterBridgePlugin implements Plugin {
120142
return;
121143
}
122144

145+
if (isInProcessClusterDriver(cluster.driver)) {
146+
ctx.logger.debug(
147+
`MetadataClusterBridgePlugin: cluster driver "${cluster.driver}" is in-process; metadata.changed fan-out has no peers to reach, skipping`,
148+
);
149+
return;
150+
}
151+
123152
try {
124153
this.detach = (attach as (
125154
pubsub: IClusterService['pubsub'],

0 commit comments

Comments
 (0)