Initial Commit
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user