Skip to content

Commit c89e8b3

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(service-cluster): split-brain startup guard for memory driver (ADR-0010 path A) (#1841)
The memory cluster driver keeps PubSub/Lock/KV/Counter state per-process. Across multiple replicas each process holds its own state → locks grant to everyone, counters/KV versions diverge, pub/sub doesn't fan out. This fails silently: single-node tests/dev pass, only production multi-replica corrupts. Add a conservative startup guard (ADR-0010 path A): when a multi-node topology is *declared* (OS_EXPECT_MULTI_NODE=true or OS_CLUSTER_REPLICAS>1) but the resolved driver is in-process "memory", ClusterServicePlugin throws at init (before registering the service) with an actionable message. Escape hatch OS_ALLOW_MEMORY_CLUSTER_MULTINODE=true for the rare "replicas declared but cluster primitives unused cross-node" case. Pure, unit-tested decision (declaresMultiNode / assertClusterDriverSafeForTopology), exported for reuse. Path B (active DB-presence detection) is deferred per ADR-0010. Refs: cloud ADR-0010 (D6). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 20690dd commit c89e8b3

4 files changed

Lines changed: 151 additions & 0 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Plugin, PluginContext } from '@objectstack/core';
44
import type { IClusterService } from '@objectstack/spec/contracts';
55
import type { ClusterCapabilityConfigInput } from '@objectstack/spec/kernel';
66
import { defineCluster } from './cluster.js';
7+
import { assertClusterDriverSafeForTopology } from './split-brain-guard.js';
78

