@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.
- package/README.md +259 -0
- 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
|
+
}
|