first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 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,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,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,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,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.