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,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);
});
});