@einaraglen/quorum 1.0.0 → 1.0.3
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/README.md +66 -45
- 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 +152 -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 +51 -0
- package/dist/core/quorum.d.ts.map +1 -0
- package/dist/core/quorum.js +100 -0
- package/dist/core/sharding.d.ts +100 -0
- package/dist/core/sharding.d.ts.map +1 -0
- package/dist/core/sharding.js +264 -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 +49 -43
package/README.md
CHANGED
|
@@ -18,13 +18,13 @@ When a pod starts up it runs an election: it contacts every peer with a higher n
|
|
|
18
18
|
|
|
19
19
|
All coordination travels over a single internal HTTP port (default `4001`). Each pod runs a small Express server on that port with five endpoints:
|
|
20
20
|
|
|
21
|
-
| Route
|
|
22
|
-
|
|
23
|
-
| `GET /health`
|
|
24
|
-
| `POST /bully/election`
|
|
25
|
-
| `POST /bully/coordinator`
|
|
26
|
-
| `POST /bully/message`
|
|
27
|
-
| `GET /channel/stream/:event` | SSE stream for live subscriptions
|
|
21
|
+
| Route | Purpose |
|
|
22
|
+
| ---------------------------- | --------------------------------------------- |
|
|
23
|
+
| `GET /health` | Heartbeat probe |
|
|
24
|
+
| `POST /bully/election` | Incoming election challenge from a lower peer |
|
|
25
|
+
| `POST /bully/coordinator` | Incoming leader announcement |
|
|
26
|
+
| `POST /bully/message` | Incoming application-level event |
|
|
27
|
+
| `GET /channel/stream/:event` | SSE stream for live subscriptions |
|
|
28
28
|
|
|
29
29
|
The Transport class owns both sides of this: the server that listens on those routes, and the HTTP client (`postToPeer`, `pingPeer`) that sends to them.
|
|
30
30
|
|
|
@@ -32,12 +32,12 @@ The Transport class owns both sides of this: the server that listens on those ro
|
|
|
32
32
|
|
|
33
33
|
Four messaging patterns are available, each with a different routing model:
|
|
34
34
|
|
|
35
|
-
| Method
|
|
36
|
-
|
|
37
|
-
| `broadcast(event, payload)`
|
|
38
|
-
| `send(event, payload)`
|
|
39
|
-
| `tell(peer, event, payload)`
|
|
40
|
-
| `messagePeer(peer, event, payload)` | Anyone
|
|
35
|
+
| Method | Who can call | Who receives |
|
|
36
|
+
| ----------------------------------- | ------------ | ----------------------- |
|
|
37
|
+
| `broadcast(event, payload)` | Leader only | All followers + self |
|
|
38
|
+
| `send(event, payload)` | Any follower | Current leader |
|
|
39
|
+
| `tell(peer, event, payload)` | Leader only | One specific named peer |
|
|
40
|
+
| `messagePeer(peer, event, payload)` | Anyone | One specific named peer |
|
|
41
41
|
|
|
42
42
|
All four deliver to the pod's local `channel` EventEmitter when the target happens to be self, with no network round-trip.
|
|
43
43
|
|
|
@@ -45,7 +45,7 @@ All four deliver to the pod's local `channel` EventEmitter when the target happe
|
|
|
45
45
|
|
|
46
46
|
`subscribeToPeer(peer, event, onData)` opens a live SSE connection to a named peer and delivers every event it emits under that name. Returns an unsubscribe function. Targets itself locally — no loopback HTTP connection.
|
|
47
47
|
|
|
48
|
-
###
|
|
48
|
+
### Sharding — work distribution
|
|
49
49
|
|
|
50
50
|
The leader periodically reconciles who owns what. Reconciliation works in four steps:
|
|
51
51
|
|
|
@@ -60,20 +60,20 @@ Ownership changes drive subscriptions: `subscribe(id, onEvent)` listens to the c
|
|
|
60
60
|
|
|
61
61
|
### Quorum — the wrapper
|
|
62
62
|
|
|
63
|
-
`Quorum` creates and wires all
|
|
63
|
+
`Quorum` creates and wires all four layers (Transport, Bully, Forum, Sharding) from a single options object. It is the primary entry point for production use. The individual classes are exported for testing and advanced composition.
|
|
64
64
|
|
|
65
65
|
---
|
|
66
66
|
|
|
67
67
|
## Quick start
|
|
68
68
|
|
|
69
69
|
```typescript
|
|
70
|
-
import { Quorum } from "quorum";
|
|
71
|
-
import {
|
|
70
|
+
import { Quorum } from "@einaraglen/quorum";
|
|
71
|
+
import { discovery } from "./my-discovery.js"; // returns { self, cluster }
|
|
72
72
|
|
|
73
73
|
const q = new Quorum({
|
|
74
|
-
|
|
74
|
+
discovery,
|
|
75
75
|
ids: ["uuid-1", "uuid-2", "uuid-3"], // the full set of work items to distribute
|
|
76
|
-
expectedClusterSize: 4,
|
|
76
|
+
expectedClusterSize: 4, // enables quorum protection
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
q.start(); // starts the HTTP server, runs the first election, begins reconciling
|
|
@@ -89,6 +89,10 @@ q.publish("uuid-1", { reading: 42 });
|
|
|
89
89
|
// Look up who owns an id without a network call:
|
|
90
90
|
console.log(q.getOwner("uuid-1")); // "pod-name-c"
|
|
91
91
|
|
|
92
|
+
// React the instant this pod gains or loses ids — no polling getHeldIds():
|
|
93
|
+
q.onAssigned((ids) => console.log("now holding:", ids));
|
|
94
|
+
q.onReleased((ids) => console.log("no longer holding:", ids));
|
|
95
|
+
|
|
92
96
|
// Clean shutdown (or use `using q = new Quorum(...)` for automatic teardown):
|
|
93
97
|
q.stop();
|
|
94
98
|
```
|
|
@@ -97,7 +101,7 @@ q.stop();
|
|
|
97
101
|
|
|
98
102
|
```typescript
|
|
99
103
|
async function main() {
|
|
100
|
-
using q = new Quorum({
|
|
104
|
+
using q = new Quorum({ discovery, ids });
|
|
101
105
|
q.start();
|
|
102
106
|
|
|
103
107
|
await new Promise<void>((resolve) => {
|
|
@@ -114,22 +118,22 @@ async function main() {
|
|
|
114
118
|
|
|
115
119
|
### `new Quorum(opts)`
|
|
116
120
|
|
|
117
|
-
| Option
|
|
118
|
-
|
|
119
|
-
| `
|
|
120
|
-
| `ids`
|
|
121
|
-
| `fetchFn`
|
|
122
|
-
| `internalPort`
|
|
123
|
-
| `internalHost`
|
|
124
|
-
| `requestTimeoutMs`
|
|
125
|
-
| `coordinatorWaitMs`
|
|
126
|
-
| `heartbeatIntervalMs`
|
|
127
|
-
| `reportTimeoutMs`
|
|
128
|
-
| `rebalanceIntervalMs`
|
|
129
|
-
| `expectedClusterSize`
|
|
130
|
-
| `quorumFailureThreshold` | `number`
|
|
131
|
-
| `onSustainedQuorumLoss`
|
|
132
|
-
| `logger`
|
|
121
|
+
| Option | Type | Default | Description |
|
|
122
|
+
| ------------------------ | ---------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
123
|
+
| `discovery` | `() => Promise<{ self, cluster }>` | required | Peer discovery. Return this pod as `self` and the full peer list (including self) as `cluster`. |
|
|
124
|
+
| `ids` | `TId[]` | required | The complete set of work IDs to distribute. |
|
|
125
|
+
| `fetchFn` | `typeof fetch` | `globalThis.fetch` | Swap in a custom fetch for testing. |
|
|
126
|
+
| `internalPort` | `number` | `4001` or `$INTERNAL_PORT` | Port for the internal coordination server. |
|
|
127
|
+
| `internalHost` | `string` | all interfaces | Bind address for the internal server. Useful in tests running multiple real instances on one machine. |
|
|
128
|
+
| `requestTimeoutMs` | `number` | `2000` | Timeout for outgoing HTTP requests to peers. |
|
|
129
|
+
| `coordinatorWaitMs` | `number` | `4000` | How long a pod waits for a coordinator announcement before restarting the election. |
|
|
130
|
+
| `heartbeatIntervalMs` | `number` | `5000` | How often followers ping the leader to check it is still alive. |
|
|
131
|
+
| `reportTimeoutMs` | `number` | `2000` | How long the leader waits for holdings reports before reconciling with whoever responded. |
|
|
132
|
+
| `rebalanceIntervalMs` | `number` | `10000` | How often the leader runs a full reconcile. |
|
|
133
|
+
| `expectedClusterSize` | `number` | none | Target pod count for quorum checks. |
|
|
134
|
+
| `quorumFailureThreshold` | `number` | `3` | Consecutive quorum failures before `onSustainedQuorumLoss` fires. |
|
|
135
|
+
| `onSustainedQuorumLoss` | `() => void` | `process.exit(1)` | Called once when the failure threshold is reached. |
|
|
136
|
+
| `logger` | `Logger` | `console` | Log sink, must implement `info`/`warn`/`error`/`debug`. `console` satisfies this as-is. |
|
|
133
137
|
|
|
134
138
|
**Methods:**
|
|
135
139
|
|
|
@@ -146,6 +150,9 @@ q.getOwner(id): string | undefined // local lookup — no network call
|
|
|
146
150
|
q.getOwnership(): Map<TId, string> // full id→peer map as of last reconcile
|
|
147
151
|
q.getHeldIds(): TId[] // ids currently assigned to this pod
|
|
148
152
|
q.updateIds(ids): Promise<void> // leader-only: replace the full id set and reconcile
|
|
153
|
+
|
|
154
|
+
q.onAssigned(onEvent): () => void // fires with the ids just gained when held ids grow
|
|
155
|
+
q.onReleased(onEvent): () => void // fires with the ids just lost when held ids shrink
|
|
149
156
|
```
|
|
150
157
|
|
|
151
158
|
---
|
|
@@ -155,12 +162,12 @@ q.updateIds(ids): Promise<void> // leader-only: replace the full id set a
|
|
|
155
162
|
For testing or custom wiring you can create the layers individually:
|
|
156
163
|
|
|
157
164
|
```typescript
|
|
158
|
-
import { Transport, Bully, Forum,
|
|
165
|
+
import { Transport, Bully, Forum, Sharding } from "@einaraglen/quorum";
|
|
159
166
|
|
|
160
167
|
const transport = new Transport({ fetchFn: mockFetch, internalPort: 9000 });
|
|
161
|
-
const bully = new Bully({
|
|
162
|
-
const forum = new Forum({ bully, transport,
|
|
163
|
-
const shard = new
|
|
168
|
+
const bully = new Bully({ discovery, transport });
|
|
169
|
+
const forum = new Forum({ bully, transport, discovery });
|
|
170
|
+
const shard = new Sharding({ bully, forum, ids: [] });
|
|
164
171
|
|
|
165
172
|
// Wire the transport callbacks manually:
|
|
166
173
|
transport.start({
|
|
@@ -183,12 +190,15 @@ await bully.startElection();
|
|
|
183
190
|
bully.channel.emit("assign-connections", [7, 12, 44]);
|
|
184
191
|
|
|
185
192
|
// Simulate the leader publishing an updated ownership map:
|
|
186
|
-
bully.channel.emit("ownership-map", [
|
|
193
|
+
bully.channel.emit("ownership-map", [
|
|
194
|
+
[7, "pod-c"],
|
|
195
|
+
[12, "pod-a"],
|
|
196
|
+
]);
|
|
187
197
|
```
|
|
188
198
|
|
|
189
199
|
### Lifecycle events
|
|
190
200
|
|
|
191
|
-
`bully.lifecycle` emits `"elected"` when this pod becomes leader (after peers have been notified), and `"demoted"` when it steps down.
|
|
201
|
+
`bully.lifecycle` emits `"elected"` when this pod becomes leader (after peers have been notified), and `"demoted"` when it steps down. Sharding uses these internally; you can also listen directly:
|
|
192
202
|
|
|
193
203
|
```typescript
|
|
194
204
|
bully.lifecycle.on("elected", () => {
|
|
@@ -199,11 +209,22 @@ bully.lifecycle.on("demoted", () => {
|
|
|
199
209
|
});
|
|
200
210
|
```
|
|
201
211
|
|
|
212
|
+
`shard.lifecycle` similarly emits `"assigned"` and `"released"`, each with the delta of ids that just changed (not the full held set) — this is what `Quorum.onAssigned`/`onReleased` wrap:
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
shard.lifecycle.on("assigned", (ids) => {
|
|
216
|
+
console.log("now holding:", ids);
|
|
217
|
+
});
|
|
218
|
+
shard.lifecycle.on("released", (ids) => {
|
|
219
|
+
console.log("no longer holding:", ids);
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
202
223
|
---
|
|
203
224
|
|
|
204
225
|
## Peer discovery
|
|
205
226
|
|
|
206
|
-
`
|
|
227
|
+
`discovery` is the only application-specific piece. It must return:
|
|
207
228
|
|
|
208
229
|
```typescript
|
|
209
230
|
{
|
|
@@ -216,7 +237,7 @@ bully.lifecycle.on("demoted", () => {
|
|
|
216
237
|
|
|
217
238
|
```typescript
|
|
218
239
|
// In your application layer (not in quorum itself):
|
|
219
|
-
const
|
|
240
|
+
const discovery = async () => {
|
|
220
241
|
const pods = await k8s.listNamespacedPod({
|
|
221
242
|
namespace: "default",
|
|
222
243
|
labelSelector: "app=my-service",
|
|
@@ -255,5 +276,5 @@ src/
|
|
|
255
276
|
Every class (and `Quorum` itself) accepts an optional `logger` implementing `info`/`warn`/`error`/`debug` — `console` satisfies this shape as-is and is the default. Pass your own (winston, pino, a wrapper around your APM, etc.) to route quorum's election/reconcile/messaging logs wherever the rest of your app's logs go:
|
|
256
277
|
|
|
257
278
|
```typescript
|
|
258
|
-
const q = new Quorum({
|
|
279
|
+
const q = new Quorum({ discovery, ids, logger: myWinstonLogger });
|
|
259
280
|
```
|
|
@@ -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 Discovery = () => Promise<BullyCluster>;
|
|
13
|
+
export type BullyOptions = {
|
|
14
|
+
discovery: Discovery;
|
|
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 discovery;
|
|
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,SAAS,GAAG,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;AAEpD,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,EAAE,SAAS,CAAC;IACrB,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,SAAS,CAAY;IACtC,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
|
+
discovery;
|
|
6
|
+
transport;
|
|
7
|
+
coordinatorWaitMs;
|
|
8
|
+
heartbeatIntervalMs;
|
|
9
|
+
logger;
|
|
10
|
+
selfId;
|
|
11
|
+
leaderId;
|
|
12
|
+
electionInFlight = false;
|
|
13
|
+
coordinatorWaitTimeout;
|
|
14
|
+
heartbeatInterval;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.discovery = opts.discovery;
|
|
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.discovery();
|
|
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.discovery();
|
|
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. Sharding.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, Discovery } from "./bully";
|
|
3
|
+
import type { Transport } from "./transport";
|
|
4
|
+
export type ForumOptions = {
|
|
5
|
+
bully: Bully;
|
|
6
|
+
transport: Transport;
|
|
7
|
+
discovery: Discovery;
|
|
8
|
+
fetchFn?: typeof fetch;
|
|
9
|
+
logger?: Logger;
|
|
10
|
+
};
|
|
11
|
+
export declare class Forum {
|
|
12
|
+
private readonly bully;
|
|
13
|
+
private readonly transport;
|
|
14
|
+
private readonly discovery;
|
|
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,SAAS,EAAE,MAAM,SAAS,CAAC;AAC3D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,KAAK,CAAC;IACb,SAAS,EAAE,SAAS,CAAC;IACrB,SAAS,EAAE,SAAS,CAAC;IACrB,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,SAAS,CAAY;IACtC,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,CAQtE;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,CAoBf;IAED;;;;OAIG;IACI,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CA+DtG;CACF"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export class Forum {
|
|
2
|
+
bully;
|
|
3
|
+
transport;
|
|
4
|
+
discovery;
|
|
5
|
+
fetchFn;
|
|
6
|
+
logger;
|
|
7
|
+
constructor(opts) {
|
|
8
|
+
this.bully = opts.bully;
|
|
9
|
+
this.transport = opts.transport;
|
|
10
|
+
this.discovery = opts.discovery;
|
|
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.discovery();
|
|
79
|
+
const allPeers = [self, ...cluster.filter((pod) => pod.name !== self.name && pod.host)].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
|
|
80
|
+
await Promise.all(allPeers.map((peer, index) => {
|
|
81
|
+
const payload = resolvePayload(peer, index, allPeers);
|
|
82
|
+
if (peer.name === self.name) {
|
|
83
|
+
this.bully.channel.emit(event, payload);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
return this.transport.postToPeer(peer.host, "/bully/message", { event, payload });
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Peer-to-peer: open a live subscription to everything a specific peer emits under one
|
|
91
|
+
* channel event name (self included, via a local listener — no loopback HTTP call needed).
|
|
92
|
+
* Returns an unsubscribe function.
|
|
93
|
+
*/
|
|
94
|
+
subscribeToPeer(peerName, event, onData) {
|
|
95
|
+
const { self } = this.bully.getStatus();
|
|
96
|
+
if (peerName === self) {
|
|
97
|
+
this.bully.channel.on(event, onData);
|
|
98
|
+
return () => this.bully.channel.off(event, onData);
|
|
99
|
+
}
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
let stopped = false;
|
|
102
|
+
(async () => {
|
|
103
|
+
const peers = await this.bully.getPeers();
|
|
104
|
+
const target = peers.find((peer) => peer.name === peerName);
|
|
105
|
+
if (!target?.host) {
|
|
106
|
+
this.logger.warn(`subscribeToPeer('${event}') could not resolve peer '${peerName}'`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const res = await this.fetchFn(`http://${target.host}:${this.transport.port}/channel/stream/${event}`, {
|
|
111
|
+
signal: controller.signal,
|
|
112
|
+
});
|
|
113
|
+
if (!res.ok || !res.body) {
|
|
114
|
+
this.logger.warn(`subscribeToPeer('${event}') to '${peerName}' failed to connect`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const reader = res.body.getReader();
|
|
118
|
+
const decoder = new TextDecoder();
|
|
119
|
+
let buffer = "";
|
|
120
|
+
while (!stopped) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done)
|
|
123
|
+
break;
|
|
124
|
+
buffer += decoder.decode(value, { stream: true });
|
|
125
|
+
let boundary;
|
|
126
|
+
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
127
|
+
const frame = buffer.slice(0, boundary);
|
|
128
|
+
buffer = buffer.slice(boundary + 2);
|
|
129
|
+
const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
|
|
130
|
+
if (!dataLine)
|
|
131
|
+
continue;
|
|
132
|
+
try {
|
|
133
|
+
onData(JSON.parse(dataLine.slice(6)));
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
this.logger.warn(`subscribeToPeer('${event}') received a malformed frame from '${peerName}'`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err.name !== "AbortError") {
|
|
143
|
+
this.logger.error(`subscribeToPeer('${event}') stream to '${peerName}' failed: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
})();
|
|
147
|
+
return () => {
|
|
148
|
+
stopped = true;
|
|
149
|
+
controller.abort();
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -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
|