@einaraglen/quorum 1.0.0

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.
Files changed (2) hide show
  1. package/README.md +259 -0
  2. package/package.json +43 -0
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ # quorum
2
+
3
+ A Node.js library for coordinating distributed services: leader election, inter-pod messaging, and automatic work sharding — with no external dependencies beyond an HTTP server.
4
+
5
+ Designed for deployments where pods discover each other through an existing mechanism (Kubernetes, DNS, a config file) and need to elect a leader, exchange messages, and divide work among themselves without introducing Zookeeper, etcd, or any external broker.
6
+
7
+ ---
8
+
9
+ ## Concepts
10
+
11
+ ### Bully — leader election
12
+
13
+ Each pod has a name (typically its Kubernetes pod name). The pod with the lexicographically highest name that is reachable by its peers wins the election. This is a simplified version of the classic [Bully algorithm](https://en.wikipedia.org/wiki/Bully_algorithm).
14
+
15
+ When a pod starts up it runs an election: it contacts every peer with a higher name. If any of them acknowledges, it steps back and waits for a coordinator announcement. If none acknowledge (either because they are all unreachable, or because this pod already has the highest name), it declares itself leader and tells every peer. Each follower then runs a periodic heartbeat, and if the leader stops responding it triggers a new election automatically.
16
+
17
+ ### Transport — the wire
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:
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 |
28
+
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
+
31
+ ### Forum — inter-pod messaging
32
+
33
+ Four messaging patterns are available, each with a different routing model:
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 |
41
+
42
+ All four deliver to the pod's local `channel` EventEmitter when the target happens to be self, with no network round-trip.
43
+
44
+ `distribute(event, resolvePayload)` is a leader-only helper for partitioning work: it calls your callback once per peer (sorted by name for stability) and delivers the return value to each pod as a `channel` event. Useful for sending each pod its own slice of a large dataset without the leader needing to know each pod's address explicitly.
45
+
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
+
48
+ ### ShardManager — work distribution
49
+
50
+ The leader periodically reconciles who owns what. Reconciliation works in four steps:
51
+
52
+ 1. **Collect** — broadcast a request for holdings reports; wait for every peer to reply with what it currently holds.
53
+ 2. **Compute** — divide the full set of IDs into a fair share per reporting peer (± 1 for remainders), sorted by name for a stable assignment order.
54
+ 3. **Publish** — broadcast the full `id → peer` ownership map so every pod can answer "who owns X?" locally without a network call.
55
+ 4. **Converge** — send each peer exactly the `assign` and `release` messages needed to reach the target, touching only what has to change.
56
+
57
+ Ownership changes drive subscriptions: `subscribe(id, onEvent)` listens to the current owner over SSE and reconnects automatically if the ownership map changes — no coordination with the old or new owner required.
58
+
59
+ **Quorum protection** (optional): set `expectedClusterSize` to guard against split-brain. Reconciliation refuses to act until more than half of the expected pods have reported in. After `quorumFailureThreshold` consecutive failures (default 3) the `onSustainedQuorumLoss` callback fires — defaulting to `process.exit(1)` so Kubernetes restarts the pod rather than letting it serve stale data indefinitely.
60
+
61
+ ### Quorum — the wrapper
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.
64
+
65
+ ---
66
+
67
+ ## Quick start
68
+
69
+ ```typescript
70
+ import { Quorum } from "quorum";
71
+ import { getCluster } from "./my-discovery.js"; // returns { self, cluster }
72
+
73
+ const q = new Quorum({
74
+ getCluster,
75
+ ids: ["uuid-1", "uuid-2", "uuid-3"], // the full set of work items to distribute
76
+ expectedClusterSize: 4, // enables quorum protection
77
+ });
78
+
79
+ q.start(); // starts the HTTP server, runs the first election, begins reconciling
80
+
81
+ // Subscribe to events for a specific id, from wherever it currently lives:
82
+ const unsubscribe = q.subscribe("uuid-1", (payload) => {
83
+ console.log("received:", payload);
84
+ });
85
+
86
+ // Publish an event for an id this pod currently owns:
87
+ q.publish("uuid-1", { reading: 42 });
88
+
89
+ // Look up who owns an id without a network call:
90
+ console.log(q.getOwner("uuid-1")); // "pod-name-c"
91
+
92
+ // Clean shutdown (or use `using q = new Quorum(...)` for automatic teardown):
93
+ q.stop();
94
+ ```
95
+
96
+ ### With explicit resource management
97
+
98
+ ```typescript
99
+ async function main() {
100
+ using q = new Quorum({ getCluster, ids });
101
+ q.start();
102
+
103
+ await new Promise<void>((resolve) => {
104
+ process.on("SIGTERM", resolve);
105
+ process.on("SIGINT", resolve);
106
+ });
107
+ // q.stop() is called automatically here
108
+ }
109
+ ```
110
+
111
+ ---
112
+
113
+ ## API
114
+
115
+ ### `new Quorum(opts)`
116
+
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. |
133
+
134
+ **Methods:**
135
+
136
+ ```typescript
137
+ q.start() // start server, election, heartbeat, reconcile loop
138
+ q.stop() // graceful shutdown
139
+ q.isLeader(): boolean
140
+ q.getStatus(): { self, leader, isLeader }
141
+ q.getPodRoles(): Promise<{ name, host, role }[]>
142
+
143
+ q.subscribe(id, onEvent): () => void // live subscription to events for an id
144
+ q.publish(id, payload): void // emit an event for an id this pod owns
145
+ q.getOwner(id): string | undefined // local lookup — no network call
146
+ q.getOwnership(): Map<TId, string> // full id→peer map as of last reconcile
147
+ q.getHeldIds(): TId[] // ids currently assigned to this pod
148
+ q.updateIds(ids): Promise<void> // leader-only: replace the full id set and reconcile
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Advanced use
154
+
155
+ For testing or custom wiring you can create the layers individually:
156
+
157
+ ```typescript
158
+ import { Transport, Bully, Forum, ShardManager } from "quorum";
159
+
160
+ 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: [] });
164
+
165
+ // Wire the transport callbacks manually:
166
+ transport.start({
167
+ onElectionMessage: (id) => bully.onElectionMessage(id),
168
+ onCoordinatorMessage: (id) => bully.onCoordinatorMessage(id),
169
+ onMessage: (event, payload) => bully.onMessage(event, payload),
170
+ channel: bully.channel,
171
+ });
172
+ shard.start();
173
+ bully.start();
174
+ await bully.startElection();
175
+ ```
176
+
177
+ ### Channel events
178
+
179
+ `bully.channel` is a plain Node.js `EventEmitter`. Every message received from a peer arrives here as a named event. You can listen directly for any custom event the leader broadcasts, or emit events directly in tests to simulate incoming messages without a real network:
180
+
181
+ ```typescript
182
+ // Simulate the leader telling this pod to take ownership:
183
+ bully.channel.emit("assign-connections", [7, 12, 44]);
184
+
185
+ // Simulate the leader publishing an updated ownership map:
186
+ bully.channel.emit("ownership-map", [[7, "pod-c"], [12, "pod-a"]]);
187
+ ```
188
+
189
+ ### Lifecycle events
190
+
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:
192
+
193
+ ```typescript
194
+ bully.lifecycle.on("elected", () => {
195
+ console.log("I am now the leader — starting reconcile");
196
+ });
197
+ bully.lifecycle.on("demoted", () => {
198
+ console.log("Lost leadership");
199
+ });
200
+ ```
201
+
202
+ ---
203
+
204
+ ## Peer discovery
205
+
206
+ `getCluster` is the only application-specific piece. It must return:
207
+
208
+ ```typescript
209
+ {
210
+ self: { name: string; host?: string }, // this pod
211
+ cluster: { name: string; host?: string }[] // all pods, self included
212
+ }
213
+ ```
214
+
215
+ `name` is used for election ordering — higher wins. `host` is the IP or hostname peers use to reach this pod over the internal port. A Kubernetes implementation typically reads from the pod list API:
216
+
217
+ ```typescript
218
+ // In your application layer (not in quorum itself):
219
+ const getCluster = async () => {
220
+ const pods = await k8s.listNamespacedPod({
221
+ namespace: "default",
222
+ labelSelector: "app=my-service",
223
+ fieldSelector: "status.phase=Running",
224
+ });
225
+ const toPeer = (pod) => ({
226
+ name: pod.metadata.name,
227
+ host: pod.status.podIP,
228
+ });
229
+ const self = pods.find((p) => p.metadata.name === process.env.POD_NAME);
230
+ return { self: toPeer(self), cluster: pods.map(toPeer) };
231
+ };
232
+ ```
233
+
234
+ ---
235
+
236
+ ## File structure
237
+
238
+ ```
239
+ src/
240
+ index.ts Public exports
241
+ core/
242
+ transport.ts HTTP server (routes) + HTTP client (postToPeer, pingPeer)
243
+ bully.ts Election state machine — the Bully algorithm
244
+ forum.ts Inter-pod messaging — broadcast, send, tell, subscribe
245
+ sharding.ts Work distribution — reconcile, ownership map, quorum
246
+ quorum.ts Unified wrapper; the primary public entry point
247
+ logger.ts The `Logger` type every class accepts via `opts.logger`
248
+ test/ Test suites, one file per core/ module
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Logging
254
+
255
+ 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
+
257
+ ```typescript
258
+ const q = new Quorum({ getCluster, ids, logger: myWinstonLogger });
259
+ ```
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@einaraglen/quorum",
3
+ "version": "1.0.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
+ }