Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# @suaveplan/network-plugin-partition - Split-brain detection and resolution for the mesh: epoch-based partition detector, quorum resolver, merge protocol
## Summary
Split-brain detection and resolution for the mesh: epoch-based `PartitionDetector` (deterministic partition-id from sorted reachable set), `QuorumResolver` with size/performance/age/hybrid strategies, `MergeProtocol` for reconciling diverged state when partitions heal.
<!-- begin:auto-docs -->
## Documentation tree
- [detector](./src/detector/partition-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [partition-detector](./src/detector/partition-detector.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [merge](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [merge-protocol](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [quorum](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [quorum-resolver](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-partition plugin. Three codes cover the surface: `SPLIT_BRAIN_DETECTED`…
<!-- end:auto-docs --> - [partition-detector](./src/detector/partition-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge-protocol](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [quorum](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [quorum-resolver](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-partition plugin. Three codes cover the surface: `SPLIT_BRAIN_DETECTED`…
- [partition-detector](./src/detector/partition-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge-protocol](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [quorum](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [quorum-resolver](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-partition plugin. Three codes cover the surface: `SPLIT_BRAIN_DETECTED`…
- [partition-detector](./src/detector/partition-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge-protocol](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [quorum](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [quorum-resolver](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-partition plugin. Three codes cover the surface: `SPLIT_BRAIN_DETECTED`…
- [partition-detector](./src/detector/partition-detector.md) — <!-- begin:auto-docs --> _(no submodules)_- [merge](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_ - [merge-protocol](./src/merge/merge-protocol.md) — <!-- begin:auto-docs --> _(no submodules)_- [quorum](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [quorum-resolver](./src/quorum/quorum-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-partition plugin. Three codes cover the surface: `SPLIT_BRAIN_DETECTED`…

View File

@@ -0,0 +1,49 @@
{
"name": "@suaveplan/network-plugin-partition",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Split-brain detection and resolution for the mesh: epoch-based partition detector, quorum resolver, merge protocol",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PartitionDetector } from "./partition-detector.js";
describe("PartitionDetector", () => {
it("starts with the local peer reachable and not partitioned", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
const view = d.getPartitionView();
expect(view.reachablePeers).toContain("alice");
expect(view.knownPeers).toContain("alice");
expect(view.epoch).toBe(0);
expect(view.isPartitioned).toBe(false);
expect(view.primary).toBe(true);
});
it("records and tracks the highest epoch per peer", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
d.recordEpoch("bob", 3);
d.recordEpoch("bob", 1); // older — ignored
d.recordEpoch("carol", 5);
expect(d.getCurrentEpoch()).toBe(5);
});
it("flags partition when reachable < quorumRatio", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
quorumRatio: 0.66,
});
d.recordEpoch("bob", 1);
d.recordEpoch("carol", 1);
d.recordEpoch("dave", 1);
d.markUnreachable("dave");
d.markUnreachable("carol");
const view = d.getPartitionView();
expect(view.isPartitioned).toBe(true);
expect(view.primary).toBe(false);
});
it("partitionId is deterministic across observations", () => {
const d1 = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
const d2 = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
d1.recordEpoch("bob", 1);
d2.recordEpoch("bob", 1);
expect(d1.getPartitionView().partitionId).toBe(
d2.getPartitionView().partitionId
);
});
it("partitionId differs between distinct reachable sets", () => {
const d1 = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
const d2 = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
d1.recordEpoch("bob", 1);
d2.recordEpoch("carol", 1);
expect(d1.getPartitionView().partitionId).not.toBe(
d2.getPartitionView().partitionId
);
});
it("uses an injected hash function", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
hash: () => "sentinel",
});
expect(d.getPartitionView().partitionId).toBe("sentinel");
});
it("uses an injected clock for establishedAt", () => {
let now = 100;
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
clock: () => now,
});
const before = d.getPartitionView().establishedAt;
now = 9999;
d.reset();
const after = d.getPartitionView().establishedAt;
expect(before).toBe(100);
expect(after).toBe(9999);
});
it("markUnreachable forgets a peer's epoch", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
d.recordEpoch("bob", 7);
d.markUnreachable("bob");
expect(d.getPartitionView().reachablePeers).not.toContain("bob");
});
it("reset clears state and re-stamps establishedAt", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
d.recordEpoch("bob", 7);
d.reset();
expect(d.getCurrentEpoch()).toBe(0);
expect(d.getPartitionView().reachablePeers).toEqual(["alice"]);
});
it("uses default Date.now-based clock", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
const v = d.getPartitionView();
expect(typeof v.establishedAt).toBe("number");
});
it("does not duplicate localPeerId when it is already in reachable", () => {
const d = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
});
d.recordEpoch("alice", 1);
const view = d.getPartitionView();
const occurrences = view.reachablePeers.filter(
(p) => p === "alice"
).length;
expect(occurrences).toBe(1);
});
});

View File

@@ -0,0 +1,73 @@
# PartitionDetector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks per-peer epoch heartbeats from the failover plugin and surfaces a deterministic `PartitionView` whenever the reachable subset changes. Two peers in the same partition compute the same `partitionId`; two peers in different partitions compute different ids — that is the canonical signal for split-brain.
## Features
- Epoch ingestion via `recordEpoch(peerId, epoch)`; only highest-seen epoch is retained.
- Explicit `markUnreachable(peerId)` for failover-driven removal.
- Configurable `quorumRatio` (default 0.5) — below this fraction the view flags `isPartitioned: true`.
- `partitionId` is a deterministic hash of the sorted reachable peer set; the local peer is always counted reachable from its own perspective.
- Pluggable `hash` (default 32-bit FNV-1a → 8-hex string). Consumers can swap in BLAKE3, SHA-256, etc.
- Pluggable `clock` for deterministic tests.
- `reset()` clears all state and re-stamps `establishedAt`.
## Usage: Basic Example
```ts
import { PartitionDetector } from "@suaveplan/network-plugin-partition";
const detector = new PartitionDetector({
meshId: "mesh-A",
localPeerId: "alice",
});
detector.recordEpoch("bob", 1);
detector.recordEpoch("carol", 1);
const view = detector.getPartitionView();
if (view.isPartitioned) {
/* trigger resolver */
}
```
## Usage: Advanced Example
```ts
import { PartitionDetector } from "@suaveplan/network-plugin-partition";
const detector = new PartitionDetector({
meshId: "mesh-A",
localPeerId: "alice",
quorumRatio: 0.66,
hash: (s) => blake3(s).hex(),
});
// Bob and Carol are reachable; Dave was declared dead by failover.
detector.recordEpoch("bob", 7);
detector.recordEpoch("carol", 7);
detector.recordEpoch("dave", 5);
detector.markUnreachable("dave");
const view = detector.getPartitionView();
console.log(view.partitionId, view.reachablePeers);
```
## API Reference
- `interface PartitionView { epoch, knownPeers, reachablePeers, isPartitioned, partitionId, primary, latencyMs?, establishedAt? }`.
- `interface PartitionDetectorConfig { meshId, localPeerId, quorumRatio?, clock?, hash? }`.
- `recordEpoch(peerId, epoch)` — heartbeat ingestion.
- `markUnreachable(peerId)` — explicit removal.
- `getCurrentEpoch(): number`.
- `getPartitionView(): PartitionView`.
- `reset()` — clear state.
## Implementation Notes
The detector intentionally does not run a clock-driven loop — heartbeat ingestion is push-driven by the surrounding plugin. This keeps the detector synchronous from the caller's perspective and free of leaked timers in tests. The default FNV-1a hash is 32 bits, which is plenty of entropy for partition fingerprinting in any realistic mesh size; consumers that need cryptographic guarantees should swap in BLAKE3 via `config.hash`. `establishedAt` is captured on construction and on every `reset()` so the resolver's `age` strategy has a reliable timestamp to compare across nodes.

View File

@@ -0,0 +1,119 @@
/**
* One node's view of the mesh from its own perspective.
*/
export interface PartitionView {
readonly epoch: number;
readonly knownPeers: readonly string[];
readonly reachablePeers: readonly string[];
readonly isPartitioned: boolean;
readonly partitionId: string;
readonly primary: boolean;
readonly latencyMs?: number;
readonly establishedAt?: number;
}
/**
* Configuration for `PartitionDetector`.
*/
export interface PartitionDetectorConfig {
readonly meshId: string;
readonly localPeerId: string;
readonly quorumRatio?: number;
readonly clock?: () => number;
readonly hash?: (input: string) => string;
}
/**
* Stable 32-bit FNV-1a hash that emits an 8-hex-char fingerprint.
* Used as a default `partitionId` derivation; consumers can override
* via `config.hash` to use BLAKE3, SHA-256, etc.
*/
function defaultHash(input: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, "0");
}
/**
* Tracks per-peer epoch heartbeats and surfaces a deterministic
* `PartitionView` whenever the reachable subset of `knownPeers` changes.
*
* - `recordEpoch(peerId, epoch)` is the heartbeat ingestion point.
* - `markUnreachable(peerId)` is the explicit removal point (used by
* the failover plugin when a peer is declared dead).
* - `getCurrentEpoch()` returns the highest epoch this node has seen.
* - `getPartitionView()` returns a snapshot of the current view.
*/
export class PartitionDetector {
private readonly meshId: string;
private readonly localPeerId: string;
private readonly quorumRatio: number;
private readonly clock: () => number;
private readonly hash: (input: string) => string;
private readonly knownPeers = new Set<string>();
private readonly lastEpoch = new Map<string, number>();
private currentEpoch = 0;
private establishedAt: number;
public constructor(config: PartitionDetectorConfig) {
this.meshId = config.meshId;
this.localPeerId = config.localPeerId;
this.quorumRatio = config.quorumRatio ?? 0.5;
this.clock = config.clock ?? (() => Date.now());
this.hash = config.hash ?? defaultHash;
this.knownPeers.add(this.localPeerId);
this.establishedAt = this.clock();
}
public recordEpoch(peerId: string, epoch: number): void {
this.knownPeers.add(peerId);
const prev = this.lastEpoch.get(peerId) ?? -1;
if (epoch > prev) {
this.lastEpoch.set(peerId, epoch);
}
if (epoch > this.currentEpoch) {
this.currentEpoch = epoch;
}
}
public markUnreachable(peerId: string): void {
this.lastEpoch.delete(peerId);
}
public getCurrentEpoch(): number {
return this.currentEpoch;
}
public getPartitionView(): PartitionView {
const reachable = [...this.lastEpoch.keys()].sort();
// The local peer is always considered reachable from its own
// perspective, even before its first outbound heartbeat.
if (!reachable.includes(this.localPeerId))
reachable.unshift(this.localPeerId);
const known = [...this.knownPeers].sort();
const ratio = reachable.length / known.length;
const isPartitioned = ratio < this.quorumRatio;
const partitionId = this.hash(`${this.meshId}::${reachable.join(",")}`);
const primary = !isPartitioned;
return {
epoch: this.currentEpoch,
knownPeers: known,
reachablePeers: reachable,
isPartitioned,
partitionId,
primary,
establishedAt: this.establishedAt,
};
}
public reset(): void {
this.lastEpoch.clear();
this.knownPeers.clear();
this.knownPeers.add(this.localPeerId);
this.currentEpoch = 0;
this.establishedAt = this.clock();
}
}

View File

@@ -0,0 +1,60 @@
# PARTITION_CODES
## Purpose
Frozen registry of error codes for the network-partition plugin. Three codes cover the surface: `SPLIT_BRAIN_DETECTED` when the detector observes two or more partitions, `QUORUM_UNRESOLVED` when the resolver cannot pick a winner, and `MERGE_FAILED` when the merge protocol cannot reconcile diverged state. The companion `PartitionError` extends `SuaveplanError<PartitionErrorCode>` so consumers pattern-match on `code` rather than string-matching messages.
## Features
- Three frozen codes registered under the `NET_PART` namespace.
- `PartitionError extends SuaveplanError<PartitionErrorCode>`.
- `name: "PartitionError"` on every instance.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
- Codes are stable across versions — once registered they never disappear.
## Usage: Basic Example
```ts
import {
PARTITION_CODES,
PartitionError,
} from "@suaveplan/network-plugin-partition";
throw new PartitionError(
PARTITION_CODES.SPLIT_BRAIN_DETECTED,
"partition.split-brain",
{ context: { partitionCount: 2 } }
);
```
## Usage: Advanced Example
```ts
import {
PARTITION_CODES,
PartitionError,
} from "@suaveplan/network-plugin-partition";
try {
resolver.resolve(views);
} catch (err) {
if (
err instanceof PartitionError &&
err.code === PARTITION_CODES.QUORUM_UNRESOLVED
) {
// fall back to read-only mode until the operator intervenes
return enterReadOnlyMode();
}
throw err;
}
```
## API Reference
- `PARTITION_CODES` — frozen `{ SPLIT_BRAIN_DETECTED, QUORUM_UNRESOLVED, MERGE_FAILED }`.
- `type PartitionErrorCode` — union of registered code values.
- `class PartitionError extends SuaveplanError<PartitionErrorCode>` — typed throwable.
## Implementation Notes
The codes are intentionally tight: every other failure mode bubbles up as the underlying transport or schema error from `@suaveplan/error`. `SPLIT_BRAIN_DETECTED` is informational at the detector level — it signals that the resolver should run, not that anything is unrecoverable. `QUORUM_UNRESOLVED` is the only code that requires operator attention; the resolver returns `null` rather than throwing when no strategy can pick a winner, and callers convert that signal to this code at their own discretion. `MERGE_FAILED` always carries the offending reconciler's name in `context` so dashboards can surface "which step in which merge failed" without parsing log strings.

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PARTITION_CODES, PartitionError } from "./error-codes.js";
describe("PARTITION_CODES", () => {
it("registers under NET_PART", () => {
for (const value of Object.values(PARTITION_CODES)) {
expect(value).toMatch(/^NET_PART\./);
}
});
it("is frozen", () => {
expect(Object.isFrozen(PARTITION_CODES)).toBe(true);
});
it("contains the documented codes", () => {
for (const key of [
"SPLIT_BRAIN_DETECTED",
"QUORUM_UNRESOLVED",
"MERGE_FAILED",
] as const) {
expect(PARTITION_CODES).toHaveProperty(key);
}
});
});
describe("PartitionError", () => {
it("extends SuaveplanError", () => {
const err = new PartitionError(
PARTITION_CODES.SPLIT_BRAIN_DETECTED,
"split-brain"
);
expect(err.name).toBe("PartitionError");
expect(err.code).toBe(PARTITION_CODES.SPLIT_BRAIN_DETECTED);
});
it("preserves cause", () => {
const cause = new Error("inner");
const err = new PartitionError(
PARTITION_CODES.MERGE_FAILED,
"with-cause",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,21 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const PARTITION_CODES = defineCodes("NET_PART", [
"SPLIT_BRAIN_DETECTED",
"QUORUM_UNRESOLVED",
"MERGE_FAILED",
] as const);
export type PartitionErrorCode =
(typeof PARTITION_CODES)[keyof typeof PARTITION_CODES];
export class PartitionError extends SuaveplanError<PartitionErrorCode> {
public constructor(
code: PartitionErrorCode,
messageKey: string,
options?: import("@suaveplan/error").SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "PartitionError";
}
}

View File

@@ -0,0 +1,28 @@
// Error codes (side-effect registration)
import "./error-codes.js";
// Detector
export {
PartitionDetector,
type PartitionDetectorConfig,
type PartitionView,
} from "./detector/partition-detector.js";
// Errors
export {
PARTITION_CODES,
PartitionError,
type PartitionErrorCode,
} from "./error-codes.js";
// Merge
export {
MergeProtocol,
type MergeProtocolConfig,
type MergeResult,
type Reconciler,
} from "./merge/merge-protocol.js";
// Quorum
export {
QuorumResolver,
type QuorumResolverConfig,
type QuorumStrategy,
} from "./quorum/quorum-resolver.js";

View File

@@ -0,0 +1,78 @@
# MergeProtocol
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Drives the per-reconciler merge sequence when the `QuorumResolver` picks a winning partition. Each reconciler — registry, topology, audit log, etc. — gets a chance to converge its state under the authoritative view. If any reconciler throws, the merge is aborted with `MERGE_FAILED` so the operator can intervene before further damage.
## Features
- Pluggable `Reconciler<T>` interface; works for any mesh-state shape.
- Runs reconcilers in registration order so dependencies between them are explicit.
- Short-circuits when `authoritative.partitionId === divergent.partitionId` (nothing to merge).
- Throws `MERGE_FAILED` on missing state for a registered reconciler — surfacing config bugs loudly.
- Wraps reconciler exceptions with the offending reconciler's name in `context`.
- `reconcilerCount()` exposed for observability dashboards.
## Usage: Basic Example
```ts
import {
MergeProtocol,
type Reconciler,
} from "@suaveplan/network-plugin-partition";
const registryReconciler: Reconciler<RegistryState> = {
name: "registry",
reconcile: (auth, div) => mergeRegistries(auth, div),
};
const merge = new MergeProtocol({ reconcilers: [registryReconciler] });
await merge.merge(authoritative, divergent, states);
```
## Usage: Advanced Example
```ts
import {
MergeProtocol,
PARTITION_CODES,
PartitionError,
type Reconciler,
} from "@suaveplan/network-plugin-partition";
const reconcilers: Reconciler<unknown>[] = [
{ name: "registry", reconcile: mergeRegistries as never },
{ name: "topology", reconcile: mergeTopology as never },
{ name: "audit", reconcile: mergeAuditLog as never },
];
const merge = new MergeProtocol({ reconcilers });
try {
const result = await merge.merge(authoritative, divergent, states);
metric.gauge("merge.reconciler-count", result.reconcilerNames.length);
} catch (err) {
if (
err instanceof PartitionError &&
err.code === PARTITION_CODES.MERGE_FAILED
) {
operatorAlert("merge-failed", err.context);
}
throw err;
}
```
## API Reference
- `interface Reconciler<T> { name, reconcile(authoritative, divergent): T | Promise<T> }`.
- `interface MergeProtocolConfig { reconcilers }`.
- `interface MergeResult { authoritative, divergent, reconcilerNames }`.
- `merge(authoritative, divergent, states): Promise<MergeResult>`.
- `reconcilerCount(): number`.
## Implementation Notes
The merge intentionally aborts on the first reconciler exception rather than continuing through the others — partial reconciliation is more dangerous than a failed merge because it leaves the mesh in a half-converged state. Reconcilers that need to be tolerant of partial merges should catch internally and return a "best-effort" result rather than relying on the protocol to swallow exceptions. `states` is a `ReadonlyMap` keyed by reconciler name to keep call-sites explicit about what state each reconciler operates on.

View File

@@ -0,0 +1,88 @@
import type { PartitionView } from "../detector/partition-detector.js";
import { PARTITION_CODES, PartitionError } from "../error-codes.js";
/**
* Reconciler hook for one piece of mesh state (registry, topology,
* audit log, etc.). Returns the reconciled state on success and throws
* `PartitionError(MERGE_FAILED)` if reconciliation cannot proceed.
*/
export interface Reconciler<T> {
readonly name: string;
reconcile(authoritative: T, divergent: T): T | Promise<T>;
}
/**
* Configuration for `MergeProtocol`.
*/
export interface MergeProtocolConfig {
readonly reconcilers: readonly Reconciler<unknown>[];
}
/**
* Result of a merge attempt.
*/
export interface MergeResult {
readonly authoritative: PartitionView;
readonly divergent: PartitionView;
readonly reconcilerNames: readonly string[];
}
/**
* Drives the per-reconciler merge sequence when the resolver picks a
* winner. Each reconciler is run in registration order; if any throws,
* the merge is aborted with `MERGE_FAILED` carrying the reconciler
* name in `context`.
*/
export class MergeProtocol {
private readonly reconcilers: readonly Reconciler<unknown>[];
public constructor(config: MergeProtocolConfig) {
this.reconcilers = config.reconcilers;
}
public async merge(
authoritative: PartitionView,
divergent: PartitionView,
states: ReadonlyMap<
string,
{ authoritative: unknown; divergent: unknown }
>
): Promise<MergeResult> {
if (authoritative.partitionId === divergent.partitionId) {
return {
authoritative,
divergent,
reconcilerNames: [],
};
}
const ranNames: string[] = [];
for (const reconciler of this.reconcilers) {
const pair = states.get(reconciler.name);
if (pair === undefined) {
throw new PartitionError(
PARTITION_CODES.MERGE_FAILED,
"merge.missing-state",
{ context: { reconciler: reconciler.name } }
);
}
try {
await reconciler.reconcile(pair.authoritative, pair.divergent);
} catch (cause) {
throw new PartitionError(
PARTITION_CODES.MERGE_FAILED,
"merge.reconciler-threw",
{
context: { reconciler: reconciler.name },
cause: cause as Error,
}
);
}
ranNames.push(reconciler.name);
}
return { authoritative, divergent, reconcilerNames: ranNames };
}
public reconcilerCount(): number {
return this.reconcilers.length;
}
}

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PartitionView } from "../detector/partition-detector.js";
import { MergeProtocol, type Reconciler } from "./merge-protocol.js";
const authoritative: PartitionView = {
epoch: 1,
knownPeers: ["a", "b"],
reachablePeers: ["a", "b"],
isPartitioned: false,
partitionId: "auth",
primary: true,
};
const divergent: PartitionView = {
...authoritative,
partitionId: "div",
};
describe("MergeProtocol", () => {
it("short-circuits when partitionIds match", async () => {
const seen: string[] = [];
const r: Reconciler<unknown> = {
name: "x",
reconcile: () => {
seen.push("ran");
return null;
},
};
const merge = new MergeProtocol({ reconcilers: [r] });
const result = await merge.merge(
authoritative,
authoritative,
new Map()
);
expect(seen).toEqual([]);
expect(result.reconcilerNames).toEqual([]);
});
it("runs reconcilers in registration order", async () => {
const order: string[] = [];
const make = (name: string): Reconciler<unknown> => ({
name,
reconcile: () => {
order.push(name);
return null;
},
});
const merge = new MergeProtocol({
reconcilers: [make("r1"), make("r2"), make("r3")],
});
const states = new Map([
["r1", { authoritative: 1, divergent: 2 }],
["r2", { authoritative: 1, divergent: 2 }],
["r3", { authoritative: 1, divergent: 2 }],
]);
const result = await merge.merge(authoritative, divergent, states);
expect(order).toEqual(["r1", "r2", "r3"]);
expect(result.reconcilerNames).toEqual(["r1", "r2", "r3"]);
});
it("throws MERGE_FAILED on missing state", async () => {
const r: Reconciler<unknown> = {
name: "missing",
reconcile: () => null,
};
const merge = new MergeProtocol({ reconcilers: [r] });
await expect(
merge.merge(authoritative, divergent, new Map())
).rejects.toThrow("merge.missing-state");
});
it("throws MERGE_FAILED when a reconciler throws", async () => {
const r: Reconciler<unknown> = {
name: "boom",
reconcile: () => {
throw new Error("inner-fail");
},
};
const merge = new MergeProtocol({ reconcilers: [r] });
await expect(
merge.merge(
authoritative,
divergent,
new Map([["boom", { authoritative: 1, divergent: 2 }]])
)
).rejects.toThrow("merge.reconciler-threw");
});
it("supports async reconcilers", async () => {
const r: Reconciler<unknown> = {
name: "async",
reconcile: async (a, b) => Promise.resolve([a, b]),
};
const merge = new MergeProtocol({ reconcilers: [r] });
const result = await merge.merge(
authoritative,
divergent,
new Map([["async", { authoritative: 1, divergent: 2 }]])
);
expect(result.reconcilerNames).toEqual(["async"]);
});
it("aborts at the first failing reconciler", async () => {
const order: string[] = [];
const merge = new MergeProtocol({
reconcilers: [
{
name: "ok",
reconcile: () => {
order.push("ok");
return null;
},
},
{
name: "boom",
reconcile: () => {
throw new Error("nope");
},
},
{
name: "never",
reconcile: () => {
order.push("never");
return null;
},
},
],
});
const states = new Map([
["ok", { authoritative: 1, divergent: 2 }],
["boom", { authoritative: 1, divergent: 2 }],
["never", { authoritative: 1, divergent: 2 }],
]);
await expect(
merge.merge(authoritative, divergent, states)
).rejects.toThrow("merge.reconciler-threw");
expect(order).toEqual(["ok"]);
});
it("reconcilerCount reflects registered reconcilers", () => {
const merge = new MergeProtocol({
reconcilers: [
{ name: "r1", reconcile: () => null },
{ name: "r2", reconcile: () => null },
],
});
expect(merge.reconcilerCount()).toBe(2);
});
});

View File

@@ -0,0 +1,54 @@
# QuorumResolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Picks the authoritative `PartitionView` from a set of candidates produced by per-peer detectors. Without a deterministic resolver, two partitioned subsets would each declare themselves authoritative and cause cascading divergence — this class is the single decision point that breaks the tie.
## Features
- Four strategies: `size`, `performance`, `age`, `hybrid`.
- Hybrid weights configurable (default `0.4 / 0.4 / 0.2`).
- Deterministic lexicographic tiebreak on `partitionId` so independent observers agree.
- Returns `null` when no candidates are supplied.
- Single-candidate input short-circuits to that candidate.
- Missing `latencyMs` / `establishedAt` are coerced to `+Infinity` — i.e. the worst possible value on those axes.
## Usage: Basic Example
```ts
import { QuorumResolver } from "@suaveplan/network-plugin-partition";
const resolver = new QuorumResolver({ strategy: "size" });
const winner = resolver.resolve(views);
```
## Usage: Advanced Example
```ts
import { QuorumResolver } from "@suaveplan/network-plugin-partition";
const resolver = new QuorumResolver({
strategy: "hybrid",
weights: { size: 0.5, performance: 0.3, age: 0.2 },
});
const winner = resolver.resolve(allViews);
if (winner === null) {
operatorAlert("partition-unresolved");
} else {
promoteAuthoritativeMesh(winner.partitionId);
}
```
## API Reference
- `type QuorumStrategy = "size" | "performance" | "age" | "hybrid"`.
- `interface QuorumResolverConfig { strategy, weights? }`.
- `resolve(partitions): PartitionView | null` — picks the winner or `null` for empty input.
## Implementation Notes
The hybrid score normalises each axis to `[0, 1]` against the best candidate on that axis: size as `reachablePeers / maxSize`, performance as `minLatency / latencyMs` (lower-is-better inverted), age as `minAge / establishedAt` (older-is-better inverted). Sums of weighted normalised values are compared, ties broken by `partitionId.localeCompare`. `null` returns are intentionally distinct from "lowest-scoring partition" — callers should treat `null` as "operator must intervene" rather than auto-recovering. The single-strategy paths (`size`, `performance`, `age`) all use the same lex-tiebreak so independent observers reach the same winner deterministically; without that, two nodes with identical metric inputs could pick different winners and re-trigger the resolver indefinitely. The default hybrid weights (`0.4 / 0.4 / 0.2`) prioritise size and performance over age, since a brand-new partition with most of the peers is usually a healthier choice than an old partition that has bled members.

View File

@@ -0,0 +1,126 @@
import type { PartitionView } from "../detector/partition-detector.js";
/**
* Strategy for picking the authoritative partition.
*/
export type QuorumStrategy = "size" | "performance" | "age" | "hybrid";
/**
* Configuration for `QuorumResolver`.
*/
export interface QuorumResolverConfig {
readonly strategy: QuorumStrategy;
readonly weights?: {
readonly size: number;
readonly performance: number;
readonly age: number;
};
}
const DEFAULT_HYBRID_WEIGHTS = { size: 0.4, performance: 0.4, age: 0.2 };
/**
* Picks an authoritative `PartitionView` from a set of candidates.
*
* - `size` — most reachable peers wins.
* - `performance` — lowest latency wins (defaults to `Number.POSITIVE_INFINITY` when unset).
* - `age` — oldest `establishedAt` wins.
* - `hybrid` — weighted composite (defaults 0.4/0.4/0.2).
*
* Returns `null` when no candidates are supplied or the resolver
* cannot pick a winner (e.g. all candidates have identical scores under
* `hybrid` and an empty `partitionId`).
*/
export class QuorumResolver {
private readonly strategy: QuorumStrategy;
private readonly weights: {
size: number;
performance: number;
age: number;
};
public constructor(config: QuorumResolverConfig) {
this.strategy = config.strategy;
this.weights = config.weights ?? DEFAULT_HYBRID_WEIGHTS;
}
public resolve(partitions: readonly PartitionView[]): PartitionView | null {
if (partitions.length === 0) return null;
if (partitions.length === 1) return partitions[0]!;
switch (this.strategy) {
case "size":
return this.bySize(partitions);
case "performance":
return this.byPerformance(partitions);
case "age":
return this.byAge(partitions);
default:
return this.byHybrid(partitions);
}
}
private bySize(partitions: readonly PartitionView[]): PartitionView {
return [...partitions].sort((a, b) => {
const diff = b.reachablePeers.length - a.reachablePeers.length;
return diff !== 0
? diff
: a.partitionId.localeCompare(b.partitionId);
})[0]!;
}
private byPerformance(partitions: readonly PartitionView[]): PartitionView {
return [...partitions].sort((a, b) => {
const al = a.latencyMs ?? Number.POSITIVE_INFINITY;
const bl = b.latencyMs ?? Number.POSITIVE_INFINITY;
return al !== bl
? al - bl
: a.partitionId.localeCompare(b.partitionId);
})[0]!;
}
private byAge(partitions: readonly PartitionView[]): PartitionView {
return [...partitions].sort((a, b) => {
const ae = a.establishedAt ?? Number.POSITIVE_INFINITY;
const be = b.establishedAt ?? Number.POSITIVE_INFINITY;
return ae !== be
? ae - be
: a.partitionId.localeCompare(b.partitionId);
})[0]!;
}
private byHybrid(partitions: readonly PartitionView[]): PartitionView {
const maxSize = Math.max(
...partitions.map((p) => p.reachablePeers.length)
);
const minLatency = Math.min(
...partitions.map((p) => p.latencyMs ?? Number.POSITIVE_INFINITY)
);
const minAge = Math.min(
...partitions.map(
(p) => p.establishedAt ?? Number.POSITIVE_INFINITY
)
);
const scored = partitions.map((p) => ({
view: p,
score:
this.weights.size *
(maxSize === 0 ? 0 : p.reachablePeers.length / maxSize) +
this.weights.performance *
(minLatency === Number.POSITIVE_INFINITY || minLatency === 0
? 1
: minLatency /
(p.latencyMs ?? Number.POSITIVE_INFINITY)) +
this.weights.age *
(minAge === Number.POSITIVE_INFINITY
? 0
: minAge /
(p.establishedAt ?? Number.POSITIVE_INFINITY)),
}));
return scored.sort((a, b) => {
const diff = b.score - a.score;
return diff !== 0
? diff
: a.view.partitionId.localeCompare(b.view.partitionId);
})[0]!.view;
}
}

View File

@@ -0,0 +1,241 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { PartitionView } from "../detector/partition-detector.js";
import { QuorumResolver } from "./quorum-resolver.js";
function view(
p: Partial<PartitionView> & { partitionId: string }
): PartitionView {
return {
epoch: 1,
knownPeers: ["a", "b", "c"],
reachablePeers: ["a", "b"],
isPartitioned: false,
primary: true,
...p,
};
}
describe("QuorumResolver", () => {
it("returns null for empty input", () => {
const r = new QuorumResolver({ strategy: "size" });
expect(r.resolve([])).toBeNull();
});
it("short-circuits on a single candidate", () => {
const r = new QuorumResolver({ strategy: "size" });
const only = view({ partitionId: "p1" });
expect(r.resolve([only])).toBe(only);
});
it("size strategy picks the largest reachable set", () => {
const r = new QuorumResolver({ strategy: "size" });
const small = view({ partitionId: "small", reachablePeers: ["a"] });
const big = view({
partitionId: "big",
reachablePeers: ["a", "b", "c"],
});
expect(r.resolve([small, big])).toBe(big);
});
it("size tiebreaks lexicographically on partitionId", () => {
const r = new QuorumResolver({ strategy: "size" });
const a = view({
partitionId: "aaa",
reachablePeers: ["a", "b"],
});
const z = view({
partitionId: "zzz",
reachablePeers: ["a", "b"],
});
expect(r.resolve([z, a])).toBe(a);
});
it("performance strategy picks the lowest latency", () => {
const r = new QuorumResolver({ strategy: "performance" });
const fast = view({ partitionId: "fast", latencyMs: 50 });
const slow = view({ partitionId: "slow", latencyMs: 500 });
expect(r.resolve([slow, fast])).toBe(fast);
});
it("performance treats missing latency as worst-case", () => {
const r = new QuorumResolver({ strategy: "performance" });
const fast = view({ partitionId: "fast", latencyMs: 50 });
const unknown = view({ partitionId: "unknown" });
expect(r.resolve([unknown, fast])).toBe(fast);
});
it("performance tiebreaks lex on partitionId", () => {
const r = new QuorumResolver({ strategy: "performance" });
const a = view({ partitionId: "a", latencyMs: 100 });
const b = view({ partitionId: "b", latencyMs: 100 });
expect(r.resolve([b, a])).toBe(a);
});
it("age strategy picks the oldest establishedAt", () => {
const r = new QuorumResolver({ strategy: "age" });
const old_ = view({ partitionId: "old", establishedAt: 100 });
const new_ = view({ partitionId: "new", establishedAt: 1000 });
expect(r.resolve([new_, old_])).toBe(old_);
});
it("age tiebreaks lex on partitionId", () => {
const r = new QuorumResolver({ strategy: "age" });
const a = view({ partitionId: "a", establishedAt: 100 });
const b = view({ partitionId: "b", establishedAt: 100 });
expect(r.resolve([b, a])).toBe(a);
});
it("hybrid strategy combines all three axes", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const winner = view({
partitionId: "winner",
reachablePeers: ["a", "b", "c"],
latencyMs: 50,
establishedAt: 100,
});
const loser = view({
partitionId: "loser",
reachablePeers: ["a"],
latencyMs: 500,
establishedAt: 1000,
});
expect(r.resolve([loser, winner])).toBe(winner);
});
it("hybrid tiebreaks lex on partitionId for identical scores", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const a = view({
partitionId: "a",
reachablePeers: ["x", "y"],
latencyMs: 100,
establishedAt: 100,
});
const b = view({
partitionId: "b",
reachablePeers: ["x", "y"],
latencyMs: 100,
establishedAt: 100,
});
expect(r.resolve([b, a])).toBe(a);
});
it("hybrid handles all-missing latency / age (no Infinity propagation)", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const a = view({ partitionId: "a", reachablePeers: ["x"] });
const b = view({
partitionId: "b",
reachablePeers: ["x", "y"],
});
expect(r.resolve([a, b])).toBe(b);
});
it("hybrid uses custom weights", () => {
const r = new QuorumResolver({
strategy: "hybrid",
weights: { size: 1, performance: 0, age: 0 },
});
const small = view({
partitionId: "small",
reachablePeers: ["a"],
latencyMs: 1,
establishedAt: 1,
});
const big = view({
partitionId: "big",
reachablePeers: ["a", "b"],
latencyMs: 1000,
establishedAt: 9999,
});
expect(r.resolve([small, big])).toBe(big);
});
it("hybrid handles all-zero size set", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const a = view({ partitionId: "a", reachablePeers: [] });
const b = view({ partitionId: "b", reachablePeers: [] });
const winner = r.resolve([a, b]);
expect(winner).not.toBeNull();
});
it("hybrid handles minLatency=0", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const fast = view({
partitionId: "fast",
reachablePeers: ["a"],
latencyMs: 0,
});
const slow = view({
partitionId: "slow",
reachablePeers: ["a"],
latencyMs: 100,
});
const winner = r.resolve([fast, slow]);
expect(winner).not.toBeNull();
});
it("hybrid handles all-missing establishedAt", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const a = view({
partitionId: "a",
reachablePeers: ["x"],
latencyMs: 10,
});
const b = view({
partitionId: "b",
reachablePeers: ["x", "y"],
latencyMs: 10,
});
const winner = r.resolve([a, b]);
expect(winner).toBe(b);
});
it("age handles tiebreak with all-missing establishedAt", () => {
const r = new QuorumResolver({ strategy: "age" });
const a = view({ partitionId: "a" });
const b = view({ partitionId: "b" });
const winner = r.resolve([b, a]);
expect(winner).toBe(a);
});
it("hybrid mixes views with and without latencyMs", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const known = view({
partitionId: "known",
reachablePeers: ["a"],
latencyMs: 50,
establishedAt: 100,
});
const unknown = view({
partitionId: "unknown",
reachablePeers: ["a"],
establishedAt: 100,
});
const winner = r.resolve([unknown, known]);
expect(winner).toBe(known);
});
it("hybrid mixes views with and without establishedAt", () => {
const r = new QuorumResolver({ strategy: "hybrid" });
const known = view({
partitionId: "known",
reachablePeers: ["a"],
latencyMs: 10,
establishedAt: 100,
});
const unknown = view({
partitionId: "unknown",
reachablePeers: ["a"],
latencyMs: 10,
});
const winner = r.resolve([unknown, known]);
expect(winner).toBe(known);
});
it("performance also lex-tiebreaks when one latency is missing", () => {
const r = new QuorumResolver({ strategy: "performance" });
const noLatA = view({ partitionId: "a" });
const noLatB = view({ partitionId: "b" });
const winner = r.resolve([noLatB, noLatA]);
expect(winner).toBe(noLatA);
});
});

