@einaraglen/quorum 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/bully.d.ts +56 -0
- package/dist/core/bully.d.ts.map +1 -0
- package/dist/core/bully.js +140 -0
- package/dist/core/forum.d.ts +41 -0
- package/dist/core/forum.d.ts.map +1 -0
- package/dist/core/forum.js +153 -0
- package/dist/core/logger.d.ts +3 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +0 -0
- package/dist/core/quorum.d.ts +47 -0
- package/dist/core/quorum.d.ts.map +1 -0
- package/dist/core/quorum.js +74 -0
- package/dist/core/sharding.d.ts +94 -0
- package/dist/core/sharding.d.ts.map +1 -0
- package/dist/core/sharding.js +247 -0
- package/dist/core/transport.d.ts +29 -0
- package/dist/core/transport.d.ts.map +1 -0
- package/dist/core/transport.js +83 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/package.json +43 -43
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import type { Logger } from "./logger";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
export type BullyPeer = {
|
|
5
|
+
name?: string;
|
|
6
|
+
host?: string;
|
|
7
|
+
};
|
|
8
|
+
export type BullyCluster = {
|
|
9
|
+
self: BullyPeer;
|
|
10
|
+
cluster: BullyPeer[];
|
|
11
|
+
};
|
|
12
|
+
export type GetCluster = () => Promise<BullyCluster>;
|
|
13
|
+
export type BullyOptions = {
|
|
14
|
+
getCluster: GetCluster;
|
|
15
|
+
transport: Transport;
|
|
16
|
+
coordinatorWaitMs?: number;
|
|
17
|
+
heartbeatIntervalMs?: number;
|
|
18
|
+
logger?: Logger;
|
|
19
|
+
};
|
|
20
|
+
export declare class Bully implements Disposable {
|
|
21
|
+
readonly lifecycle: EventEmitter<[never]>;
|
|
22
|
+
readonly channel: EventEmitter<[never]>;
|
|
23
|
+
private readonly getClusterFn;
|
|
24
|
+
private readonly transport;
|
|
25
|
+
private readonly coordinatorWaitMs;
|
|
26
|
+
private readonly heartbeatIntervalMs;
|
|
27
|
+
private readonly logger;
|
|
28
|
+
private selfId?;
|
|
29
|
+
private leaderId?;
|
|
30
|
+
private electionInFlight;
|
|
31
|
+
private coordinatorWaitTimeout?;
|
|
32
|
+
private heartbeatInterval?;
|
|
33
|
+
constructor(opts: BullyOptions);
|
|
34
|
+
isLeader(): boolean;
|
|
35
|
+
getStatus(): {
|
|
36
|
+
self: string | undefined;
|
|
37
|
+
leader: string | undefined;
|
|
38
|
+
isLeader: boolean;
|
|
39
|
+
};
|
|
40
|
+
getPodRoles(): Promise<{
|
|
41
|
+
name: string | undefined;
|
|
42
|
+
host: string | undefined;
|
|
43
|
+
role: string;
|
|
44
|
+
}[]>;
|
|
45
|
+
/** Returns all cluster peers except self. Side-effect: populates selfId. */
|
|
46
|
+
getPeers(): Promise<BullyPeer[]>;
|
|
47
|
+
private becomeLeader;
|
|
48
|
+
startElection(): Promise<void>;
|
|
49
|
+
onElectionMessage(fromId: string): void;
|
|
50
|
+
onCoordinatorMessage(id: string): void;
|
|
51
|
+
onMessage(event: string, payload: unknown): void;
|
|
52
|
+
start(): void;
|
|
53
|
+
stop(): void;
|
|
54
|
+
[Symbol.dispose](): void;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=bully.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bully.d.ts","sourceRoot":"","sources":["../../src/core/bully.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,MAAM,SAAS,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AACzD,MAAM,MAAM,YAAY,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,SAAS,EAAE,CAAA;CAAE,CAAC;AACrE,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;AAErD,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,EAAE,UAAU,CAAC;IACvB,SAAS,EAAE,SAAS,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qBAAa,KAAM,YAAW,UAAU;IACtC,SAAgB,SAAS,wBAAsB;IAC/C,SAAgB,OAAO,wBAAsB;IAE7C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAa;IAC1C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,OAAO,CAAC,MAAM,CAAC,CAAS;IACxB,OAAO,CAAC,QAAQ,CAAC,CAAS;IAC1B,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,sBAAsB,CAAC,CAAiB;IAChD,OAAO,CAAC,iBAAiB,CAAC,CAAiB;IAE3C,YAAY,IAAI,EAAE,YAAY,EAM7B;IAEM,QAAQ,IAAI,OAAO,CAEzB;IAEM,SAAS;QACL,IAAI;QAAe,MAAM;QAAiB,QAAQ;MAC5D;IAEY,WAAW;;;;SAOvB;IAED,4EAA4E;IAC/D,QAAQ,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAI5C;YAEa,YAAY;IAeb,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAkC1C;IAEM,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAG7C;IAEM,oBAAoB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAa5C;IAEM,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAEtD;IAEM,KAAK,IAAI,IAAI,CAuBnB;IAEM,IAAI,IAAI,IAAI,CAIlB;IAEM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAE9B;CACF"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
export class Bully {
|
|
3
|
+
lifecycle = new EventEmitter();
|
|
4
|
+
channel = new EventEmitter();
|
|
5
|
+
getClusterFn;
|
|
6
|
+
transport;
|
|
7
|
+
coordinatorWaitMs;
|
|
8
|
+
heartbeatIntervalMs;
|
|
9
|
+
logger;
|
|
10
|
+
selfId;
|
|
11
|
+
leaderId;
|
|
12
|
+
electionInFlight = false;
|
|
13
|
+
coordinatorWaitTimeout;
|
|
14
|
+
heartbeatInterval;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.getClusterFn = opts.getCluster;
|
|
17
|
+
this.transport = opts.transport;
|
|
18
|
+
this.coordinatorWaitMs = opts.coordinatorWaitMs ?? 4000;
|
|
19
|
+
this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 5000;
|
|
20
|
+
this.logger = opts.logger ?? console;
|
|
21
|
+
}
|
|
22
|
+
isLeader() {
|
|
23
|
+
return !!this.selfId && this.selfId === this.leaderId;
|
|
24
|
+
}
|
|
25
|
+
getStatus() {
|
|
26
|
+
return { self: this.selfId, leader: this.leaderId, isLeader: this.isLeader() };
|
|
27
|
+
}
|
|
28
|
+
async getPodRoles() {
|
|
29
|
+
const { cluster } = await this.getClusterFn();
|
|
30
|
+
return cluster.map((pod) => ({
|
|
31
|
+
name: pod.name,
|
|
32
|
+
host: pod.host,
|
|
33
|
+
role: pod.name === this.leaderId ? "leader" : "follower",
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
/** Returns all cluster peers except self. Side-effect: populates selfId. */
|
|
37
|
+
async getPeers() {
|
|
38
|
+
const { self, cluster } = await this.getClusterFn();
|
|
39
|
+
this.selfId = self.name;
|
|
40
|
+
return cluster.filter((pod) => pod.name !== self.name && pod.host);
|
|
41
|
+
}
|
|
42
|
+
async becomeLeader(peers) {
|
|
43
|
+
const wasLeader = this.leaderId === this.selfId;
|
|
44
|
+
this.leaderId = this.selfId;
|
|
45
|
+
if (!wasLeader)
|
|
46
|
+
this.logger.info(`Became leader (id=${this.selfId})`);
|
|
47
|
+
await Promise.all(peers.map((peer) => this.transport.postToPeer(peer.host, "/bully/coordinator", { id: this.selfId })));
|
|
48
|
+
// Emitted only after peers have been told, so an "elected" listener that immediately talks
|
|
49
|
+
// to peers (e.g. ShardManager.reconcile()) doesn't race ahead of them learning who's leader.
|
|
50
|
+
if (!wasLeader)
|
|
51
|
+
this.lifecycle.emit("elected");
|
|
52
|
+
}
|
|
53
|
+
async startElection() {
|
|
54
|
+
if (this.electionInFlight)
|
|
55
|
+
return;
|
|
56
|
+
this.electionInFlight = true;
|
|
57
|
+
if (this.coordinatorWaitTimeout)
|
|
58
|
+
clearTimeout(this.coordinatorWaitTimeout);
|
|
59
|
+
this.logger.info("Starting election");
|
|
60
|
+
try {
|
|
61
|
+
const peers = await this.getPeers();
|
|
62
|
+
const higherPeers = peers.filter((peer) => peer.name > this.selfId);
|
|
63
|
+
if (higherPeers.length === 0) {
|
|
64
|
+
await this.becomeLeader(peers);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const acked = await Promise.all(higherPeers.map((peer) => this.transport.postToPeer(peer.host, "/bully/election", { id: this.selfId })));
|
|
68
|
+
if (acked.some(Boolean)) {
|
|
69
|
+
this.logger.debug("Higher peer(s) acknowledged, waiting for coordinator announcement");
|
|
70
|
+
this.coordinatorWaitTimeout = setTimeout(() => {
|
|
71
|
+
this.logger.debug("Timed out waiting for coordinator, restarting election");
|
|
72
|
+
this.startElection();
|
|
73
|
+
}, this.coordinatorWaitMs);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
await this.becomeLeader(peers);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
this.logger.error(`Election error: ${err.message}`);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
this.electionInFlight = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
onElectionMessage(fromId) {
|
|
87
|
+
this.logger.debug(`Received election message from ${fromId}, asserting alive and starting own election`);
|
|
88
|
+
this.startElection();
|
|
89
|
+
}
|
|
90
|
+
onCoordinatorMessage(id) {
|
|
91
|
+
if (this.coordinatorWaitTimeout)
|
|
92
|
+
clearTimeout(this.coordinatorWaitTimeout);
|
|
93
|
+
const wasLeader = this.isLeader();
|
|
94
|
+
const changed = this.leaderId !== id;
|
|
95
|
+
this.leaderId = id;
|
|
96
|
+
if (changed)
|
|
97
|
+
this.logger.debug(`New leader announced: ${id}`);
|
|
98
|
+
if (wasLeader && id !== this.selfId) {
|
|
99
|
+
this.logger.info("Demoted to follower");
|
|
100
|
+
this.lifecycle.emit("demoted");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
onMessage(event, payload) {
|
|
104
|
+
this.channel.emit(event, payload);
|
|
105
|
+
}
|
|
106
|
+
start() {
|
|
107
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
108
|
+
try {
|
|
109
|
+
if (this.electionInFlight)
|
|
110
|
+
return;
|
|
111
|
+
if (this.leaderId && this.leaderId === this.selfId)
|
|
112
|
+
return;
|
|
113
|
+
if (!this.leaderId) {
|
|
114
|
+
this.startElection();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const peers = await this.getPeers();
|
|
118
|
+
const leader = peers.find((peer) => peer.name === this.leaderId);
|
|
119
|
+
if (!leader?.host || !(await this.transport.pingPeer(leader.host))) {
|
|
120
|
+
this.logger.info(`Leader ${this.leaderId} unreachable, starting election`);
|
|
121
|
+
this.leaderId = undefined;
|
|
122
|
+
this.startElection();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
this.logger.error(`Heartbeat error: ${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
}, this.heartbeatIntervalMs);
|
|
129
|
+
}
|
|
130
|
+
stop() {
|
|
131
|
+
this.logger.info("Stopping Bully Coordinator...");
|
|
132
|
+
if (this.heartbeatInterval)
|
|
133
|
+
clearInterval(this.heartbeatInterval);
|
|
134
|
+
if (this.coordinatorWaitTimeout)
|
|
135
|
+
clearTimeout(this.coordinatorWaitTimeout);
|
|
136
|
+
}
|
|
137
|
+
[Symbol.dispose]() {
|
|
138
|
+
this.stop();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Logger } from "./logger";
|
|
2
|
+
import type { Bully, BullyPeer, GetCluster } from "./bully";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
export type ForumOptions = {
|
|
5
|
+
bully: Bully;
|
|
6
|
+
transport: Transport;
|
|
7
|
+
getCluster: GetCluster;
|
|
8
|
+
fetchFn?: typeof fetch;
|
|
9
|
+
logger?: Logger;
|
|
10
|
+
};
|
|
11
|
+
export declare class Forum {
|
|
12
|
+
private readonly bully;
|
|
13
|
+
private readonly transport;
|
|
14
|
+
private readonly getClusterFn;
|
|
15
|
+
private readonly fetchFn;
|
|
16
|
+
private readonly logger;
|
|
17
|
+
constructor(opts: ForumOptions);
|
|
18
|
+
/** Leader-only: fan a custom event out to every follower, and fire it locally too. */
|
|
19
|
+
broadcast(event: string, payload?: unknown): Promise<void>;
|
|
20
|
+
/** Follower-only: send a custom event to whichever pod is currently leader. */
|
|
21
|
+
send(event: string, payload?: unknown): Promise<void>;
|
|
22
|
+
/** Leader-only: send a custom event to exactly one named peer (self included). */
|
|
23
|
+
tell(peerName: string, event: string, payload?: unknown): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Peer-to-peer: message one specific named peer directly (self included), regardless of
|
|
26
|
+
* leadership. Unlike broadcast/send/tell, this isn't leader-mediated.
|
|
27
|
+
*/
|
|
28
|
+
messagePeer(peerName: string, event: string, payload?: unknown): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Leader-only: partition work across the whole cluster (self included). `resolvePayload` is
|
|
31
|
+
* called once per participant, in a stable order, and gets back that pod's own share to send.
|
|
32
|
+
*/
|
|
33
|
+
distribute<T>(event: string, resolvePayload: (peer: BullyPeer, index: number, allPeers: BullyPeer[]) => T): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Peer-to-peer: open a live subscription to everything a specific peer emits under one
|
|
36
|
+
* channel event name (self included, via a local listener — no loopback HTTP call needed).
|
|
37
|
+
* Returns an unsubscribe function.
|
|
38
|
+
*/
|
|
39
|
+
subscribeToPeer(peerName: string, event: string, onData: (payload: unknown) => void): () => void;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=forum.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"forum.d.ts","sourceRoot":"","sources":["../../src/core/forum.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,SAAS,CAAC;IACrB,UAAU,EAAE,UAAU,CAAC;IACvB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qBAAa,KAAK;IAChB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAa;IAC1C,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAEhC,YAAY,IAAI,EAAE,YAAY,EAM7B;IAED,sFAAsF;IACzE,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAUtE;IAED,+EAA+E;IAClE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAiBjE;IAED,kFAAkF;IACrE,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAMnF;IAED;;;OAGG;IACU,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAa1F;IAED;;;OAGG;IACU,UAAU,CAAC,CAAC,EACvB,KAAK,EAAE,MAAM,EACb,cAAc,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,KAAK,CAAC,GAC3E,OAAO,CAAC,IAAI,CAAC,CAqBf;IAED;;;;OAIG;IACI,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAgEtG;CACF"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export class Forum {
|
|
2
|
+
bully;
|
|
3
|
+
transport;
|
|
4
|
+
getClusterFn;
|
|
5
|
+
fetchFn;
|
|
6
|
+
logger;
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.bully = opts.bully;
|
|
9
|
+
this.transport = opts.transport;
|
|
10
|
+
this.getClusterFn = opts.getCluster;
|
|
11
|
+
this.fetchFn = opts.fetchFn ?? this.transport.fetchFn;
|
|
12
|
+
this.logger = opts.logger ?? console;
|
|
13
|
+
}
|
|
14
|
+
/** Leader-only: fan a custom event out to every follower, and fire it locally too. */
|
|
15
|
+
async broadcast(event, payload) {
|
|
16
|
+
if (!this.bully.isLeader()) {
|
|
17
|
+
this.logger.warn(`broadcast('${event}') called while not leader, ignoring`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
this.bully.channel.emit(event, payload);
|
|
21
|
+
const peers = await this.bully.getPeers();
|
|
22
|
+
await Promise.all(peers.map((peer) => this.transport.postToPeer(peer.host, "/bully/message", { event, payload })));
|
|
23
|
+
}
|
|
24
|
+
/** Follower-only: send a custom event to whichever pod is currently leader. */
|
|
25
|
+
async send(event, payload) {
|
|
26
|
+
if (this.bully.isLeader()) {
|
|
27
|
+
this.bully.channel.emit(event, payload);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const { leader } = this.bully.getStatus();
|
|
31
|
+
if (!leader) {
|
|
32
|
+
this.logger.warn(`send('${event}') called with no known leader, dropping`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const peers = await this.bully.getPeers();
|
|
36
|
+
const leaderPeer = peers.find((peer) => peer.name === leader);
|
|
37
|
+
if (!leaderPeer?.host) {
|
|
38
|
+
this.logger.warn(`send('${event}') could not resolve leader host, dropping`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await this.transport.postToPeer(leaderPeer.host, "/bully/message", { event, payload });
|
|
42
|
+
}
|
|
43
|
+
/** Leader-only: send a custom event to exactly one named peer (self included). */
|
|
44
|
+
async tell(peerName, event, payload) {
|
|
45
|
+
if (!this.bully.isLeader()) {
|
|
46
|
+
this.logger.warn(`tell('${event}') called while not leader, ignoring`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
await this.messagePeer(peerName, event, payload);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Peer-to-peer: message one specific named peer directly (self included), regardless of
|
|
53
|
+
* leadership. Unlike broadcast/send/tell, this isn't leader-mediated.
|
|
54
|
+
*/
|
|
55
|
+
async messagePeer(peerName, event, payload) {
|
|
56
|
+
const { self } = this.bully.getStatus();
|
|
57
|
+
if (peerName === self) {
|
|
58
|
+
this.bully.channel.emit(event, payload);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const peers = await this.bully.getPeers();
|
|
62
|
+
const target = peers.find((peer) => peer.name === peerName);
|
|
63
|
+
if (!target?.host) {
|
|
64
|
+
this.logger.warn(`messagePeer('${event}') could not resolve peer '${peerName}', dropping`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await this.transport.postToPeer(target.host, "/bully/message", { event, payload });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Leader-only: partition work across the whole cluster (self included). `resolvePayload` is
|
|
71
|
+
* called once per participant, in a stable order, and gets back that pod's own share to send.
|
|
72
|
+
*/
|
|
73
|
+
async distribute(event, resolvePayload) {
|
|
74
|
+
if (!this.bully.isLeader()) {
|
|
75
|
+
this.logger.warn(`distribute('${event}') called while not leader, ignoring`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const { self, cluster } = await this.getClusterFn();
|
|
79
|
+
const allPeers = [
|
|
80
|
+
self,
|
|
81
|
+
...cluster.filter((pod) => pod.name !== self.name && pod.host),
|
|
82
|
+
].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
|
83
|
+
await Promise.all(allPeers.map((peer, index) => {
|
|
84
|
+
const payload = resolvePayload(peer, index, allPeers);
|
|
85
|
+
if (peer.name === self.name) {
|
|
86
|
+
this.bully.channel.emit(event, payload);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
return this.transport.postToPeer(peer.host, "/bully/message", { event, payload });
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Peer-to-peer: open a live subscription to everything a specific peer emits under one
|
|
94
|
+
* channel event name (self included, via a local listener — no loopback HTTP call needed).
|
|
95
|
+
* Returns an unsubscribe function.
|
|
96
|
+
*/
|
|
97
|
+
subscribeToPeer(peerName, event, onData) {
|
|
98
|
+
const { self } = this.bully.getStatus();
|
|
99
|
+
if (peerName === self) {
|
|
100
|
+
this.bully.channel.on(event, onData);
|
|
101
|
+
return () => this.bully.channel.off(event, onData);
|
|
102
|
+
}
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
let stopped = false;
|
|
105
|
+
(async () => {
|
|
106
|
+
const peers = await this.bully.getPeers();
|
|
107
|
+
const target = peers.find((peer) => peer.name === peerName);
|
|
108
|
+
if (!target?.host) {
|
|
109
|
+
this.logger.warn(`subscribeToPeer('${event}') could not resolve peer '${peerName}'`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const res = await this.fetchFn(`http://${target.host}:${this.transport.port}/channel/stream/${event}`, { signal: controller.signal });
|
|
114
|
+
if (!res.ok || !res.body) {
|
|
115
|
+
this.logger.warn(`subscribeToPeer('${event}') to '${peerName}' failed to connect`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const reader = res.body.getReader();
|
|
119
|
+
const decoder = new TextDecoder();
|
|
120
|
+
let buffer = "";
|
|
121
|
+
while (!stopped) {
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
if (done)
|
|
124
|
+
break;
|
|
125
|
+
buffer += decoder.decode(value, { stream: true });
|
|
126
|
+
let boundary;
|
|
127
|
+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
128
|
+
const frame = buffer.slice(0, boundary);
|
|
129
|
+
buffer = buffer.slice(boundary + 2);
|
|
130
|
+
const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
|
|
131
|
+
if (!dataLine)
|
|
132
|
+
continue;
|
|
133
|
+
try {
|
|
134
|
+
onData(JSON.parse(dataLine.slice(6)));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
this.logger.warn(`subscribeToPeer('${event}') received a malformed frame from '${peerName}'`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err.name !== "AbortError") {
|
|
144
|
+
this.logger.error(`subscribeToPeer('${event}') stream to '${peerName}' failed: ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
148
|
+
return () => {
|
|
149
|
+
stopped = true;
|
|
150
|
+
controller.abort();
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../../src/core/logger.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,MAAM,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC,CAAC"}
|
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type GetCluster } from "./bully";
|
|
2
|
+
import type { Logger } from "./logger";
|
|
3
|
+
export type QuorumOptions<TId = string> = {
|
|
4
|
+
getCluster: GetCluster;
|
|
5
|
+
ids: TId[];
|
|
6
|
+
fetchFn?: typeof fetch;
|
|
7
|
+
internalPort?: number;
|
|
8
|
+
internalHost?: string;
|
|
9
|
+
requestTimeoutMs?: number;
|
|
10
|
+
coordinatorWaitMs?: number;
|
|
11
|
+
heartbeatIntervalMs?: number;
|
|
12
|
+
reportTimeoutMs?: number;
|
|
13
|
+
rebalanceIntervalMs?: number;
|
|
14
|
+
expectedClusterSize?: number;
|
|
15
|
+
quorumFailureThreshold?: number;
|
|
16
|
+
onSustainedQuorumLoss?: () => void;
|
|
17
|
+
/** Injectable log sink; must implement `info`/`warn`/`error`/`debug`. Defaults to `console`. */
|
|
18
|
+
logger?: Logger;
|
|
19
|
+
};
|
|
20
|
+
export declare class Quorum<TId = string> implements Disposable {
|
|
21
|
+
private readonly transport;
|
|
22
|
+
private readonly bully;
|
|
23
|
+
private readonly forum;
|
|
24
|
+
private readonly shard;
|
|
25
|
+
constructor(opts: QuorumOptions<TId>);
|
|
26
|
+
isLeader(): boolean;
|
|
27
|
+
getStatus(): {
|
|
28
|
+
self: string | undefined;
|
|
29
|
+
leader: string | undefined;
|
|
30
|
+
isLeader: boolean;
|
|
31
|
+
};
|
|
32
|
+
getPodRoles(): Promise<{
|
|
33
|
+
name: string | undefined;
|
|
34
|
+
host: string | undefined;
|
|
35
|
+
role: string;
|
|
36
|
+
}[]>;
|
|
37
|
+
subscribe(id: TId, onEvent: (payload: unknown) => void): () => void;
|
|
38
|
+
publish(id: TId, payload: unknown): void;
|
|
39
|
+
getOwner(id: TId): string | undefined;
|
|
40
|
+
getOwnership(): Map<TId, string>;
|
|
41
|
+
getHeldIds(): TId[];
|
|
42
|
+
updateIds(ids: TId[]): Promise<void>;
|
|
43
|
+
start(): void;
|
|
44
|
+
stop(): void;
|
|
45
|
+
[Symbol.dispose](): void;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=quorum.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"quorum.d.ts","sourceRoot":"","sources":["../../src/core/quorum.ts"],"names":[],"mappings":"AACA,OAAO,EAAS,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAGjD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC,MAAM,MAAM,aAAa,CAAC,GAAG,GAAG,MAAM,IAAI;IACxC,UAAU,EAAE,UAAU,CAAC;IACvB,GAAG,EAAE,GAAG,EAAE,CAAC;IACX,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,qBAAqB,CAAC,EAAE,MAAM,IAAI,CAAC;IACnC,gGAAgG;IAChG,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qBAAa,MAAM,CAAC,GAAG,GAAG,MAAM,CAAE,YAAW,UAAU;IACrD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAoB;IAE1C,YAAY,IAAI,EAAE,aAAa,CAAC,GAAG,CAAC,EAiCnC;IAEM,QAAQ,IAAI,OAAO,CAAkC;IACrD,SAAS;;;;MAAqC;IACxC,WAAW;;;;SAAuC;IAExD,SAAS,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAEzE;IACM,OAAO,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAqC;IAC7E,QAAQ,CAAC,EAAE,EAAE,GAAG,GAAG,MAAM,GAAG,SAAS,CAAoC;IACzE,YAAY,IAAI,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAsC;IACtE,UAAU,IAAI,GAAG,EAAE,CAAoC;IACjD,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAsC;IAEhF,KAAK,IAAI,IAAI,CAUnB;IAEM,IAAI,IAAI,IAAI,CAIlB;IAEM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAE9B;CACF"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Transport } from "./transport";
|
|
2
|
+
import { Bully } from "./bully";
|
|
3
|
+
import { Forum } from "./forum";
|
|
4
|
+
import { ShardManager } from "./sharding";
|
|
5
|
+
export class Quorum {
|
|
6
|
+
transport;
|
|
7
|
+
bully;
|
|
8
|
+
forum;
|
|
9
|
+
shard;
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
this.transport = new Transport({
|
|
12
|
+
fetchFn: opts.fetchFn,
|
|
13
|
+
internalPort: opts.internalPort,
|
|
14
|
+
internalHost: opts.internalHost,
|
|
15
|
+
requestTimeoutMs: opts.requestTimeoutMs,
|
|
16
|
+
logger: opts.logger,
|
|
17
|
+
});
|
|
18
|
+
this.bully = new Bully({
|
|
19
|
+
getCluster: opts.getCluster,
|
|
20
|
+
transport: this.transport,
|
|
21
|
+
coordinatorWaitMs: opts.coordinatorWaitMs,
|
|
22
|
+
heartbeatIntervalMs: opts.heartbeatIntervalMs,
|
|
23
|
+
logger: opts.logger,
|
|
24
|
+
});
|
|
25
|
+
this.forum = new Forum({
|
|
26
|
+
bully: this.bully,
|
|
27
|
+
transport: this.transport,
|
|
28
|
+
getCluster: opts.getCluster,
|
|
29
|
+
fetchFn: opts.fetchFn,
|
|
30
|
+
logger: opts.logger,
|
|
31
|
+
});
|
|
32
|
+
this.shard = new ShardManager({
|
|
33
|
+
bully: this.bully,
|
|
34
|
+
forum: this.forum,
|
|
35
|
+
ids: opts.ids,
|
|
36
|
+
reportTimeoutMs: opts.reportTimeoutMs,
|
|
37
|
+
rebalanceIntervalMs: opts.rebalanceIntervalMs,
|
|
38
|
+
expectedClusterSize: opts.expectedClusterSize,
|
|
39
|
+
quorumFailureThreshold: opts.quorumFailureThreshold,
|
|
40
|
+
onSustainedQuorumLoss: opts.onSustainedQuorumLoss,
|
|
41
|
+
logger: opts.logger,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
isLeader() { return this.bully.isLeader(); }
|
|
45
|
+
getStatus() { return this.bully.getStatus(); }
|
|
46
|
+
async getPodRoles() { return this.bully.getPodRoles(); }
|
|
47
|
+
subscribe(id, onEvent) {
|
|
48
|
+
return this.shard.subscribe(id, onEvent);
|
|
49
|
+
}
|
|
50
|
+
publish(id, payload) { this.shard.publish(id, payload); }
|
|
51
|
+
getOwner(id) { return this.shard.getOwner(id); }
|
|
52
|
+
getOwnership() { return this.shard.getOwnership(); }
|
|
53
|
+
getHeldIds() { return this.shard.getHeldIds(); }
|
|
54
|
+
async updateIds(ids) { return this.shard.updateIds(ids); }
|
|
55
|
+
start() {
|
|
56
|
+
this.transport.start({
|
|
57
|
+
onElectionMessage: (id) => this.bully.onElectionMessage(id),
|
|
58
|
+
onCoordinatorMessage: (id) => this.bully.onCoordinatorMessage(id),
|
|
59
|
+
onMessage: (event, payload) => this.bully.onMessage(event, payload),
|
|
60
|
+
channel: this.bully.channel,
|
|
61
|
+
});
|
|
62
|
+
this.shard.start();
|
|
63
|
+
this.bully.start();
|
|
64
|
+
this.bully.startElection();
|
|
65
|
+
}
|
|
66
|
+
stop() {
|
|
67
|
+
this.shard.stop();
|
|
68
|
+
this.bully.stop();
|
|
69
|
+
this.transport.stop();
|
|
70
|
+
}
|
|
71
|
+
[Symbol.dispose]() {
|
|
72
|
+
this.stop();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Bully } from "./bully";
|
|
2
|
+
import type { Forum } from "./forum";
|
|
3
|
+
import type { Logger } from "./logger";
|
|
4
|
+
export type ShardManagerOptions<TId> = {
|
|
5
|
+
bully: Bully;
|
|
6
|
+
forum: Forum;
|
|
7
|
+
ids: TId[];
|
|
8
|
+
reportTimeoutMs?: number;
|
|
9
|
+
rebalanceIntervalMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* The cluster's intended total pod count (e.g. the Deployment's `replicas`), used as a
|
|
12
|
+
* quorum guard: reconcile() refuses to act unless more than half of this count actually
|
|
13
|
+
* reported in. Left unset, quorum checking is disabled entirely — safe default for tests
|
|
14
|
+
* and small ad-hoc clusters that don't care about split-brain protection.
|
|
15
|
+
*/
|
|
16
|
+
expectedClusterSize?: number;
|
|
17
|
+
/**
|
|
18
|
+
* How many *consecutive* quorum failures to tolerate before treating this pod as sustainably
|
|
19
|
+
* isolated (as opposed to one slow reconcile pass on an otherwise-healthy network) and
|
|
20
|
+
* invoking onSustainedQuorumLoss. Only relevant when expectedClusterSize is set.
|
|
21
|
+
*/
|
|
22
|
+
quorumFailureThreshold?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Called once quorumFailureThreshold consecutive quorum failures are hit. Defaults to
|
|
25
|
+
* `process.exit(1)` — fail loudly and let Kubernetes restart the pod, rather than keep
|
|
26
|
+
* serving increasingly stale data while believing it's still leader. Injectable so tests
|
|
27
|
+
* (and anyone who wants different behavior) don't have to actually kill the process.
|
|
28
|
+
*/
|
|
29
|
+
onSustainedQuorumLoss?: () => void;
|
|
30
|
+
logger?: Logger;
|
|
31
|
+
};
|
|
32
|
+
export declare class ShardManager<TId = string> implements Disposable {
|
|
33
|
+
private bully;
|
|
34
|
+
private forum;
|
|
35
|
+
private allIds;
|
|
36
|
+
private reportTimeoutMs;
|
|
37
|
+
private rebalanceIntervalMs;
|
|
38
|
+
private expectedClusterSize?;
|
|
39
|
+
private quorumFailureThreshold;
|
|
40
|
+
private sustainedQuorumLossCallback;
|
|
41
|
+
private logger;
|
|
42
|
+
private consecutiveQuorumFailures;
|
|
43
|
+
/** Ensures sustainedQuorumLossCallback fires once per episode, not on every failure past threshold. */
|
|
44
|
+
private hasTriggeredSustainedLoss;
|
|
45
|
+
private heldIds;
|
|
46
|
+
private ownership;
|
|
47
|
+
private rebalanceInterval?;
|
|
48
|
+
/** Local-only: fires `<id>` whenever getOwner(id) changes, to drive subscribe() reconnects. */
|
|
49
|
+
private ownershipEvents;
|
|
50
|
+
/** Safety net: any subscribe() the caller never explicitly closed gets torn down in stop(). */
|
|
51
|
+
private activeSubscriptions;
|
|
52
|
+
constructor(opts: ShardManagerOptions<TId>);
|
|
53
|
+
getHeldIds(): TId[];
|
|
54
|
+
/** Local, instant lookup — no network round-trip — of which peer currently owns an id. */
|
|
55
|
+
getOwner(id: TId): string | undefined;
|
|
56
|
+
/** Local, cluster-wide view of every id's current owner — every peer as of the last reconcile. */
|
|
57
|
+
getOwnership(): Map<TId, string>;
|
|
58
|
+
/**
|
|
59
|
+
* Live-subscribe to events published for `id`, wherever it currently lives. Returns an
|
|
60
|
+
* unsubscribe function. Reconnects automatically if ownership of this specific id changes
|
|
61
|
+
* while the subscription is active, driven by the same ownership map reconcile() already
|
|
62
|
+
* keeps fresh — no message from the old owner required, no subscriber bookkeeping on the
|
|
63
|
+
* owner's side at all. Under the hood this is just an SSE connection (via
|
|
64
|
+
* bully.subscribeToPeer); closing it is the entire unsubscribe.
|
|
65
|
+
*/
|
|
66
|
+
subscribe(id: TId, onEvent: (payload: unknown) => void): () => void;
|
|
67
|
+
/** Called by the app whenever it has a new event for one of the ids this pod owns. */
|
|
68
|
+
publish(id: TId, payload: unknown): void;
|
|
69
|
+
private onAssign;
|
|
70
|
+
private onRelease;
|
|
71
|
+
private onReportRequest;
|
|
72
|
+
private onUpdateIds;
|
|
73
|
+
private onOwnershipMap;
|
|
74
|
+
private onElected;
|
|
75
|
+
private onDemoted;
|
|
76
|
+
start(): void;
|
|
77
|
+
stop(): void;
|
|
78
|
+
[Symbol.dispose](): void;
|
|
79
|
+
/**
|
|
80
|
+
* Leader-only: change the full set of ids that should be distributed across the cluster —
|
|
81
|
+
* e.g. after re-querying the database this bundle came from and finding it grew or shrank.
|
|
82
|
+
* Broadcasts the new set to every peer (so whichever pod is leader next already has it) and
|
|
83
|
+
* immediately reconciles, rather than waiting for the next periodic tick.
|
|
84
|
+
*/
|
|
85
|
+
updateIds(ids: TId[]): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Gathers what every reachable peer currently holds, computes the fair target split of all
|
|
88
|
+
* ids across those peers, broadcasts the full id→peer map so any pod can look up an owner
|
|
89
|
+
* locally via getOwner() without asking around, and sends each peer exactly the
|
|
90
|
+
* assign/release messages needed to converge on that target.
|
|
91
|
+
*/
|
|
92
|
+
reconcile(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=sharding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sharding.d.ts","sourceRoot":"","sources":["../../src/core/sharding.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC,MAAM,MAAM,mBAAmB,CAAC,GAAG,IAAI;IACrC,KAAK,EAAE,KAAK,CAAC;IACb,KAAK,EAAE,KAAK,CAAC;IACb,GAAG,EAAE,GAAG,EAAE,CAAC;IACX,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC;;;;;OAKG;IACH,qBAAqB,CAAC,EAAE,MAAM,IAAI,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAKF,qBAAa,YAAY,CAAC,GAAG,GAAG,MAAM,CAAE,YAAW,UAAU;IAC3D,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,KAAK,CAAQ;IACrB,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,mBAAmB,CAAS;IACpC,OAAO,CAAC,mBAAmB,CAAC,CAAS;IACrC,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,2BAA2B,CAAa;IAChD,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,yBAAyB,CAAK;IACtC,uGAAuG;IACvG,OAAO,CAAC,yBAAyB,CAAS;IAC1C,OAAO,CAAC,OAAO,CAAkB;IACjC,OAAO,CAAC,SAAS,CAA0B;IAC3C,OAAO,CAAC,iBAAiB,CAAC,CAAiB;IAC3C,+FAA+F;IAC/F,OAAO,CAAC,eAAe,CAAsB;IAC7C,+FAA+F;IAC/F,OAAO,CAAC,mBAAmB,CAAyB;IAEpD,YAAY,IAAI,EAAE,mBAAmB,CAAC,GAAG,CAAC,EAUzC;IAEM,UAAU,UAEhB;IAED,0FAA0F;IACnF,QAAQ,CAAC,EAAE,EAAE,GAAG,sBAEtB;IAED,kGAAkG;IAC3F,YAAY,qBAElB;IAED;;;;;;;OAOG;IACI,SAAS,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CA+BzE;IAED,sFAAsF;IAC/E,OAAO,CAAC,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,QAMvC;IAED,OAAO,CAAC,QAAQ,CAGd;IAEF,OAAO,CAAC,SAAS,CAGf;IAEF,OAAO,CAAC,eAAe,CAIrB;IAEF,OAAO,CAAC,WAAW,CAEjB;IAEF,OAAO,CAAC,cAAc,CAUpB;IAEF,OAAO,CAAC,SAAS,CAGf;IAEF,OAAO,CAAC,SAAS,CAKf;IAEK,KAAK,SAQX;IAEM,IAAI,SAWV;IAEM,CAAC,MAAM,CAAC,OAAO,CAAC,SAEtB;IAED;;;;;OAKG;IACU,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,iBAQhC;IAED;;;;;OAKG;IACU,SAAS,kBA0FrB;CACF"}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
export class ShardManager {
|
|
3
|
+
bully;
|
|
4
|
+
forum;
|
|
5
|
+
allIds;
|
|
6
|
+
reportTimeoutMs;
|
|
7
|
+
rebalanceIntervalMs;
|
|
8
|
+
expectedClusterSize;
|
|
9
|
+
quorumFailureThreshold;
|
|
10
|
+
sustainedQuorumLossCallback;
|
|
11
|
+
logger;
|
|
12
|
+
consecutiveQuorumFailures = 0;
|
|
13
|
+
/** Ensures sustainedQuorumLossCallback fires once per episode, not on every failure past threshold. */
|
|
14
|
+
hasTriggeredSustainedLoss = false;
|
|
15
|
+
heldIds = new Set();
|
|
16
|
+
ownership = new Map();
|
|
17
|
+
rebalanceInterval;
|
|
18
|
+
/** Local-only: fires `<id>` whenever getOwner(id) changes, to drive subscribe() reconnects. */
|
|
19
|
+
ownershipEvents = new EventEmitter();
|
|
20
|
+
/** Safety net: any subscribe() the caller never explicitly closed gets torn down in stop(). */
|
|
21
|
+
activeSubscriptions = new Set();
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
this.bully = opts.bully;
|
|
24
|
+
this.forum = opts.forum;
|
|
25
|
+
this.allIds = opts.ids;
|
|
26
|
+
this.reportTimeoutMs = opts.reportTimeoutMs ?? 2000;
|
|
27
|
+
this.rebalanceIntervalMs = opts.rebalanceIntervalMs ?? 10000;
|
|
28
|
+
this.expectedClusterSize = opts.expectedClusterSize;
|
|
29
|
+
this.quorumFailureThreshold = opts.quorumFailureThreshold ?? 3;
|
|
30
|
+
this.sustainedQuorumLossCallback = opts.onSustainedQuorumLoss ?? (() => process.exit(1));
|
|
31
|
+
this.logger = opts.logger ?? console;
|
|
32
|
+
}
|
|
33
|
+
getHeldIds() {
|
|
34
|
+
return [...this.heldIds];
|
|
35
|
+
}
|
|
36
|
+
/** Local, instant lookup — no network round-trip — of which peer currently owns an id. */
|
|
37
|
+
getOwner(id) {
|
|
38
|
+
return this.ownership.get(id);
|
|
39
|
+
}
|
|
40
|
+
/** Local, cluster-wide view of every id's current owner — every peer as of the last reconcile. */
|
|
41
|
+
getOwnership() {
|
|
42
|
+
return new Map(this.ownership);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Live-subscribe to events published for `id`, wherever it currently lives. Returns an
|
|
46
|
+
* unsubscribe function. Reconnects automatically if ownership of this specific id changes
|
|
47
|
+
* while the subscription is active, driven by the same ownership map reconcile() already
|
|
48
|
+
* keeps fresh — no message from the old owner required, no subscriber bookkeeping on the
|
|
49
|
+
* owner's side at all. Under the hood this is just an SSE connection (via
|
|
50
|
+
* bully.subscribeToPeer); closing it is the entire unsubscribe.
|
|
51
|
+
*/
|
|
52
|
+
subscribe(id, onEvent) {
|
|
53
|
+
let close;
|
|
54
|
+
const connect = () => {
|
|
55
|
+
const owner = this.getOwner(id);
|
|
56
|
+
if (!owner) {
|
|
57
|
+
this.logger.warn(`subscribe(${id}) could not resolve an owner`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
close = this.forum.subscribeToPeer(owner, "connection-event", (event) => {
|
|
61
|
+
const { id: eventId, payload } = event;
|
|
62
|
+
if (eventId === id)
|
|
63
|
+
onEvent(payload);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
const onOwnerChanged = () => {
|
|
67
|
+
close?.();
|
|
68
|
+
connect();
|
|
69
|
+
};
|
|
70
|
+
connect();
|
|
71
|
+
this.ownershipEvents.on(String(id), onOwnerChanged);
|
|
72
|
+
const unsubscribe = () => {
|
|
73
|
+
this.ownershipEvents.off(String(id), onOwnerChanged);
|
|
74
|
+
close?.();
|
|
75
|
+
this.activeSubscriptions.delete(unsubscribe);
|
|
76
|
+
};
|
|
77
|
+
this.activeSubscriptions.add(unsubscribe);
|
|
78
|
+
return unsubscribe;
|
|
79
|
+
}
|
|
80
|
+
/** Called by the app whenever it has a new event for one of the ids this pod owns. */
|
|
81
|
+
publish(id, payload) {
|
|
82
|
+
if (!this.heldIds.has(id)) {
|
|
83
|
+
this.logger.warn(`publish(${id}) called but this pod doesn't own that id, dropping`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.bully.channel.emit("connection-event", { id, payload });
|
|
87
|
+
}
|
|
88
|
+
onAssign = (ids) => {
|
|
89
|
+
for (const id of ids)
|
|
90
|
+
this.heldIds.add(id);
|
|
91
|
+
this.logger.debug(`Now holding ${this.heldIds.size} id(s)`);
|
|
92
|
+
};
|
|
93
|
+
onRelease = (ids) => {
|
|
94
|
+
for (const id of ids)
|
|
95
|
+
this.heldIds.delete(id);
|
|
96
|
+
this.logger.debug(`Released ${ids.length} id(s), now holding ${this.heldIds.size}`);
|
|
97
|
+
};
|
|
98
|
+
onReportRequest = () => {
|
|
99
|
+
const { self } = this.bully.getStatus();
|
|
100
|
+
if (!self)
|
|
101
|
+
return;
|
|
102
|
+
this.forum.send("holdings-report", { peer: self, ids: this.getHeldIds() });
|
|
103
|
+
};
|
|
104
|
+
onUpdateIds = (ids) => {
|
|
105
|
+
this.allIds = ids;
|
|
106
|
+
};
|
|
107
|
+
onOwnershipMap = (entries) => {
|
|
108
|
+
const next = new Map(entries);
|
|
109
|
+
const previous = this.ownership;
|
|
110
|
+
// Swapped in before emitting: subscribe()'s reconnect handler calls getOwner() synchronously
|
|
111
|
+
// in reaction to this event, so it must already see the new value, not the one it's replacing.
|
|
112
|
+
this.ownership = next;
|
|
113
|
+
for (const [id, peer] of next) {
|
|
114
|
+
if (previous.get(id) !== peer)
|
|
115
|
+
this.ownershipEvents.emit(String(id), peer);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
onElected = () => {
|
|
119
|
+
this.reconcile();
|
|
120
|
+
this.rebalanceInterval = setInterval(() => this.reconcile(), this.rebalanceIntervalMs);
|
|
121
|
+
};
|
|
122
|
+
onDemoted = () => {
|
|
123
|
+
if (this.rebalanceInterval)
|
|
124
|
+
clearInterval(this.rebalanceInterval);
|
|
125
|
+
this.rebalanceInterval = undefined;
|
|
126
|
+
this.consecutiveQuorumFailures = 0;
|
|
127
|
+
this.hasTriggeredSustainedLoss = false;
|
|
128
|
+
};
|
|
129
|
+
start() {
|
|
130
|
+
this.bully.channel.on("assign-connections", this.onAssign);
|
|
131
|
+
this.bully.channel.on("release-connections", this.onRelease);
|
|
132
|
+
this.bully.channel.on("report-holdings-request", this.onReportRequest);
|
|
133
|
+
this.bully.channel.on("update-ids", this.onUpdateIds);
|
|
134
|
+
this.bully.channel.on("ownership-map", this.onOwnershipMap);
|
|
135
|
+
this.bully.lifecycle.on("elected", this.onElected);
|
|
136
|
+
this.bully.lifecycle.on("demoted", this.onDemoted);
|
|
137
|
+
}
|
|
138
|
+
stop() {
|
|
139
|
+
this.bully.channel.off("assign-connections", this.onAssign);
|
|
140
|
+
this.bully.channel.off("release-connections", this.onRelease);
|
|
141
|
+
this.bully.channel.off("report-holdings-request", this.onReportRequest);
|
|
142
|
+
this.bully.channel.off("update-ids", this.onUpdateIds);
|
|
143
|
+
this.bully.channel.off("ownership-map", this.onOwnershipMap);
|
|
144
|
+
this.bully.lifecycle.off("elected", this.onElected);
|
|
145
|
+
this.bully.lifecycle.off("demoted", this.onDemoted);
|
|
146
|
+
this.onDemoted();
|
|
147
|
+
for (const unsubscribe of [...this.activeSubscriptions])
|
|
148
|
+
unsubscribe();
|
|
149
|
+
}
|
|
150
|
+
[Symbol.dispose]() {
|
|
151
|
+
this.stop();
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Leader-only: change the full set of ids that should be distributed across the cluster —
|
|
155
|
+
* e.g. after re-querying the database this bundle came from and finding it grew or shrank.
|
|
156
|
+
* Broadcasts the new set to every peer (so whichever pod is leader next already has it) and
|
|
157
|
+
* immediately reconciles, rather than waiting for the next periodic tick.
|
|
158
|
+
*/
|
|
159
|
+
async updateIds(ids) {
|
|
160
|
+
if (!this.bully.isLeader()) {
|
|
161
|
+
this.logger.warn("updateIds() called while not leader, ignoring");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await this.forum.broadcast("update-ids", ids);
|
|
165
|
+
await this.reconcile();
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Gathers what every reachable peer currently holds, computes the fair target split of all
|
|
169
|
+
* ids across those peers, broadcasts the full id→peer map so any pod can look up an owner
|
|
170
|
+
* locally via getOwner() without asking around, and sends each peer exactly the
|
|
171
|
+
* assign/release messages needed to converge on that target.
|
|
172
|
+
*/
|
|
173
|
+
async reconcile() {
|
|
174
|
+
const reports = new Map();
|
|
175
|
+
const { self } = this.bully.getStatus();
|
|
176
|
+
if (self)
|
|
177
|
+
reports.set(self, this.getHeldIds());
|
|
178
|
+
const collector = (payload) => {
|
|
179
|
+
reports.set(payload.peer, payload.ids);
|
|
180
|
+
};
|
|
181
|
+
this.bully.channel.on("holdings-report", collector);
|
|
182
|
+
await this.forum.broadcast("report-holdings-request");
|
|
183
|
+
await new Promise((resolve) => setTimeout(resolve, this.reportTimeoutMs));
|
|
184
|
+
this.bully.channel.off("holdings-report", collector);
|
|
185
|
+
const reporters = [...reports.keys()].sort();
|
|
186
|
+
if (reporters.length === 0) {
|
|
187
|
+
this.logger.warn("Reconciled: no peer reported in, nothing we can do");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (this.expectedClusterSize) {
|
|
191
|
+
const majority = Math.floor(this.expectedClusterSize / 2) + 1;
|
|
192
|
+
if (reporters.length < majority) {
|
|
193
|
+
this.consecutiveQuorumFailures++;
|
|
194
|
+
this.logger.warn(`Reconciled: only ${reporters.length}/${this.expectedClusterSize} peer(s) reachable ` +
|
|
195
|
+
`(need ${majority} for quorum), refusing to reconcile ` +
|
|
196
|
+
`[${this.consecutiveQuorumFailures}/${this.quorumFailureThreshold} consecutive failures]`);
|
|
197
|
+
if (this.consecutiveQuorumFailures >= this.quorumFailureThreshold && !this.hasTriggeredSustainedLoss) {
|
|
198
|
+
this.hasTriggeredSustainedLoss = true;
|
|
199
|
+
this.logger.error(`Sustained quorum loss after ${this.consecutiveQuorumFailures} consecutive attempts, ` +
|
|
200
|
+
`invoking onSustainedQuorumLoss`);
|
|
201
|
+
this.sustainedQuorumLossCallback();
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this.consecutiveQuorumFailures = 0;
|
|
207
|
+
this.hasTriggeredSustainedLoss = false;
|
|
208
|
+
const fairShare = Math.floor(this.allIds.length / reporters.length);
|
|
209
|
+
const remainder = this.allIds.length % reporters.length;
|
|
210
|
+
let cursor = 0;
|
|
211
|
+
const targets = new Map();
|
|
212
|
+
reporters.forEach((peer, index) => {
|
|
213
|
+
const count = fairShare + (index < remainder ? 1 : 0);
|
|
214
|
+
targets.set(peer, new Set(this.allIds.slice(cursor, cursor + count)));
|
|
215
|
+
cursor += count;
|
|
216
|
+
});
|
|
217
|
+
// Broadcast unconditionally, even on a pass where nothing moves — a peer's cached map is
|
|
218
|
+
// otherwise only as fresh as the last pass that happened to change something.
|
|
219
|
+
const ownershipEntries = [];
|
|
220
|
+
for (const [peer, ids] of targets)
|
|
221
|
+
for (const id of ids)
|
|
222
|
+
ownershipEntries.push([id, peer]);
|
|
223
|
+
await this.forum.broadcast("ownership-map", ownershipEntries);
|
|
224
|
+
const assignments = new Map();
|
|
225
|
+
const releases = new Map();
|
|
226
|
+
for (const peer of reporters) {
|
|
227
|
+
const current = new Set(reports.get(peer));
|
|
228
|
+
const target = targets.get(peer);
|
|
229
|
+
const toAdd = [...target].filter((id) => !current.has(id));
|
|
230
|
+
const toRemove = [...current].filter((id) => !target.has(id));
|
|
231
|
+
if (toAdd.length > 0)
|
|
232
|
+
assignments.set(peer, toAdd);
|
|
233
|
+
if (toRemove.length > 0)
|
|
234
|
+
releases.set(peer, toRemove);
|
|
235
|
+
}
|
|
236
|
+
if (assignments.size === 0 && releases.size === 0) {
|
|
237
|
+
this.logger.debug("Reconciled: already balanced, nothing to do");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.logger.info(`Reconciled: converging ${reporters.length} peer(s) on a fair split ` +
|
|
241
|
+
`(${assignments.size} gaining ids, ${releases.size} releasing ids)`);
|
|
242
|
+
await Promise.all([
|
|
243
|
+
...[...assignments.entries()].map(([peer, ids]) => this.forum.tell(peer, "assign-connections", ids)),
|
|
244
|
+
...[...releases.entries()].map(([peer, ids]) => this.forum.tell(peer, "release-connections", ids)),
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import type { Logger } from "./logger";
|
|
3
|
+
export type TransportOptions = {
|
|
4
|
+
fetchFn?: typeof fetch;
|
|
5
|
+
internalPort?: number;
|
|
6
|
+
internalHost?: string;
|
|
7
|
+
requestTimeoutMs?: number;
|
|
8
|
+
logger?: Logger;
|
|
9
|
+
};
|
|
10
|
+
export type TransportCallbacks = {
|
|
11
|
+
onElectionMessage: (fromId: string) => void;
|
|
12
|
+
onCoordinatorMessage: (id: string) => void;
|
|
13
|
+
onMessage: (event: string, payload: unknown) => void;
|
|
14
|
+
channel: EventEmitter;
|
|
15
|
+
};
|
|
16
|
+
export declare class Transport {
|
|
17
|
+
readonly fetchFn: typeof fetch;
|
|
18
|
+
readonly port: number;
|
|
19
|
+
private readonly internalHost?;
|
|
20
|
+
private readonly requestTimeoutMs;
|
|
21
|
+
private readonly logger;
|
|
22
|
+
private server?;
|
|
23
|
+
constructor(opts?: TransportOptions);
|
|
24
|
+
postToPeer(host: string, path: string, body: unknown): Promise<boolean>;
|
|
25
|
+
pingPeer(host: string): Promise<boolean>;
|
|
26
|
+
start(cb: TransportCallbacks): void;
|
|
27
|
+
stop(): void;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/core/transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAGtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC,MAAM,MAAM,gBAAgB,GAAG;IAC7B,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,iBAAiB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,oBAAoB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,SAAS,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IACrD,OAAO,EAAE,YAAY,CAAC;CACvB,CAAC;AAEF,qBAAa,SAAS;IACpB,SAAgB,OAAO,EAAE,OAAO,KAAK,CAAC;IACtC,SAAgB,IAAI,EAAE,MAAM,CAAC;IAC7B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAC1C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,MAAM,CAAC,CAAS;IAExB,YAAY,IAAI,GAAE,gBAAqB,EAMtC;IAEY,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAYnF;IAEY,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CASpD;IAEM,KAAK,CAAC,EAAE,EAAE,kBAAkB,GAAG,IAAI,CA4CzC;IAEM,IAAI,IAAI,IAAI,CAElB;CACF"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
export class Transport {
|
|
3
|
+
fetchFn;
|
|
4
|
+
port;
|
|
5
|
+
internalHost;
|
|
6
|
+
requestTimeoutMs;
|
|
7
|
+
logger;
|
|
8
|
+
server;
|
|
9
|
+
constructor(opts = {}) {
|
|
10
|
+
this.fetchFn = opts.fetchFn ?? fetch;
|
|
11
|
+
this.port = opts.internalPort ?? Number(process.env.INTERNAL_PORT || 4001);
|
|
12
|
+
this.internalHost = opts.internalHost;
|
|
13
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? 2000;
|
|
14
|
+
this.logger = opts.logger ?? console;
|
|
15
|
+
}
|
|
16
|
+
async postToPeer(host, path, body) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await this.fetchFn(`http://${host}:${this.port}${path}`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "content-type": "application/json" },
|
|
21
|
+
body: JSON.stringify(body),
|
|
22
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
23
|
+
});
|
|
24
|
+
return res.ok;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async pingPeer(host) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await this.fetchFn(`http://${host}:${this.port}/health`, {
|
|
33
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
|
34
|
+
});
|
|
35
|
+
return res.ok;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
start(cb) {
|
|
42
|
+
const app = express();
|
|
43
|
+
app.use(express.json());
|
|
44
|
+
app.get("/health", (_, res) => {
|
|
45
|
+
res.json({ status: "OK", timestamp: new Date().toISOString() });
|
|
46
|
+
});
|
|
47
|
+
app.post("/bully/election", (req, res) => {
|
|
48
|
+
cb.onElectionMessage(req.body?.id);
|
|
49
|
+
res.sendStatus(200);
|
|
50
|
+
});
|
|
51
|
+
app.post("/bully/coordinator", (req, res) => {
|
|
52
|
+
cb.onCoordinatorMessage(req.body?.id);
|
|
53
|
+
res.sendStatus(200);
|
|
54
|
+
});
|
|
55
|
+
app.post("/bully/message", (req, res) => {
|
|
56
|
+
cb.onMessage(req.body?.event, req.body?.payload);
|
|
57
|
+
res.sendStatus(200);
|
|
58
|
+
});
|
|
59
|
+
app.get("/channel/stream/:event", (req, res) => {
|
|
60
|
+
const eventName = req.params.event;
|
|
61
|
+
res.writeHead(200, {
|
|
62
|
+
"Content-Type": "text/event-stream",
|
|
63
|
+
"Cache-Control": "no-cache",
|
|
64
|
+
Connection: "keep-alive",
|
|
65
|
+
});
|
|
66
|
+
const forward = (payload) => res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
67
|
+
cb.channel.on(eventName, forward);
|
|
68
|
+
req.on("close", () => cb.channel.off(eventName, forward));
|
|
69
|
+
});
|
|
70
|
+
app.use((err, _req, res, _next) => {
|
|
71
|
+
this.logger.error(err);
|
|
72
|
+
res.status(500).json({ status: "ERROR", error: err?.message ?? String(err) });
|
|
73
|
+
});
|
|
74
|
+
const onListening = () => this.logger.info(`Internal coordination server listening on port ${this.port}`);
|
|
75
|
+
this.server = this.internalHost
|
|
76
|
+
? app.listen(this.port, this.internalHost, onListening)
|
|
77
|
+
: app.listen(this.port, onListening);
|
|
78
|
+
}
|
|
79
|
+
stop() {
|
|
80
|
+
if (this.server)
|
|
81
|
+
this.server.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { Quorum } from "./core/quorum";
|
|
2
|
+
export type { QuorumOptions } from "./core/quorum";
|
|
3
|
+
export { Bully } from "./core/bully";
|
|
4
|
+
export type { BullyPeer, BullyCluster, GetCluster, BullyOptions } from "./core/bully";
|
|
5
|
+
export { Forum } from "./core/forum";
|
|
6
|
+
export type { ForumOptions } from "./core/forum";
|
|
7
|
+
export { Transport } from "./core/transport";
|
|
8
|
+
export type { TransportOptions, TransportCallbacks } from "./core/transport";
|
|
9
|
+
export { ShardManager } from "./core/sharding";
|
|
10
|
+
export type { ShardManagerOptions } from "./core/sharding";
|
|
11
|
+
export type { Logger } from "./core/logger";
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AACvC,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEtF,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,YAAY,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,YAAY,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE7E,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/C,YAAY,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AAE3D,YAAY,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC"}
|
package/dist/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@einaraglen/quorum",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Distributed pod coordination: Bully leader election and work sharding",
|
|
5
|
-
"keywords": [
|
|
6
|
-
"leader-election",
|
|
7
|
-
"sharding",
|
|
8
|
-
"distributed-systems"
|
|
9
|
-
],
|
|
10
|
-
"license": "ISC",
|
|
11
|
-
"repository": {
|
|
12
|
-
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/einaraglen/quorum.git"
|
|
14
|
-
},
|
|
15
|
-
"type": "module",
|
|
16
|
-
"publishConfig": {
|
|
17
|
-
"access": "public"
|
|
18
|
-
},
|
|
19
|
-
"files": [
|
|
20
|
-
"dist"
|
|
21
|
-
],
|
|
22
|
-
"main": "./dist/index.js",
|
|
23
|
-
"types": "./dist/index.d.ts",
|
|
24
|
-
"exports": {
|
|
25
|
-
".": {
|
|
26
|
-
"types": "./dist/index.d.ts",
|
|
27
|
-
"default": "./dist/index.js"
|
|
28
|
-
}
|
|
29
|
-
},
|
|
30
|
-
"scripts": {
|
|
31
|
-
"test": "tsx --test --test-reporter=spec --test-reporter-destination=stdout src/**/*.test.ts",
|
|
32
|
-
"build": "tsc"
|
|
33
|
-
},
|
|
34
|
-
"devDependencies": {
|
|
35
|
-
"@types/express": "^5.0.6",
|
|
36
|
-
"@types/node": "^24.9.2",
|
|
37
|
-
"tsx": "^4.23.12",
|
|
38
|
-
"typescript": "^7.0.2"
|
|
39
|
-
},
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"express": "^5.2.1"
|
|
42
|
-
}
|
|
43
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@einaraglen/quorum",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Distributed pod coordination: Bully leader election and work sharding",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"leader-election",
|
|
7
|
+
"sharding",
|
|
8
|
+
"distributed-systems"
|
|
9
|
+
],
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/einaraglen/quorum.git"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "tsx --test --test-reporter=spec --test-reporter-destination=stdout src/**/*.test.ts",
|
|
32
|
+
"build": "tsc"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/express": "^5.0.6",
|
|
36
|
+
"@types/node": "^24.9.2",
|
|
37
|
+
"tsx": "^4.23.12",
|
|
38
|
+
"typescript": "^7.0.2"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"express": "^5.2.1"
|
|
42
|
+
}
|
|
43
|
+
}
|