Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .changeset/metadata-bridge-in-process-guard.md
Original file line numberDiff line numberDiff line change
@@ -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=<id>)
```

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.
4 changes: 3 additions & 1 deletion content/docs/kernel/cluster.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <before> <after>` 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 |

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -110,6 +114,8 @@ const infoLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.info.mock.calls.map((c) => String(c[0]));
const warnLines = (h: ReturnType<typeof makeHarness>) =>
h.logger.warn.mock.calls.map((c) => String(c[0]));
const debugLines = (h: ReturnType<typeof makeHarness>) =>
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 () => {
Expand DownExpand Up@@ -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();
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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'],
Expand Down
Loading