View File

@@ -0,0 +1,10 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-plugin-partition dep-graph", () => {
it("loads index.js (acyclic static imports)", async () => {
const mod = await import("../src/index.js");
expect(mod.PartitionDetector).toBeDefined();
expect(mod.QuorumResolver).toBeDefined();
expect(mod.MergeProtocol).toBeDefined();
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
MergeProtocol,
PartitionDetector,
QuorumResolver,
type Reconciler,
} from "../../src/index.js";
describe("partition end-to-end", () => {
it("detects split-brain, resolves quorum, and runs the merge", async () => {
const aliceDetector = new PartitionDetector({
meshId: "m",
localPeerId: "alice",
quorumRatio: 0.5,
});
aliceDetector.recordEpoch("bob", 1);
const charlieDetector = new PartitionDetector({
meshId: "m",
localPeerId: "charlie",
quorumRatio: 0.5,
});
charlieDetector.recordEpoch("dave", 1);
const aliceView = aliceDetector.getPartitionView();
const charlieView = charlieDetector.getPartitionView();
expect(aliceView.partitionId).not.toBe(charlieView.partitionId);
const resolver = new QuorumResolver({ strategy: "size" });
const winner = resolver.resolve([
{ ...aliceView, reachablePeers: ["alice", "bob", "x"] },
{ ...charlieView, reachablePeers: ["charlie"] },
]);
expect(winner?.reachablePeers.length).toBe(3);
const reconciled: string[] = [];
const reg: Reconciler<unknown> = {
name: "registry",
reconcile: (auth, div) => {
reconciled.push(`${String(auth)}+${String(div)}`);
return auth;
},
};
const merge = new MergeProtocol({ reconcilers: [reg] });
await merge.merge(
aliceView,
charlieView,
new Map([
[
"registry",
{ authoritative: "auth-state", divergent: "div-state" },
],
])
);
expect(reconciled).toEqual(["auth-state+div-state"]);
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir:
"../../../node_modules/.vite/packages/network-plugin-partition",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/network",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});