@einaraglen/quorum 1.0.2 → 1.0.4

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 CHANGED
@@ -16,15 +16,15 @@ When a pod starts up it runs an election: it contacts every peer with a higher n
16
16
 
17
17
  ### Transport — the wire
18
18
 
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:
19
+ All coordination travels over a single internal HTTP port (default `4001`). Each pod runs a small tinyhttp server on that port with five endpoints:
20
20
 
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 |
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 | 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 |
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
- ### ShardManager — work distribution
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 three layers (Transport, Bully, Forum, ShardManager) from a single options object. It is the primary entry point for production use. The individual classes are exported for testing and advanced composition.
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 { getCluster } from "./my-discovery.js"; // returns { self, cluster }
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
- getCluster,
74
+ discovery,
75
75
  ids: ["uuid-1", "uuid-2", "uuid-3"], // the full set of work items to distribute
76
- expectedClusterSize: 4, // enables quorum protection
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({ getCluster, ids });
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 | Type | Default | Description |
118
- |---|---|---|---|
119
- | `getCluster` | `() => Promise<{ self, cluster }>` | required | Peer discovery. Return this pod as `self` and the full peer list (including self) as `cluster`. |
120
- | `ids` | `TId[]` | required | The complete set of work IDs to distribute. |
121
- | `fetchFn` | `typeof fetch` | `globalThis.fetch` | Swap in a custom fetch for testing. |
122
- | `internalPort` | `number` | `4001` or `$INTERNAL_PORT` | Port for the internal coordination server. |
123
- | `internalHost` | `string` | all interfaces | Bind address for the internal server. Useful in tests running multiple real instances on one machine. |
124
- | `requestTimeoutMs` | `number` | `2000` | Timeout for outgoing HTTP requests to peers. |
125
- | `coordinatorWaitMs` | `number` | `4000` | How long a pod waits for a coordinator announcement before restarting the election. |
126
- | `heartbeatIntervalMs` | `number` | `5000` | How often followers ping the leader to check it is still alive. |
127
- | `reportTimeoutMs` | `number` | `2000` | How long the leader waits for holdings reports before reconciling with whoever responded. |
128
- | `rebalanceIntervalMs` | `number` | `10000` | How often the leader runs a full reconcile. |
129
- | `expectedClusterSize` | `number` | none | Target pod count for quorum checks. |
130
- | `quorumFailureThreshold` | `number` | `3` | Consecutive quorum failures before `onSustainedQuorumLoss` fires. |
131
- | `onSustainedQuorumLoss` | `() => void` | `process.exit(1)` | Called once when the failure threshold is reached. |
132
- | `logger` | `Logger` | `console` | Log sink, must implement `info`/`warn`/`error`/`debug`. `console` satisfies this as-is. |
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, ShardManager } from "quorum";
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({ getCluster, transport });
162
- const forum = new Forum({ bully, transport, getCluster });
163
- const shard = new ShardManager({ bully, forum, ids: [] });
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", [[7, "pod-c"], [12, "pod-a"]]);
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. ShardManager uses these internally; you can also listen directly:
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
- `getCluster` is the only application-specific piece. It must return:
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 getCluster = async () => {
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({ getCluster, ids, logger: myWinstonLogger });
279
+ const q = new Quorum({ discovery, ids, logger: myWinstonLogger });
259
280
  ```
@@ -9,9 +9,9 @@ export type BullyCluster = {
9
9
  self: BullyPeer;
10
10
  cluster: BullyPeer[];
11
11
  };
12
- export type GetCluster = () => Promise<BullyCluster>;
12
+ export type Discovery = () => Promise<BullyCluster>;
13
13
  export type BullyOptions = {
14
- getCluster: GetCluster;
14
+ discovery: Discovery;
15
15
  transport: Transport;
16
16
  coordinatorWaitMs?: number;
17
17
  heartbeatIntervalMs?: number;
@@ -20,7 +20,7 @@ export type BullyOptions = {
20
20
  export declare class Bully implements Disposable {
21
21
  readonly lifecycle: EventEmitter<[never]>;
22
22
  readonly channel: EventEmitter<[never]>;
23
- private readonly getClusterFn;
23
+ private readonly discovery;
24
24
  private readonly transport;
25
25
  private readonly coordinatorWaitMs;
26
26
  private readonly heartbeatIntervalMs;
@@ -1 +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"}
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"}
@@ -2,7 +2,7 @@ import { EventEmitter } from "events";
2
2
  export class Bully {
3
3
  lifecycle = new EventEmitter();
4
4
  channel = new EventEmitter();
5
- getClusterFn;
5
+ discovery;
6
6
  transport;
7
7
  coordinatorWaitMs;
8
8
  heartbeatIntervalMs;
@@ -13,7 +13,7 @@ export class Bully {
13
13
  coordinatorWaitTimeout;
14
14
  heartbeatInterval;
15
15
  constructor(opts) {
16
- this.getClusterFn = opts.getCluster;
16
+ this.discovery = opts.discovery;
17
17
  this.transport = opts.transport;
18
18
  this.coordinatorWaitMs = opts.coordinatorWaitMs ?? 4000;
19
19
  this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 5000;
@@ -26,7 +26,7 @@ export class Bully {
26
26
  return { self: this.selfId, leader: this.leaderId, isLeader: this.isLeader() };
27
27
  }
28
28
  async getPodRoles() {
29
- const { cluster } = await this.getClusterFn();
29
+ const { cluster } = await this.discovery();
30
30
  return cluster.map((pod) => ({
31
31
  name: pod.name,
32
32
  host: pod.host,
@@ -35,7 +35,7 @@ export class Bully {
35
35
  }
36
36
  /** Returns all cluster peers except self. Side-effect: populates selfId. */
37
37
  async getPeers() {
38
- const { self, cluster } = await this.getClusterFn();
38
+ const { self, cluster } = await this.discovery();
39
39
  this.selfId = self.name;
40
40
  return cluster.filter((pod) => pod.name !== self.name && pod.host);
41
41
  }
@@ -46,7 +46,7 @@ export class Bully {
46
46
  this.logger.info(`Became leader (id=${this.selfId})`);
47
47
  await Promise.all(peers.map((peer) => this.transport.postToPeer(peer.host, "/bully/coordinator", { id: this.selfId })));
48
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.
49
+ // to peers (e.g. Sharding.reconcile()) doesn't race ahead of them learning who's leader.
50
50
  if (!wasLeader)
51
51
  this.lifecycle.emit("elected");
52
52
  }
@@ -1,17 +1,17 @@
1
1
  import type { Logger } from "./logger";
2
- import type { Bully, BullyPeer, GetCluster } from "./bully";
2
+ import type { Bully, BullyPeer, Discovery } from "./bully";
3
3
  import type { Transport } from "./transport";
4
4
  export type ForumOptions = {
5
5
  bully: Bully;
6
6
  transport: Transport;
7
- getCluster: GetCluster;
7
+ discovery: Discovery;
8
8
  fetchFn?: typeof fetch;
9
9
  logger?: Logger;
10
10
  };
11
11
  export declare class Forum {
12
12
  private readonly bully;
13
13
  private readonly transport;
14
- private readonly getClusterFn;
14
+ private readonly discovery;
15
15
  private readonly fetchFn;
16
16
  private readonly logger;
17
17
  constructor(opts: ForumOptions);
@@ -1 +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"}
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"}
@@ -1,13 +1,13 @@
1
1
  export class Forum {
2
2
  bully;
3
3
  transport;
4
- getClusterFn;
4
+ discovery;
5
5
  fetchFn;
6
6
  logger;
7
7
  constructor(opts) {
8
8
  this.bully = opts.bully;
9
9
  this.transport = opts.transport;
10
- this.getClusterFn = opts.getCluster;
10
+ this.discovery = opts.discovery;
11
11
  this.fetchFn = opts.fetchFn ?? this.transport.fetchFn;
12
12
  this.logger = opts.logger ?? console;
13
13
  }
@@ -75,11 +75,8 @@ export class Forum {
75
75
  this.logger.warn(`distribute('${event}') called while not leader, ignoring`);
76
76
  return;
77
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 ?? ""));
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 ?? ""));
83
80
  await Promise.all(allPeers.map((peer, index) => {
84
81
  const payload = resolvePayload(peer, index, allPeers);
85
82
  if (peer.name === self.name) {
@@ -110,7 +107,9 @@ export class Forum {
110
107
  return;
111
108
  }
112
109
  try {
113
- const res = await this.fetchFn(`http://${target.host}:${this.transport.port}/channel/stream/${event}`, { signal: controller.signal });
110
+ const res = await this.fetchFn(`http://${target.host}:${this.transport.port}/channel/stream/${event}`, {
111
+ signal: controller.signal,
112
+ });
114
113
  if (!res.ok || !res.body) {
115
114
  this.logger.warn(`subscribeToPeer('${event}') to '${peerName}' failed to connect`);
116
115
  return;
@@ -1,7 +1,7 @@
1
- import { type GetCluster } from "./bully";
1
+ import { type Discovery } from "./bully";
2
2
  import type { Logger } from "./logger";
3
3
  export type QuorumOptions<TId = string> = {
4
- getCluster: GetCluster;
4
+ discovery: Discovery;
5
5
  ids: TId[];
6
6
  fetchFn?: typeof fetch;
7
7
  internalPort?: number;
@@ -40,6 +40,10 @@ export declare class Quorum<TId = string> implements Disposable {
40
40
  getOwnership(): Map<TId, string>;
41
41
  getHeldIds(): TId[];
42
42
  updateIds(ids: TId[]): Promise<void>;
43
+ /** Fires with the ids just gained whenever this pod's held ids grow. Returns an unsubscribe function. */
44
+ onAssigned(onEvent: (ids: TId[]) => void): () => void;
45
+ /** Fires with the ids just lost whenever this pod's held ids shrink. Returns an unsubscribe function. */
46
+ onReleased(onEvent: (ids: TId[]) => void): () => void;
43
47
  start(): void;
44
48
  stop(): void;
45
49
  [Symbol.dispose](): void;
@@ -1 +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"}
1
+ {"version":3,"file":"quorum.d.ts","sourceRoot":"","sources":["../../src/core/quorum.ts"],"names":[],"mappings":"AACA,OAAO,EAAS,KAAK,SAAS,EAAE,MAAM,SAAS,CAAC;AAGhD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC,MAAM,MAAM,aAAa,CAAC,GAAG,GAAG,MAAM,IAAI;IACxC,SAAS,EAAE,SAAS,CAAC;IACrB,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,CAAgB;IAEtC,YAAY,IAAI,EAAE,aAAa,CAAC,GAAG,CAAC,EAiCnC;IAEM,QAAQ,IAAI,OAAO,CAEzB;IACM,SAAS;;;;MAEf;IACY,WAAW;;;;SAEvB;IAEM,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,CAE9C;IACM,QAAQ,CAAC,EAAE,EAAE,GAAG,GAAG,MAAM,GAAG,SAAS,CAE3C;IACM,YAAY,IAAI,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAEtC;IACM,UAAU,IAAI,GAAG,EAAE,CAEzB;IACY,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAEhD;IAED,yGAAyG;IAClG,UAAU,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,MAAM,IAAI,CAG3D;IACD,yGAAyG;IAClG,UAAU,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,MAAM,IAAI,CAG3D;IAEM,KAAK,IAAI,IAAI,CAUnB;IAEM,IAAI,IAAI,IAAI,CAIlB;IAEM,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI,CAE9B;CACF"}
@@ -1,7 +1,7 @@
1
1
  import { Transport } from "./transport";
2
2
  import { Bully } from "./bully";
3
3
  import { Forum } from "./forum";
4
- import { ShardManager } from "./sharding";
4
+ import { Sharding } from "./sharding";
5
5
  export class Quorum {
6
6
  transport;
7
7
  bully;
@@ -16,7 +16,7 @@ export class Quorum {
16
16
  logger: opts.logger,
17
17
  });
18
18
  this.bully = new Bully({
19
- getCluster: opts.getCluster,
19
+ discovery: opts.discovery,
20
20
  transport: this.transport,
21
21
  coordinatorWaitMs: opts.coordinatorWaitMs,
22
22
  heartbeatIntervalMs: opts.heartbeatIntervalMs,
@@ -25,11 +25,11 @@ export class Quorum {
25
25
  this.forum = new Forum({
26
26
  bully: this.bully,
27
27
  transport: this.transport,
28
- getCluster: opts.getCluster,
28
+ discovery: opts.discovery,
29
29
  fetchFn: opts.fetchFn,
30
30
  logger: opts.logger,
31
31
  });
32
- this.shard = new ShardManager({
32
+ this.shard = new Sharding({
33
33
  bully: this.bully,
34
34
  forum: this.forum,
35
35
  ids: opts.ids,
@@ -41,17 +41,43 @@ export class Quorum {
41
41
  logger: opts.logger,
42
42
  });
43
43
  }
44
- isLeader() { return this.bully.isLeader(); }
45
- getStatus() { return this.bully.getStatus(); }
46
- async getPodRoles() { return this.bully.getPodRoles(); }
44
+ isLeader() {
45
+ return this.bully.isLeader();
46
+ }
47
+ getStatus() {
48
+ return this.bully.getStatus();
49
+ }
50
+ async getPodRoles() {
51
+ return this.bully.getPodRoles();
52
+ }
47
53
  subscribe(id, onEvent) {
48
54
  return this.shard.subscribe(id, onEvent);
49
55
  }
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); }
56
+ publish(id, payload) {
57
+ this.shard.publish(id, payload);
58
+ }
59
+ getOwner(id) {
60
+ return this.shard.getOwner(id);
61
+ }
62
+ getOwnership() {
63
+ return this.shard.getOwnership();
64
+ }
65
+ getHeldIds() {
66
+ return this.shard.getHeldIds();
67
+ }
68
+ async updateIds(ids) {
69
+ return this.shard.updateIds(ids);
70
+ }
71
+ /** Fires with the ids just gained whenever this pod's held ids grow. Returns an unsubscribe function. */
72
+ onAssigned(onEvent) {
73
+ this.shard.lifecycle.on("assigned", onEvent);
74
+ return () => this.shard.lifecycle.off("assigned", onEvent);
75
+ }
76
+ /** Fires with the ids just lost whenever this pod's held ids shrink. Returns an unsubscribe function. */
77
+ onReleased(onEvent) {
78
+ this.shard.lifecycle.on("released", onEvent);
79
+ return () => this.shard.lifecycle.off("released", onEvent);
80
+ }
55
81
  start() {
56
82
  this.transport.start({
57
83
  onElectionMessage: (id) => this.bully.onElectionMessage(id),
@@ -1,7 +1,8 @@
1
+ import { EventEmitter } from "events";
1
2
  import type { Bully } from "./bully";
2
3
  import type { Forum } from "./forum";
3
4
  import type { Logger } from "./logger";
4
- export type ShardManagerOptions<TId> = {
5
+ export type ShardingOptions<TId> = {
5
6
  bully: Bully;
6
7
  forum: Forum;
7
8
  ids: TId[];
@@ -29,7 +30,9 @@ export type ShardManagerOptions<TId> = {
29
30
  onSustainedQuorumLoss?: () => void;
30
31
  logger?: Logger;
31
32
  };
32
- export declare class ShardManager<TId = string> implements Disposable {
33
+ export declare class Sharding<TId = string> implements Disposable {
34
+ /** Emits `"assigned"` / `"released"` (each with the delta of ids, not the full held set) whenever this pod's held ids change. */
35
+ readonly lifecycle: EventEmitter<[never]>;
33
36
  private bully;
34
37
  private forum;
35
38
  private allIds;
@@ -42,6 +45,8 @@ export declare class ShardManager<TId = string> implements Disposable {
42
45
  private consecutiveQuorumFailures;
43
46
  /** Ensures sustainedQuorumLossCallback fires once per episode, not on every failure past threshold. */
44
47
  private hasTriggeredSustainedLoss;
48
+ /** Guards against a periodic rebalance tick overlapping an explicit reconcile() already in flight. */
49
+ private reconcileInFlight;
45
50
  private heldIds;
46
51
  private ownership;
47
52
  private rebalanceInterval?;
@@ -49,7 +54,7 @@ export declare class ShardManager<TId = string> implements Disposable {
49
54
  private ownershipEvents;
50
55
  /** Safety net: any subscribe() the caller never explicitly closed gets torn down in stop(). */
51
56
  private activeSubscriptions;
52
- constructor(opts: ShardManagerOptions<TId>);
57
+ constructor(opts: ShardingOptions<TId>);
53
58
  getHeldIds(): TId[];
54
59
  /** Local, instant lookup — no network round-trip — of which peer currently owns an id. */
55
60
  getOwner(id: TId): string | undefined;
@@ -90,5 +95,6 @@ export declare class ShardManager<TId = string> implements Disposable {
90
95
  * assign/release messages needed to converge on that target.
91
96
  */
92
97
  reconcile(): Promise<void>;
98
+ private doReconcile;
93
99
  }
94
100
  //# sourceMappingURL=sharding.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"sharding.d.ts","sourceRoot":"","sources":["../../src/core/sharding.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AACtC,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,eAAe,CAAC,GAAG,IAAI;IACjC,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,QAAQ,CAAC,GAAG,GAAG,MAAM,CAAE,YAAW,UAAU;IACvD,iIAAiI;IACjI,SAAgB,SAAS,wBAAsB;IAE/C,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,sGAAsG;IACtG,OAAO,CAAC,iBAAiB,CAAS;IAClC,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,eAAe,CAAC,GAAG,CAAC,EAUrC;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,CAId;IAEF,OAAO,CAAC,SAAS,CAIf;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,kBASrB;YAEa,WAAW;CA2F1B"}
@@ -1,5 +1,7 @@
1
1
  import { EventEmitter } from "events";
2
- export class ShardManager {
2
+ export class Sharding {
3
+ /** Emits `"assigned"` / `"released"` (each with the delta of ids, not the full held set) whenever this pod's held ids change. */
4
+ lifecycle = new EventEmitter();
3
5
  bully;
4
6
  forum;
5
7
  allIds;
@@ -12,6 +14,8 @@ export class ShardManager {
12
14
  consecutiveQuorumFailures = 0;
13
15
  /** Ensures sustainedQuorumLossCallback fires once per episode, not on every failure past threshold. */
14
16
  hasTriggeredSustainedLoss = false;
17
+ /** Guards against a periodic rebalance tick overlapping an explicit reconcile() already in flight. */
18
+ reconcileInFlight = false;
15
19
  heldIds = new Set();
16
20
  ownership = new Map();
17
21
  rebalanceInterval;
@@ -89,11 +93,13 @@ export class ShardManager {
89
93
  for (const id of ids)
90
94
  this.heldIds.add(id);
91
95
  this.logger.debug(`Now holding ${this.heldIds.size} id(s)`);
96
+ this.lifecycle.emit("assigned", ids);
92
97
  };
93
98
  onRelease = (ids) => {
94
99
  for (const id of ids)
95
100
  this.heldIds.delete(id);
96
101
  this.logger.debug(`Released ${ids.length} id(s), now holding ${this.heldIds.size}`);
102
+ this.lifecycle.emit("released", ids);
97
103
  };
98
104
  onReportRequest = () => {
99
105
  const { self } = this.bully.getStatus();
@@ -144,7 +150,7 @@ export class ShardManager {
144
150
  this.bully.lifecycle.off("elected", this.onElected);
145
151
  this.bully.lifecycle.off("demoted", this.onDemoted);
146
152
  this.onDemoted();
147
- for (const unsubscribe of [...this.activeSubscriptions])
153
+ for (const unsubscribe of this.activeSubscriptions)
148
154
  unsubscribe();
149
155
  }
150
156
  [Symbol.dispose]() {
@@ -171,6 +177,17 @@ export class ShardManager {
171
177
  * assign/release messages needed to converge on that target.
172
178
  */
173
179
  async reconcile() {
180
+ if (this.reconcileInFlight)
181
+ return;
182
+ this.reconcileInFlight = true;
183
+ try {
184
+ await this.doReconcile();
185
+ }
186
+ finally {
187
+ this.reconcileInFlight = false;
188
+ }
189
+ }
190
+ async doReconcile() {
174
191
  const reports = new Map();
175
192
  const { self } = this.bully.getStatus();
176
193
  if (self)
@@ -1 +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"}
1
+ {"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/core/transport.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,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;AAQF,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,CAA4B;IAE3C,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,CA8CzC;IAEM,IAAI,IAAI,IAAI,CAElB;CACF"}
@@ -1,4 +1,10 @@
1
- import express from "express";
1
+ import { App } from "@tinyhttp/app";
2
+ const readJsonBody = async (req) => {
3
+ const chunks = [];
4
+ for await (const chunk of req)
5
+ chunks.push(chunk);
6
+ return chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8"));
7
+ };
2
8
  export class Transport {
3
9
  fetchFn;
4
10
  port;
@@ -39,23 +45,23 @@ export class Transport {
39
45
  }
40
46
  }
41
47
  start(cb) {
42
- const app = express();
43
- app.use(express.json());
44
- app.get("/health", (_, res) => {
48
+ const app = new App();
49
+ const onJsonMessage = (handle) => async (req, res) => {
50
+ try {
51
+ handle(await readJsonBody(req));
52
+ res.sendStatus(200);
53
+ }
54
+ catch (err) {
55
+ this.logger.error(err);
56
+ res.status(500).json({ status: "ERROR", error: err?.message ?? String(err) });
57
+ }
58
+ };
59
+ app.get("/health", (_req, res) => {
45
60
  res.json({ status: "OK", timestamp: new Date().toISOString() });
46
61
  });
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
- });
62
+ app.post("/bully/election", onJsonMessage((body) => cb.onElectionMessage(body?.id)));
63
+ app.post("/bully/coordinator", onJsonMessage((body) => cb.onCoordinatorMessage(body?.id)));
64
+ app.post("/bully/message", onJsonMessage((body) => cb.onMessage(body?.event, body?.payload)));
59
65
  app.get("/channel/stream/:event", (req, res) => {
60
66
  const eventName = req.params.event;
61
67
  res.writeHead(200, {
@@ -67,13 +73,9 @@ export class Transport {
67
73
  cb.channel.on(eventName, forward);
68
74
  req.on("close", () => cb.channel.off(eventName, forward));
69
75
  });
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
76
  const onListening = () => this.logger.info(`Internal coordination server listening on port ${this.port}`);
75
77
  this.server = this.internalHost
76
- ? app.listen(this.port, this.internalHost, onListening)
78
+ ? app.listen(this.port, onListening, this.internalHost)
77
79
  : app.listen(this.port, onListening);
78
80
  }
79
81
  stop() {
package/dist/index.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  export { Quorum } from "./core/quorum";
2
2
  export type { QuorumOptions } from "./core/quorum";
3
3
  export { Bully } from "./core/bully";
4
- export type { BullyPeer, BullyCluster, GetCluster, BullyOptions } from "./core/bully";
4
+ export type { BullyPeer, BullyCluster, Discovery, BullyOptions } from "./core/bully";
5
5
  export { Forum } from "./core/forum";
6
6
  export type { ForumOptions } from "./core/forum";
7
7
  export { Transport } from "./core/transport";
8
8
  export type { TransportOptions, TransportCallbacks } from "./core/transport";
9
- export { ShardManager } from "./core/sharding";
10
- export type { ShardManagerOptions } from "./core/sharding";
9
+ export { Sharding } from "./core/sharding";
10
+ export type { ShardingOptions } from "./core/sharding";
11
11
  export type { Logger } from "./core/logger";
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
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,SAAS,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAErF,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,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAC3C,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,YAAY,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC"}
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { Quorum } from "./core/quorum";
2
2
  export { Bully } from "./core/bully";
3
3
  export { Forum } from "./core/forum";
4
4
  export { Transport } from "./core/transport";
5
- export { ShardManager } from "./core/sharding";
5
+ export { Sharding } from "./core/sharding";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@einaraglen/quorum",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Distributed pod coordination: Bully leader election and work sharding",
5
5
  "keywords": [
6
6
  "leader-election",
@@ -29,15 +29,20 @@
29
29
  },
30
30
  "scripts": {
31
31
  "test": "tsx --test --test-reporter=spec --test-reporter-destination=stdout src/**/*.test.ts",
32
- "build": "tsc"
32
+ "build": "tsc",
33
+ "format": "prettier --write .",
34
+ "format:check": "prettier --check .",
35
+ "lint": "oxlint .",
36
+ "check": "npm run format:check && npm run lint"
33
37
  },
34
38
  "devDependencies": {
35
- "@types/express": "^5.0.6",
36
39
  "@types/node": "^24.9.2",
40
+ "oxlint": "^1.80.0",
41
+ "prettier": "^3.9.6",
37
42
  "tsx": "^4.23.12",
38
43
  "typescript": "^7.0.2"
39
44
  },
40
45
  "dependencies": {
41
- "express": "^5.2.1"
46
+ "@tinyhttp/app": "^3.0.11"
42
47
  }
43
48
  }