89
/**
910
* Options for `ClusterServicePlugin`.
@@ -59,6 +60,11 @@ export class ClusterServicePlugin implements Plugin {
5960
this.cluster = defineCluster(this.options.config ?? {});
6061
this.owned = true;
6162
}
63+
// Split-brain guard (ADR-0010): fail fast if a multi-node topology is
64+
// declared but the resolved driver is in-process. Runs before
65+
// registration so a misconfiguration never half-wires the service.
66+
assertClusterDriverSafeForTopology(this.cluster.driver);
67+
6268
ctx.registerService('cluster', this.cluster);
6369
ctx.logger.info(
6470
`ClusterServicePlugin: registered "${this.cluster.driver}" driver (node=${this.cluster.nodeId})`,

packages/services/service-cluster/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ export {
3939
type ClusterServicePluginOptions,
4040
} from './cluster-service-plugin.js';
4141

42+
export {
43+
assertClusterDriverSafeForTopology,
44+
declaresMultiNode,
45+
type SplitBrainGuardEnv,
46+
} from './split-brain-guard.js';
47+
4248
export { MetadataClusterBridgePlugin } from './metadata-cluster-bridge-plugin.js';
4349

4450
// Re-export contracts for convenience.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
declaresMultiNode,
6+
assertClusterDriverSafeForTopology,
7+
} from './split-brain-guard.js';
8+
9+
describe('declaresMultiNode', () => {
10+
it('false when nothing declared', () => {
11+
expect(declaresMultiNode({})).toBe(false);
12+
});
13+
it('true for OS_EXPECT_MULTI_NODE=true (case-insensitive, trimmed)', () => {
14+
expect(declaresMultiNode({ OS_EXPECT_MULTI_NODE: 'true' })).toBe(true);
15+
expect(declaresMultiNode({ OS_EXPECT_MULTI_NODE: ' TRUE ' })).toBe(true);
16+
});
17+
it('false for OS_EXPECT_MULTI_NODE=false / other', () => {
18+
expect(declaresMultiNode({ OS_EXPECT_MULTI_NODE: 'false' })).toBe(false);
19+
expect(declaresMultiNode({ OS_EXPECT_MULTI_NODE: '1' })).toBe(false);
20+
});
21+
it('replicas: >1 true, <=1 false, non-numeric false', () => {
22+
expect(declaresMultiNode({ OS_CLUSTER_REPLICAS: '3' })).toBe(true);
23+
expect(declaresMultiNode({ OS_CLUSTER_REPLICAS: '1' })).toBe(false);
24+
expect(declaresMultiNode({ OS_CLUSTER_REPLICAS: '0' })).toBe(false);
25+
expect(declaresMultiNode({ OS_CLUSTER_REPLICAS: 'abc' })).toBe(false);
26+
});
27+
});
28+
29+
describe('assertClusterDriverSafeForTopology', () => {
30+
it('no throw: single-node + memory (the common case)', () => {
31+
expect(() => assertClusterDriverSafeForTopology('memory', {})).not.toThrow();
32+
});
33+
it('no throw: multi-node + remote driver', () => {
34+
expect(() =>
35+
assertClusterDriverSafeForTopology('redis', { OS_EXPECT_MULTI_NODE: 'true' }),
36+
).not.toThrow();
37+
});
38+
it('THROWS: multi-node (OS_EXPECT_MULTI_NODE) + memory', () => {
39+
expect(() =>
40+
assertClusterDriverSafeForTopology('memory', { OS_EXPECT_MULTI_NODE: 'true' }),
41+
).toThrow(/split-brain/);
42+
});
43+
it('THROWS: multi-node (replicas>1) + memory, cites ADR-0010', () => {
44+
expect(() =>
45+
assertClusterDriverSafeForTopology('memory', { OS_CLUSTER_REPLICAS: '2' }),
46+
).toThrow(/ADR-0010/);
47+
});
48+
it('escape hatch: multi-node + memory + OS_ALLOW_MEMORY_CLUSTER_MULTINODE=true', () => {
49+
expect(() =>
50+
assertClusterDriverSafeForTopology('memory', {
51+
OS_EXPECT_MULTI_NODE: 'true',
52+
OS_ALLOW_MEMORY_CLUSTER_MULTINODE: 'true',
53+
}),
54+
).not.toThrow();
55+
});
56+
it('does not guard unknown custom drivers (author responsibility)', () => {
57+
expect(() =>
58+
assertClusterDriverSafeForTopology('mycustom', { OS_EXPECT_MULTI_NODE: 'true' }),
59+
).not.toThrow();
60+
});
61+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Split-brain guard (ADR-0010, path A).
5+
*
6+
* The `memory` cluster driver keeps PubSub/Lock/KV/Counter state **per
7+
* process**. On a single node that is correct; across multiple replicas
8+
* each process holds its own state, so:
9+
* - `ILock` is acquired locally by every replica -> no mutual exclusion;
10+
* - `ICounter`/`IKV` versions diverge per replica;
11+
* - `IPubSub` does not fan out across processes.
12+
*
13+
* This fails silently -- single-node tests and dev pass, only production
14+
* multi-replica corrupts. The guard turns that silent corruption into a
15+
* loud startup error when the operator has *declared* a multi-node
16+
* topology yet wired an in-process driver.
17+
*
18+
* Detection is deliberately conservative (path A): it keys off an
19+
* explicit operator declaration, not active peer discovery (that is the
20+
* optional path B in ADR-0010). It cannot know at boot whether the
21+
* cluster primitives are actually used cross-node, so it offers an
22+
* escape hatch for the rare "replicas declared but primitives unused"
23+
* case.
24+
*/
25+
26+
/** In-process drivers whose state does not coordinate across replicas. */
27+
const IN_PROCESS_DRIVERS = new Set(['memory']);
28+
29+
/** Environment inputs the guard reads. */
30+
export interface SplitBrainGuardEnv {
31+
/** `'true'` -> operator declares a multi-node deployment. */
32+
OS_EXPECT_MULTI_NODE?: string;
33+
/** Replica count; `> 1` -> multi-node. */
34+
OS_CLUSTER_REPLICAS?: string;
35+
/** `'true'` -> bypass the guard (replicas declared but primitives unused cross-node). */
36+
OS_ALLOW_MEMORY_CLUSTER_MULTINODE?: string;
37+
}
38+
39+
function isTrue(v: string | undefined): boolean {
40+
return String(v).trim().toLowerCase() === 'true';
41+
}
42+
43+
/**
44+
* True when the operator has declared a multi-node topology via
45+
* `OS_EXPECT_MULTI_NODE=true` or `OS_CLUSTER_REPLICAS` greater than 1.
46+
*/
47+
export function declaresMultiNode(env: SplitBrainGuardEnv = process.env): boolean {
48+
if (isTrue(env.OS_EXPECT_MULTI_NODE)) return true;
49+
const replicas = Number(env.OS_CLUSTER_REPLICAS);
50+
return Number.isFinite(replicas) && replicas > 1;
51+
}
52+
53+
/**
54+
* Throw if a multi-node topology is declared while the resolved cluster
55+
* `driver` is in-process (`memory`), unless explicitly allowed via
56+
* `OS_ALLOW_MEMORY_CLUSTER_MULTINODE=true`.
57+
*
58+
* Call this at startup *before* registering the cluster service so a
59+
* misconfiguration fails fast.
60+
*/
61+
export function assertClusterDriverSafeForTopology(
62+
driver: string,
63+
env: SplitBrainGuardEnv = process.env,
64+
): void {
65+
if (!declaresMultiNode(env)) return;
66+
if (!IN_PROCESS_DRIVERS.has(driver)) return;
67+
if (isTrue(env.OS_ALLOW_MEMORY_CLUSTER_MULTINODE)) return;
68+
69+
throw new Error(
70+
`ClusterServicePlugin: multi-node deployment declared ` +
71+
`(OS_EXPECT_MULTI_NODE / OS_CLUSTER_REPLICAS>1) but the cluster driver is ` +
72+
`in-process "${driver}" -- its locks/counters/pub-sub do not coordinate across ` +
73+
`processes, so multiple replicas silently split-brain. Configure a remote cluster ` +
74+
`driver (e.g. @objectstack/service-cluster-redis) or a DB-backed driver. To override ` +
75+
`(replicas declared but cluster primitives unused cross-node), set ` +
76+
`OS_ALLOW_MEMORY_CLUSTER_MULTINODE=true. See cloud ADR-0010.`,
77+
);
78+
}

0 commit comments

Comments
 (0)