hungry-ghost-hive 0.45.0 → 0.46.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/dist/cli/commands/cluster.d.ts.map +1 -1
- package/dist/cli/commands/cluster.js +348 -1
- package/dist/cli/commands/cluster.js.map +1 -1
- package/dist/cli/commands/cluster.test.js +313 -9
- package/dist/cli/commands/cluster.test.js.map +1 -1
- package/dist/cli/commands/req-spawn.test.d.ts +2 -0
- package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
- package/dist/cli/commands/req-spawn.test.js +116 -0
- package/dist/cli/commands/req-spawn.test.js.map +1 -0
- package/dist/cli/commands/req.d.ts.map +1 -1
- package/dist/cli/commands/req.js +21 -13
- package/dist/cli/commands/req.js.map +1 -1
- package/dist/cluster/cluster-http-server.d.ts +32 -0
- package/dist/cluster/cluster-http-server.d.ts.map +1 -1
- package/dist/cluster/cluster-http-server.js +42 -0
- package/dist/cluster/cluster-http-server.js.map +1 -1
- package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
- package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
- package/dist/cluster/distributed-system.test.js +135 -0
- package/dist/cluster/distributed-system.test.js.map +1 -1
- package/dist/cluster/events.d.ts +23 -0
- package/dist/cluster/events.d.ts.map +1 -1
- package/dist/cluster/events.js +74 -0
- package/dist/cluster/events.js.map +1 -1
- package/dist/cluster/heartbeat-manager.d.ts +2 -0
- package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
- package/dist/cluster/heartbeat-manager.js +42 -6
- package/dist/cluster/heartbeat-manager.js.map +1 -1
- package/dist/cluster/membership.test.d.ts +2 -0
- package/dist/cluster/membership.test.d.ts.map +1 -0
- package/dist/cluster/membership.test.js +416 -0
- package/dist/cluster/membership.test.js.map +1 -0
- package/dist/cluster/partition-safety.test.d.ts +2 -0
- package/dist/cluster/partition-safety.test.d.ts.map +1 -0
- package/dist/cluster/partition-safety.test.js +440 -0
- package/dist/cluster/partition-safety.test.js.map +1 -0
- package/dist/cluster/raft-state-machine.d.ts +33 -1
- package/dist/cluster/raft-state-machine.d.ts.map +1 -1
- package/dist/cluster/raft-state-machine.js +65 -3
- package/dist/cluster/raft-state-machine.js.map +1 -1
- package/dist/cluster/raft-store.d.ts +26 -1
- package/dist/cluster/raft-store.d.ts.map +1 -1
- package/dist/cluster/raft-store.js +137 -0
- package/dist/cluster/raft-store.js.map +1 -1
- package/dist/cluster/replication-lag.test.d.ts +2 -0
- package/dist/cluster/replication-lag.test.d.ts.map +1 -0
- package/dist/cluster/replication-lag.test.js +239 -0
- package/dist/cluster/replication-lag.test.js.map +1 -0
- package/dist/cluster/replication.d.ts +2 -2
- package/dist/cluster/replication.d.ts.map +1 -1
- package/dist/cluster/replication.js +1 -1
- package/dist/cluster/replication.js.map +1 -1
- package/dist/cluster/runtime.d.ts +78 -0
- package/dist/cluster/runtime.d.ts.map +1 -1
- package/dist/cluster/runtime.js +400 -13
- package/dist/cluster/runtime.js.map +1 -1
- package/dist/cluster/state-recovery.test.d.ts +2 -0
- package/dist/cluster/state-recovery.test.d.ts.map +1 -0
- package/dist/cluster/state-recovery.test.js +310 -0
- package/dist/cluster/state-recovery.test.js.map +1 -0
- package/dist/cluster/types.d.ts +30 -0
- package/dist/cluster/types.d.ts.map +1 -1
- package/dist/config/schema.d.ts +48 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +11 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/context-files/generator.js +1 -1
- package/dist/context-files/generator.js.map +1 -1
- package/dist/context-files/generator.test.js +51 -0
- package/dist/context-files/generator.test.js.map +1 -1
- package/dist/orchestrator/orphan-recovery.d.ts +1 -1
- package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
- package/dist/orchestrator/orphan-recovery.js +4 -4
- package/dist/orchestrator/orphan-recovery.js.map +1 -1
- package/dist/orchestrator/prompt-templates.d.ts +3 -1
- package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
- package/dist/orchestrator/prompt-templates.js +45 -8
- package/dist/orchestrator/prompt-templates.js.map +1 -1
- package/dist/orchestrator/prompt-templates.test.js +210 -0
- package/dist/orchestrator/prompt-templates.test.js.map +1 -1
- package/dist/orchestrator/scheduler.d.ts +1 -0
- package/dist/orchestrator/scheduler.d.ts.map +1 -1
- package/dist/orchestrator/scheduler.js +15 -10
- package/dist/orchestrator/scheduler.js.map +1 -1
- package/dist/orchestrator/scheduler.test.js +97 -6
- package/dist/orchestrator/scheduler.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/cluster.test.ts +387 -9
- package/src/cli/commands/cluster.ts +486 -1
- package/src/cli/commands/req-spawn.test.ts +153 -0
- package/src/cli/commands/req.ts +31 -18
- package/src/cluster/cluster-http-server.ts +80 -0
- package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
- package/src/cluster/distributed-system.test.ts +168 -0
- package/src/cluster/events.ts +90 -0
- package/src/cluster/heartbeat-manager.ts +48 -6
- package/src/cluster/membership.test.ts +498 -0
- package/src/cluster/partition-safety.test.ts +523 -0
- package/src/cluster/raft-state-machine.ts +76 -4
- package/src/cluster/raft-store.ts +167 -1
- package/src/cluster/replication-lag.test.ts +284 -0
- package/src/cluster/replication.ts +6 -0
- package/src/cluster/runtime.ts +551 -12
- package/src/cluster/state-recovery.test.ts +420 -0
- package/src/cluster/types.ts +32 -0
- package/src/config/schema.ts +11 -0
- package/src/context-files/generator.test.ts +55 -0
- package/src/context-files/generator.ts +5 -5
- package/src/orchestrator/orphan-recovery.ts +32 -13
- package/src/orchestrator/prompt-templates.test.ts +263 -0
- package/src/orchestrator/prompt-templates.ts +49 -8
- package/src/orchestrator/scheduler.test.ts +129 -6
- package/src/orchestrator/scheduler.ts +46 -20
package/dist/cluster/events.js
CHANGED
|
@@ -56,6 +56,13 @@ export function ensureClusterTables(db, nodeId) {
|
|
|
56
56
|
merged_at TEXT NOT NULL
|
|
57
57
|
)
|
|
58
58
|
`);
|
|
59
|
+
// Add snapshot_version_vector column if it doesn't exist yet (backward-compat migration)
|
|
60
|
+
try {
|
|
61
|
+
run(db, 'ALTER TABLE cluster_state ADD COLUMN snapshot_version_vector TEXT');
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Column already exists — ignore
|
|
65
|
+
}
|
|
59
66
|
const state = queryOne(db, 'SELECT id FROM cluster_state WHERE id = 1');
|
|
60
67
|
const now = new Date().toISOString();
|
|
61
68
|
if (!state) {
|
|
@@ -77,6 +84,45 @@ export function getVersionVector(db) {
|
|
|
77
84
|
}
|
|
78
85
|
return vector;
|
|
79
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns the snapshot version vector stored after the last snapshot-based recovery.
|
|
89
|
+
* Empty object if no snapshot has been applied.
|
|
90
|
+
*/
|
|
91
|
+
export function getSnapshotVersionVector(db) {
|
|
92
|
+
const row = queryOne(db, 'SELECT snapshot_version_vector FROM cluster_state WHERE id = 1');
|
|
93
|
+
if (!row?.snapshot_version_vector)
|
|
94
|
+
return {};
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(row.snapshot_version_vector);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return {};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Persists the snapshot version vector so that future delta requests start
|
|
104
|
+
* from this point rather than from the (empty) event log.
|
|
105
|
+
*/
|
|
106
|
+
export function setSnapshotVersionVector(db, vector) {
|
|
107
|
+
run(db, 'UPDATE cluster_state SET snapshot_version_vector = ? WHERE id = 1', [
|
|
108
|
+
JSON.stringify(vector),
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Returns the effective version vector for delta-sync requests.
|
|
113
|
+
* Takes the max per actor between the event-log-derived vector and any
|
|
114
|
+
* snapshot vector stored from a previous snapshot-based recovery.
|
|
115
|
+
* This prevents re-requesting events that were already covered by a snapshot.
|
|
116
|
+
*/
|
|
117
|
+
export function getEffectiveVersionVector(db) {
|
|
118
|
+
const eventVector = getVersionVector(db);
|
|
119
|
+
const snapshotVector = getSnapshotVersionVector(db);
|
|
120
|
+
const effective = { ...snapshotVector };
|
|
121
|
+
for (const [actor, counter] of Object.entries(eventVector)) {
|
|
122
|
+
effective[actor] = Math.max(effective[actor] ?? 0, counter);
|
|
123
|
+
}
|
|
124
|
+
return effective;
|
|
125
|
+
}
|
|
80
126
|
export function getAllClusterEvents(db) {
|
|
81
127
|
const rows = queryAll(db, `
|
|
82
128
|
SELECT event_id, actor_id, actor_counter, logical_ts, table_name, row_id, op, payload, created_at
|
|
@@ -122,6 +168,34 @@ export function emitLocalEvent(db, nodeId, input) {
|
|
|
122
168
|
logical_ts = excluded.logical_ts
|
|
123
169
|
`, [input.table_name, input.row_id, nodeId, nextCounter, logicalTs]);
|
|
124
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* Prune old cluster_events rows, retaining only the most recent `retainCount` events.
|
|
173
|
+
* Returns the number of rows deleted.
|
|
174
|
+
*/
|
|
175
|
+
export function pruneClusterEvents(db, retainCount) {
|
|
176
|
+
if (retainCount <= 0)
|
|
177
|
+
return 0;
|
|
178
|
+
const countRow = queryOne(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
179
|
+
const total = countRow?.total || 0;
|
|
180
|
+
if (total <= retainCount)
|
|
181
|
+
return 0;
|
|
182
|
+
// Delete events that are not in the most recent `retainCount` by logical_ts ordering.
|
|
183
|
+
// We keep the newest events and delete the oldest.
|
|
184
|
+
run(db, `
|
|
185
|
+
DELETE FROM cluster_events
|
|
186
|
+
WHERE event_id NOT IN (
|
|
187
|
+
SELECT event_id FROM cluster_events
|
|
188
|
+
ORDER BY logical_ts DESC, actor_id DESC, actor_counter DESC
|
|
189
|
+
LIMIT ?
|
|
190
|
+
)
|
|
191
|
+
`, [retainCount]);
|
|
192
|
+
const afterRow = queryOne(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
193
|
+
return total - (afterRow?.total || 0);
|
|
194
|
+
}
|
|
195
|
+
export function getClusterEventCount(db) {
|
|
196
|
+
const row = queryOne(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
197
|
+
return row?.total || 0;
|
|
198
|
+
}
|
|
125
199
|
export function fetchTableSnapshots(db, adapter) {
|
|
126
200
|
const rows = queryAll(db, adapter.selectSql);
|
|
127
201
|
return rows.map(row => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"events.js","sourceRoot":"","sources":["../../src/cluster/events.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"events.js","sourceRoot":"","sources":["../../src/cluster/events.ts"],"names":[],"mappings":"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"}
|
|
@@ -3,12 +3,14 @@ import type { RaftStateMachine } from './raft-state-machine.js';
|
|
|
3
3
|
interface HeartbeatResponse {
|
|
4
4
|
term: number;
|
|
5
5
|
success: boolean;
|
|
6
|
+
fencing_token: number;
|
|
6
7
|
}
|
|
7
8
|
export interface HeartbeatManagerDeps {
|
|
8
9
|
raft: RaftStateMachine;
|
|
9
10
|
postJson: <T>(peer: ClusterPeerConfig, path: string, body: unknown) => Promise<T | null>;
|
|
10
11
|
isActive: () => boolean;
|
|
11
12
|
handleBackgroundError: (error: unknown) => void;
|
|
13
|
+
onPeersUpdated?: (peers: ClusterPeerConfig[]) => void;
|
|
12
14
|
}
|
|
13
15
|
export declare class HeartbeatManager {
|
|
14
16
|
private readonly config;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"heartbeat-manager.d.ts","sourceRoot":"","sources":["../../src/cluster/heartbeat-manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC5E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"heartbeat-manager.d.ts","sourceRoot":"","sources":["../../src/cluster/heartbeat-manager.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC5E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAShE,UAAU,iBAAiB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,gBAAgB,CAAC;IACvB,QAAQ,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;IACzF,QAAQ,EAAE,MAAM,OAAO,CAAC;IACxB,qBAAqB,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,EAAE,KAAK,IAAI,CAAC;CACvD;AAED,qBAAa,gBAAgB;IAIzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAJvB,OAAO,CAAC,cAAc,CAA+B;gBAGlC,MAAM,EAAE,aAAa,EACrB,IAAI,EAAE,oBAAoB;IAG7C,kBAAkB,IAAI,IAAI;IAQ1B,iBAAiB,IAAI,IAAI;IAOnB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC;IAuCrC,eAAe,CAAC,IAAI,EAAE,OAAO,GAAG,iBAAiB;CAqDlD"}
|
|
@@ -26,21 +26,27 @@ export class HeartbeatManager {
|
|
|
26
26
|
if (!this.deps.isActive())
|
|
27
27
|
return;
|
|
28
28
|
const { raft } = this.deps;
|
|
29
|
+
const peers = raft.getPeers();
|
|
29
30
|
const heartbeat = {
|
|
30
31
|
term: raft.currentTerm,
|
|
31
32
|
leader_id: this.config.node_id,
|
|
33
|
+
fencing_token: raft.getFencingToken(),
|
|
34
|
+
peers: peers.map(p => ({ id: p.id, url: p.url })),
|
|
32
35
|
};
|
|
33
36
|
raft.appendDurableEntry('heartbeat_sent', {
|
|
34
37
|
term: raft.currentTerm,
|
|
35
38
|
leader_id: this.config.node_id,
|
|
36
|
-
peer_count:
|
|
39
|
+
peer_count: peers.filter(peer => peer.id !== this.config.node_id).length,
|
|
37
40
|
});
|
|
38
|
-
await Promise.all(
|
|
41
|
+
await Promise.all(peers
|
|
39
42
|
.filter(peer => peer.id !== this.config.node_id)
|
|
40
43
|
.map(async (peer) => {
|
|
41
44
|
const response = await this.deps.postJson(peer, '/cluster/v1/election/heartbeat', heartbeat);
|
|
42
|
-
if (response
|
|
43
|
-
|
|
45
|
+
if (response) {
|
|
46
|
+
const remoteTerm = Math.max(response.term, response.fencing_token ?? 0);
|
|
47
|
+
if (remoteTerm > raft.currentTerm) {
|
|
48
|
+
raft.stepDown(remoteTerm, peer.id);
|
|
49
|
+
}
|
|
44
50
|
}
|
|
45
51
|
}));
|
|
46
52
|
}
|
|
@@ -49,8 +55,14 @@ export class HeartbeatManager {
|
|
|
49
55
|
const request = body;
|
|
50
56
|
const term = Number(request.term || 0);
|
|
51
57
|
const leaderId = typeof request.leader_id === 'string' ? request.leader_id : null;
|
|
58
|
+
const fencingToken = Number(request.fencing_token ?? term);
|
|
59
|
+
// Reject heartbeats from stale leaders
|
|
52
60
|
if (term < raft.currentTerm) {
|
|
53
|
-
return { term: raft.currentTerm, success: false };
|
|
61
|
+
return { term: raft.currentTerm, success: false, fencing_token: raft.getFencingToken() };
|
|
62
|
+
}
|
|
63
|
+
// Reject if fencing token doesn't match the heartbeat term
|
|
64
|
+
if (fencingToken < term) {
|
|
65
|
+
return { term: raft.currentTerm, success: false, fencing_token: raft.getFencingToken() };
|
|
54
66
|
}
|
|
55
67
|
const changed = term > raft.currentTerm || leaderId !== raft.leaderId || raft.role !== 'follower';
|
|
56
68
|
if (term > raft.currentTerm) {
|
|
@@ -61,14 +73,38 @@ export class HeartbeatManager {
|
|
|
61
73
|
raft.leaderId = leaderId;
|
|
62
74
|
raft.persistRaftState();
|
|
63
75
|
}
|
|
76
|
+
// Update lease: record that we received a valid heartbeat now
|
|
77
|
+
raft.lastHeartbeatReceivedAt = Date.now();
|
|
64
78
|
raft.resetElectionDeadline();
|
|
79
|
+
// Apply peer list from leader if present
|
|
80
|
+
const requestPeers = request.peers;
|
|
81
|
+
if (Array.isArray(requestPeers)) {
|
|
82
|
+
const parsed = parsePeerList(requestPeers);
|
|
83
|
+
if (parsed.length > 0) {
|
|
84
|
+
raft.setPeers(parsed);
|
|
85
|
+
this.deps.onPeersUpdated?.(parsed);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
65
88
|
if (changed) {
|
|
66
89
|
raft.appendDurableEntry('heartbeat_received', {
|
|
67
90
|
term,
|
|
68
91
|
leader_id: leaderId,
|
|
92
|
+
fencing_token: fencingToken,
|
|
69
93
|
});
|
|
70
94
|
}
|
|
71
|
-
return { term: raft.currentTerm, success: true };
|
|
95
|
+
return { term: raft.currentTerm, success: true, fencing_token: raft.getFencingToken() };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function parsePeerList(input) {
|
|
99
|
+
const peers = [];
|
|
100
|
+
for (const item of input) {
|
|
101
|
+
if (!item || typeof item !== 'object')
|
|
102
|
+
continue;
|
|
103
|
+
const p = item;
|
|
104
|
+
if (typeof p.id === 'string' && typeof p.url === 'string') {
|
|
105
|
+
peers.push({ id: p.id, url: p.url });
|
|
106
|
+
}
|
|
72
107
|
}
|
|
108
|
+
return peers;
|
|
73
109
|
}
|
|
74
110
|
//# sourceMappingURL=heartbeat-manager.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"heartbeat-manager.js","sourceRoot":"","sources":["../../src/cluster/heartbeat-manager.ts"],"names":[],"mappings":"AAAA,6DAA6D;
|
|
1
|
+
{"version":3,"file":"heartbeat-manager.js","sourceRoot":"","sources":["../../src/cluster/heartbeat-manager.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"membership.test.d.ts","sourceRoot":"","sources":["../../src/cluster/membership.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// Licensed under the Hungry Ghost Hive License. See LICENSE.
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { createServer as createNetServer } from 'net';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { ClusterRuntime } from './runtime.js';
|
|
8
|
+
const tempRoots = [];
|
|
9
|
+
const activeRuntimes = [];
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
for (const runtime of activeRuntimes.splice(0)) {
|
|
12
|
+
try {
|
|
13
|
+
await runtime.stop();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Best effort shutdown for test cleanup.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (const root of tempRoots.splice(0)) {
|
|
20
|
+
rmSync(root, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
describe('dynamic membership join', () => {
|
|
24
|
+
it('leader accepts join request and adds peer to cluster', async () => {
|
|
25
|
+
if (!(await canListenOnLocalhost()))
|
|
26
|
+
return;
|
|
27
|
+
const fixture = await startRuntimeFixture({
|
|
28
|
+
node_id: 'leader-join',
|
|
29
|
+
election_timeout_min_ms: 80,
|
|
30
|
+
election_timeout_max_ms: 120,
|
|
31
|
+
heartbeat_interval_ms: 60,
|
|
32
|
+
});
|
|
33
|
+
await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
|
|
34
|
+
const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
35
|
+
node_id: 'new-node',
|
|
36
|
+
url: 'http://127.0.0.1:9999',
|
|
37
|
+
});
|
|
38
|
+
expect(res.success).toBe(true);
|
|
39
|
+
expect(res.leader_id).toBe('leader-join');
|
|
40
|
+
expect(res.peers).toContainEqual({ id: 'new-node', url: 'http://127.0.0.1:9999' });
|
|
41
|
+
const status = fixture.runtime.getStatus();
|
|
42
|
+
expect(status.peers).toContainEqual({ id: 'new-node', url: 'http://127.0.0.1:9999' });
|
|
43
|
+
});
|
|
44
|
+
it('follower redirects join request to leader', async () => {
|
|
45
|
+
if (!(await canListenOnLocalhost()))
|
|
46
|
+
return;
|
|
47
|
+
const fixture = await startRuntimeFixture({
|
|
48
|
+
node_id: 'follower-join',
|
|
49
|
+
election_timeout_min_ms: 5000,
|
|
50
|
+
election_timeout_max_ms: 5000,
|
|
51
|
+
peers: [{ id: 'remote-leader', url: 'http://127.0.0.1:9998' }],
|
|
52
|
+
});
|
|
53
|
+
// Set the node as follower with a known leader
|
|
54
|
+
await postJson(fixture.config.public_url, '/cluster/v1/election/heartbeat', {
|
|
55
|
+
term: 3,
|
|
56
|
+
leader_id: 'remote-leader',
|
|
57
|
+
fencing_token: 3,
|
|
58
|
+
});
|
|
59
|
+
const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/join`, {
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: { 'Content-Type': 'application/json' },
|
|
62
|
+
body: JSON.stringify({ node_id: 'joiner', url: 'http://127.0.0.1:9997' }),
|
|
63
|
+
});
|
|
64
|
+
expect(res.status).toBe(307);
|
|
65
|
+
const body = (await res.json());
|
|
66
|
+
expect(body.success).toBe(false);
|
|
67
|
+
expect(body.leader_id).toBe('remote-leader');
|
|
68
|
+
expect(body.leader_url).toBe('http://127.0.0.1:9998');
|
|
69
|
+
});
|
|
70
|
+
it('rejects join request with missing fields', async () => {
|
|
71
|
+
if (!(await canListenOnLocalhost()))
|
|
72
|
+
return;
|
|
73
|
+
const fixture = await startRuntimeFixture({ node_id: 'leader-join-bad' });
|
|
74
|
+
const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/join`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ node_id: 'missing-url' }),
|
|
78
|
+
});
|
|
79
|
+
expect(res.status).toBe(400);
|
|
80
|
+
});
|
|
81
|
+
it('updates url for existing peer on re-join', async () => {
|
|
82
|
+
if (!(await canListenOnLocalhost()))
|
|
83
|
+
return;
|
|
84
|
+
const fixture = await startRuntimeFixture({
|
|
85
|
+
node_id: 'leader-rejoin',
|
|
86
|
+
election_timeout_min_ms: 80,
|
|
87
|
+
election_timeout_max_ms: 120,
|
|
88
|
+
heartbeat_interval_ms: 60,
|
|
89
|
+
});
|
|
90
|
+
await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
|
|
91
|
+
// First add the peer
|
|
92
|
+
await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
93
|
+
node_id: 'existing-peer',
|
|
94
|
+
url: 'http://127.0.0.1:8000',
|
|
95
|
+
});
|
|
96
|
+
// Re-join with different URL
|
|
97
|
+
const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
98
|
+
node_id: 'existing-peer',
|
|
99
|
+
url: 'http://127.0.0.1:9000',
|
|
100
|
+
});
|
|
101
|
+
expect(res.success).toBe(true);
|
|
102
|
+
expect(res.peers).toContainEqual({ id: 'existing-peer', url: 'http://127.0.0.1:9000' });
|
|
103
|
+
});
|
|
104
|
+
it('idempotent join with same url returns success', async () => {
|
|
105
|
+
if (!(await canListenOnLocalhost()))
|
|
106
|
+
return;
|
|
107
|
+
const fixture = await startRuntimeFixture({
|
|
108
|
+
node_id: 'leader-idem',
|
|
109
|
+
election_timeout_min_ms: 80,
|
|
110
|
+
election_timeout_max_ms: 120,
|
|
111
|
+
heartbeat_interval_ms: 60,
|
|
112
|
+
});
|
|
113
|
+
await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
|
|
114
|
+
// Add peer first
|
|
115
|
+
await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
116
|
+
node_id: 'peer-x',
|
|
117
|
+
url: 'http://127.0.0.1:7777',
|
|
118
|
+
});
|
|
119
|
+
// Join again with same details — idempotent
|
|
120
|
+
const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
121
|
+
node_id: 'peer-x',
|
|
122
|
+
url: 'http://127.0.0.1:7777',
|
|
123
|
+
});
|
|
124
|
+
expect(res.success).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
describe('dynamic membership leave', () => {
|
|
128
|
+
it('leader removes peer on leave request', async () => {
|
|
129
|
+
if (!(await canListenOnLocalhost()))
|
|
130
|
+
return;
|
|
131
|
+
const fixture = await startRuntimeFixture({
|
|
132
|
+
node_id: 'leader-leave',
|
|
133
|
+
election_timeout_min_ms: 80,
|
|
134
|
+
election_timeout_max_ms: 120,
|
|
135
|
+
heartbeat_interval_ms: 60,
|
|
136
|
+
});
|
|
137
|
+
await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
|
|
138
|
+
// Add peer first, then remove it
|
|
139
|
+
await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
140
|
+
node_id: 'departing-node',
|
|
141
|
+
url: 'http://127.0.0.1:8888',
|
|
142
|
+
});
|
|
143
|
+
const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/leave', {
|
|
144
|
+
node_id: 'departing-node',
|
|
145
|
+
});
|
|
146
|
+
expect(res.success).toBe(true);
|
|
147
|
+
expect(res.peers).not.toContainEqual(expect.objectContaining({ id: 'departing-node' }));
|
|
148
|
+
const status = fixture.runtime.getStatus();
|
|
149
|
+
expect(status.peers.find(p => p.id === 'departing-node')).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
it('follower rejects leave request', async () => {
|
|
152
|
+
if (!(await canListenOnLocalhost()))
|
|
153
|
+
return;
|
|
154
|
+
const fixture = await startRuntimeFixture({
|
|
155
|
+
node_id: 'follower-leave',
|
|
156
|
+
election_timeout_min_ms: 5000,
|
|
157
|
+
election_timeout_max_ms: 5000,
|
|
158
|
+
});
|
|
159
|
+
const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { 'Content-Type': 'application/json' },
|
|
162
|
+
body: JSON.stringify({ node_id: 'some-node' }),
|
|
163
|
+
});
|
|
164
|
+
expect(res.status).toBe(400);
|
|
165
|
+
const body = (await res.json());
|
|
166
|
+
expect(body.success).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
it('leader cannot remove itself', async () => {
|
|
169
|
+
if (!(await canListenOnLocalhost()))
|
|
170
|
+
return;
|
|
171
|
+
const fixture = await startRuntimeFixture({
|
|
172
|
+
node_id: 'leader-self-leave',
|
|
173
|
+
election_timeout_min_ms: 80,
|
|
174
|
+
election_timeout_max_ms: 120,
|
|
175
|
+
heartbeat_interval_ms: 60,
|
|
176
|
+
});
|
|
177
|
+
await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
|
|
178
|
+
const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({ node_id: 'leader-self-leave' }),
|
|
182
|
+
});
|
|
183
|
+
expect(res.status).toBe(400);
|
|
184
|
+
});
|
|
185
|
+
it('leave for unknown node is a no-op success', async () => {
|
|
186
|
+
if (!(await canListenOnLocalhost()))
|
|
187
|
+
return;
|
|
188
|
+
const fixture = await startRuntimeFixture({
|
|
189
|
+
node_id: 'leader-unknown-leave',
|
|
190
|
+
election_timeout_min_ms: 80,
|
|
191
|
+
election_timeout_max_ms: 120,
|
|
192
|
+
heartbeat_interval_ms: 60,
|
|
193
|
+
});
|
|
194
|
+
await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
|
|
195
|
+
const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/leave', {
|
|
196
|
+
node_id: 'ghost-node',
|
|
197
|
+
});
|
|
198
|
+
expect(res.success).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
it('rejects leave request with missing node_id', async () => {
|
|
201
|
+
if (!(await canListenOnLocalhost()))
|
|
202
|
+
return;
|
|
203
|
+
const fixture = await startRuntimeFixture({ node_id: 'leader-leave-bad' });
|
|
204
|
+
const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers: { 'Content-Type': 'application/json' },
|
|
207
|
+
body: JSON.stringify({}),
|
|
208
|
+
});
|
|
209
|
+
expect(res.status).toBe(400);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe('peer list propagation via heartbeat', () => {
|
|
213
|
+
it('leader propagates updated peer list to followers', async () => {
|
|
214
|
+
if (!(await canListenOnLocalhost()))
|
|
215
|
+
return;
|
|
216
|
+
const portLeader = await getFreePort();
|
|
217
|
+
const portFollower = await getFreePort();
|
|
218
|
+
const leaderConfig = await buildConfig({
|
|
219
|
+
node_id: 'leader-prop',
|
|
220
|
+
listen_port: portLeader,
|
|
221
|
+
public_url: `http://127.0.0.1:${portLeader}`,
|
|
222
|
+
peers: [{ id: 'follower-prop', url: `http://127.0.0.1:${portFollower}` }],
|
|
223
|
+
election_timeout_min_ms: 80,
|
|
224
|
+
election_timeout_max_ms: 120,
|
|
225
|
+
heartbeat_interval_ms: 60,
|
|
226
|
+
});
|
|
227
|
+
const followerConfig = await buildConfig({
|
|
228
|
+
node_id: 'follower-prop',
|
|
229
|
+
listen_port: portFollower,
|
|
230
|
+
public_url: `http://127.0.0.1:${portFollower}`,
|
|
231
|
+
peers: [{ id: 'leader-prop', url: `http://127.0.0.1:${portLeader}` }],
|
|
232
|
+
election_timeout_min_ms: 5000,
|
|
233
|
+
election_timeout_max_ms: 5000,
|
|
234
|
+
});
|
|
235
|
+
const leaderFixture = await startRuntimeWithConfig(leaderConfig);
|
|
236
|
+
const followerFixture = await startRuntimeWithConfig(followerConfig);
|
|
237
|
+
// Wait for leader election
|
|
238
|
+
await waitFor(() => leaderFixture.runtime.getStatus().is_leader, 4000);
|
|
239
|
+
// Add a new peer via the leader
|
|
240
|
+
await postJson(leaderFixture.config.public_url, '/cluster/v1/membership/join', {
|
|
241
|
+
node_id: 'new-node-prop',
|
|
242
|
+
url: 'http://127.0.0.1:7777',
|
|
243
|
+
});
|
|
244
|
+
// Wait for heartbeat to propagate peer list to follower
|
|
245
|
+
await waitFor(() => {
|
|
246
|
+
const peers = followerFixture.runtime.getStatus().peers;
|
|
247
|
+
return peers.some(p => p.id === 'new-node-prop');
|
|
248
|
+
}, 4000);
|
|
249
|
+
const followerPeers = followerFixture.runtime.getStatus().peers;
|
|
250
|
+
expect(followerPeers).toContainEqual({ id: 'new-node-prop', url: 'http://127.0.0.1:7777' });
|
|
251
|
+
});
|
|
252
|
+
it('follower applies peer list from heartbeat', async () => {
|
|
253
|
+
if (!(await canListenOnLocalhost()))
|
|
254
|
+
return;
|
|
255
|
+
const fixture = await startRuntimeFixture({
|
|
256
|
+
node_id: 'follower-apply',
|
|
257
|
+
election_timeout_min_ms: 5000,
|
|
258
|
+
election_timeout_max_ms: 5000,
|
|
259
|
+
});
|
|
260
|
+
// Send heartbeat with peer list
|
|
261
|
+
await postJson(fixture.config.public_url, '/cluster/v1/election/heartbeat', {
|
|
262
|
+
term: 5,
|
|
263
|
+
leader_id: 'external-leader',
|
|
264
|
+
fencing_token: 5,
|
|
265
|
+
peers: [
|
|
266
|
+
{ id: 'external-leader', url: 'http://127.0.0.1:6000' },
|
|
267
|
+
{ id: 'follower-apply', url: fixture.config.public_url },
|
|
268
|
+
{ id: 'peer-z', url: 'http://127.0.0.1:6001' },
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
const status = fixture.runtime.getStatus();
|
|
272
|
+
expect(status.peers).toHaveLength(3);
|
|
273
|
+
expect(status.peers).toContainEqual({ id: 'peer-z', url: 'http://127.0.0.1:6001' });
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
describe('quorum recalculation after membership change', () => {
|
|
277
|
+
it('quorum adjusts after adding a peer', async () => {
|
|
278
|
+
if (!(await canListenOnLocalhost()))
|
|
279
|
+
return;
|
|
280
|
+
// Start as a single node (quorum = 1)
|
|
281
|
+
const fixture = await startRuntimeFixture({
|
|
282
|
+
node_id: 'quorum-node',
|
|
283
|
+
election_timeout_min_ms: 80,
|
|
284
|
+
election_timeout_max_ms: 120,
|
|
285
|
+
heartbeat_interval_ms: 60,
|
|
286
|
+
});
|
|
287
|
+
await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
|
|
288
|
+
// Single node: quorum = 1
|
|
289
|
+
// Add two peers: 3 nodes total, quorum = 2
|
|
290
|
+
await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
291
|
+
node_id: 'peer-1',
|
|
292
|
+
url: 'http://127.0.0.1:9001',
|
|
293
|
+
});
|
|
294
|
+
await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
|
|
295
|
+
node_id: 'peer-2',
|
|
296
|
+
url: 'http://127.0.0.1:9002',
|
|
297
|
+
});
|
|
298
|
+
const status = fixture.runtime.getStatus();
|
|
299
|
+
expect(status.peers).toHaveLength(2);
|
|
300
|
+
// The node should still be functional with updated peer list
|
|
301
|
+
expect(status.is_leader).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
// --- Test helpers ---
|
|
305
|
+
async function startRuntimeFixture(overrides = {}) {
|
|
306
|
+
const attempts = overrides.listen_port ? 1 : 5;
|
|
307
|
+
let lastError;
|
|
308
|
+
for (let i = 0; i < attempts; i++) {
|
|
309
|
+
const config = await buildConfig(overrides);
|
|
310
|
+
try {
|
|
311
|
+
return await startRuntimeWithConfig(config);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
lastError = error;
|
|
315
|
+
const err = error;
|
|
316
|
+
if (!overrides.listen_port && err.code === 'EADDRINUSE') {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
throw lastError instanceof Error ? lastError : new Error('Failed to start runtime fixture');
|
|
323
|
+
}
|
|
324
|
+
async function startRuntimeWithConfig(config) {
|
|
325
|
+
const root = mkdtempSync(join(tmpdir(), `hive-membership-${config.node_id}-`));
|
|
326
|
+
const hiveDir = join(root, '.hive');
|
|
327
|
+
mkdirSync(hiveDir, { recursive: true });
|
|
328
|
+
const runtime = new ClusterRuntime(config, { hiveDir });
|
|
329
|
+
try {
|
|
330
|
+
await runtime.start();
|
|
331
|
+
activeRuntimes.push(runtime);
|
|
332
|
+
tempRoots.push(root);
|
|
333
|
+
return { root, hiveDir, config, runtime };
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
try {
|
|
337
|
+
await runtime.stop();
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Best effort cleanup for partial starts.
|
|
341
|
+
}
|
|
342
|
+
rmSync(root, { recursive: true, force: true });
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function buildConfig(overrides = {}) {
|
|
347
|
+
const port = overrides.listen_port ?? (await getFreePort());
|
|
348
|
+
const base = {
|
|
349
|
+
enabled: true,
|
|
350
|
+
node_id: 'node-test',
|
|
351
|
+
listen_host: '127.0.0.1',
|
|
352
|
+
listen_port: port,
|
|
353
|
+
public_url: `http://127.0.0.1:${port}`,
|
|
354
|
+
peers: [],
|
|
355
|
+
heartbeat_interval_ms: 100,
|
|
356
|
+
election_timeout_min_ms: 150,
|
|
357
|
+
election_timeout_max_ms: 250,
|
|
358
|
+
sync_interval_ms: 200,
|
|
359
|
+
request_timeout_ms: 600,
|
|
360
|
+
story_similarity_threshold: 0.8,
|
|
361
|
+
};
|
|
362
|
+
return {
|
|
363
|
+
...base,
|
|
364
|
+
...overrides,
|
|
365
|
+
public_url: overrides.public_url || base.public_url,
|
|
366
|
+
peers: overrides.peers || base.peers,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
async function postJson(baseUrl, path, body) {
|
|
370
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
371
|
+
method: 'POST',
|
|
372
|
+
headers: { 'Content-Type': 'application/json' },
|
|
373
|
+
body: JSON.stringify(body),
|
|
374
|
+
});
|
|
375
|
+
return (await res.json());
|
|
376
|
+
}
|
|
377
|
+
async function waitFor(predicate, timeoutMs) {
|
|
378
|
+
const start = Date.now();
|
|
379
|
+
while (Date.now() - start < timeoutMs) {
|
|
380
|
+
if (predicate())
|
|
381
|
+
return;
|
|
382
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
383
|
+
}
|
|
384
|
+
throw new Error('Timed out waiting for condition');
|
|
385
|
+
}
|
|
386
|
+
async function getFreePort() {
|
|
387
|
+
return new Promise((resolve, reject) => {
|
|
388
|
+
const server = createNetServer();
|
|
389
|
+
server.once('error', reject);
|
|
390
|
+
server.listen(0, '127.0.0.1', () => {
|
|
391
|
+
const address = server.address();
|
|
392
|
+
if (!address || typeof address === 'string') {
|
|
393
|
+
server.close(() => reject(new Error('Failed to allocate free port')));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const port = address.port;
|
|
397
|
+
server.close(err => {
|
|
398
|
+
if (err) {
|
|
399
|
+
reject(err);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
resolve(port);
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
async function canListenOnLocalhost() {
|
|
408
|
+
try {
|
|
409
|
+
await getFreePort();
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
//# sourceMappingURL=membership.test.js.map
|