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/src/cli/commands/req.ts
CHANGED
|
@@ -14,7 +14,12 @@ import { createAgent, getTechLead, updateAgent } from '../../db/queries/agents.j
|
|
|
14
14
|
import { createLog } from '../../db/queries/logs.js';
|
|
15
15
|
import { createRequirement, updateRequirement } from '../../db/queries/requirements.js';
|
|
16
16
|
import { getAllTeams } from '../../db/queries/teams.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
isTmuxAvailable,
|
|
19
|
+
isTmuxSessionRunning,
|
|
20
|
+
sendToTmuxSession,
|
|
21
|
+
spawnTmuxSession,
|
|
22
|
+
} from '../../tmux/manager.js';
|
|
18
23
|
import { getTechLeadSessionName } from '../../utils/instance.js';
|
|
19
24
|
import { withHiveContext } from '../../utils/with-hive-context.js';
|
|
20
25
|
import { startDashboard } from '../dashboard/index.js';
|
|
@@ -222,24 +227,32 @@ export const reqCommand = new Command('req')
|
|
|
222
227
|
);
|
|
223
228
|
|
|
224
229
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
const sessionAlreadyRunning = await isTmuxSessionRunning(sessionName);
|
|
231
|
+
|
|
232
|
+
if (sessionAlreadyRunning) {
|
|
233
|
+
// Session is already running — send the prompt to the existing session
|
|
234
|
+
// rather than killing it and losing in-progress work.
|
|
235
|
+
await sendToTmuxSession(sessionName, techLeadPrompt);
|
|
236
|
+
} else {
|
|
237
|
+
// Build CLI command using the configured runtime for Tech Lead
|
|
238
|
+
const chromeEnabled =
|
|
239
|
+
config.agents?.chrome_enabled === true && techLeadCliTool === 'claude';
|
|
240
|
+
const commandArgs = getCliRuntimeBuilder(techLeadCliTool).buildSpawnCommand(
|
|
241
|
+
techLeadModel,
|
|
242
|
+
techLeadSafetyMode,
|
|
243
|
+
{ chrome: chromeEnabled }
|
|
244
|
+
);
|
|
233
245
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
// Pass the prompt as initialPrompt so it's included as a CLI positional
|
|
247
|
+
// argument via $(cat ...). This delivers the full multi-line prompt
|
|
248
|
+
// reliably without tmux send-keys newline issues.
|
|
249
|
+
await spawnTmuxSession({
|
|
250
|
+
sessionName,
|
|
251
|
+
workDir: root,
|
|
252
|
+
commandArgs,
|
|
253
|
+
initialPrompt: techLeadPrompt,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
243
256
|
|
|
244
257
|
// Update agent and log spawning/planning events (atomic transaction)
|
|
245
258
|
await withTransaction(db.db, () => {
|
|
@@ -7,21 +7,53 @@ import type { ClusterEvent, VersionVector } from './replication.js';
|
|
|
7
7
|
interface DeltaRequest {
|
|
8
8
|
version_vector: VersionVector;
|
|
9
9
|
limit?: number;
|
|
10
|
+
fencing_token?: number;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface DeltaResponse {
|
|
13
14
|
events: ClusterEvent[];
|
|
14
15
|
version_vector: VersionVector;
|
|
16
|
+
fencing_token: number;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
const MAX_CLUSTER_REQUEST_BODY_BYTES = 1024 * 1024; // 1 MiB
|
|
18
20
|
|
|
21
|
+
export interface MembershipJoinRequest {
|
|
22
|
+
node_id: string;
|
|
23
|
+
url: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MembershipJoinResponse {
|
|
27
|
+
success: boolean;
|
|
28
|
+
leader_id: string | null;
|
|
29
|
+
leader_url: string | null;
|
|
30
|
+
peers: Array<{ id: string; url: string }>;
|
|
31
|
+
term: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MembershipLeaveRequest {
|
|
35
|
+
node_id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MembershipLeaveResponse {
|
|
39
|
+
success: boolean;
|
|
40
|
+
peers: Array<{ id: string; url: string }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
export interface ClusterHttpHandlers {
|
|
20
44
|
getStatus: () => unknown;
|
|
21
45
|
handleVoteRequest: (body: unknown) => unknown;
|
|
22
46
|
handleHeartbeat: (body: unknown) => unknown;
|
|
23
47
|
getDeltaFromCache: (vector: VersionVector, limit: number) => ClusterEvent[];
|
|
24
48
|
getVersionVectorCache: () => VersionVector;
|
|
49
|
+
getReplicationLag: () => unknown;
|
|
50
|
+
getFencingToken: () => number;
|
|
51
|
+
validateFencingToken: (token: number) => boolean;
|
|
52
|
+
isLeaderLeaseValid: () => boolean;
|
|
53
|
+
handleMembershipJoin: (body: MembershipJoinRequest) => MembershipJoinResponse;
|
|
54
|
+
handleMembershipLeave: (body: MembershipLeaveRequest) => MembershipLeaveResponse;
|
|
55
|
+
/** Returns a full snapshot of all replicated tables for state recovery. */
|
|
56
|
+
getSnapshot: () => unknown;
|
|
25
57
|
}
|
|
26
58
|
|
|
27
59
|
export class ClusterHttpServer {
|
|
@@ -72,6 +104,11 @@ export class ClusterHttpServer {
|
|
|
72
104
|
return;
|
|
73
105
|
}
|
|
74
106
|
|
|
107
|
+
if (method === 'GET' && path === '/cluster/v1/snapshot') {
|
|
108
|
+
sendJson(res, 200, this.handlers.getSnapshot());
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
75
112
|
if (method === 'POST' && path === '/cluster/v1/election/request-vote') {
|
|
76
113
|
const body = await readJsonBody(req);
|
|
77
114
|
const response = this.handlers.handleVoteRequest(body);
|
|
@@ -86,8 +123,25 @@ export class ClusterHttpServer {
|
|
|
86
123
|
return;
|
|
87
124
|
}
|
|
88
125
|
|
|
126
|
+
if (method === 'GET' && path === '/cluster/v1/replication-lag') {
|
|
127
|
+
sendJson(res, 200, this.handlers.getReplicationLag());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
if (method === 'POST' && path === '/cluster/v1/events/delta') {
|
|
90
132
|
const body = (await readJsonBody(req)) as Partial<DeltaRequest>;
|
|
133
|
+
|
|
134
|
+
// Validate fencing token if provided — reject stale-leader requests
|
|
135
|
+
if (typeof body.fencing_token === 'number') {
|
|
136
|
+
if (!this.handlers.validateFencingToken(body.fencing_token)) {
|
|
137
|
+
sendJson(res, 409, {
|
|
138
|
+
error: 'Fencing token rejected: stale leader epoch',
|
|
139
|
+
fencing_token: this.handlers.getFencingToken(),
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
91
145
|
const vector = toVersionVector(body.version_vector);
|
|
92
146
|
const limit =
|
|
93
147
|
typeof body.limit === 'number' && Number.isFinite(body.limit) && body.limit > 0
|
|
@@ -98,10 +152,36 @@ export class ClusterHttpServer {
|
|
|
98
152
|
sendJson(res, 200, {
|
|
99
153
|
events,
|
|
100
154
|
version_vector: this.handlers.getVersionVectorCache(),
|
|
155
|
+
fencing_token: this.handlers.getFencingToken(),
|
|
101
156
|
} satisfies DeltaResponse);
|
|
102
157
|
return;
|
|
103
158
|
}
|
|
104
159
|
|
|
160
|
+
if (method === 'POST' && path === '/cluster/v1/membership/join') {
|
|
161
|
+
const body = (await readJsonBody(req)) as Partial<MembershipJoinRequest>;
|
|
162
|
+
if (typeof body.node_id !== 'string' || typeof body.url !== 'string') {
|
|
163
|
+
sendJson(res, 400, { error: 'node_id and url are required' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const response = this.handlers.handleMembershipJoin({
|
|
167
|
+
node_id: body.node_id,
|
|
168
|
+
url: body.url,
|
|
169
|
+
});
|
|
170
|
+
sendJson(res, response.success ? 200 : 307, response);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (method === 'POST' && path === '/cluster/v1/membership/leave') {
|
|
175
|
+
const body = (await readJsonBody(req)) as Partial<MembershipLeaveRequest>;
|
|
176
|
+
if (typeof body.node_id !== 'string') {
|
|
177
|
+
sendJson(res, 400, { error: 'node_id is required' });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const response = this.handlers.handleMembershipLeave({ node_id: body.node_id });
|
|
181
|
+
sendJson(res, response.success ? 200 : 400, response);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
105
185
|
sendJson(res, 404, { error: 'Not found' });
|
|
106
186
|
} catch (error) {
|
|
107
187
|
if (error instanceof HttpRequestError) {
|
|
@@ -131,10 +131,14 @@ describe('distributed runtime transport and status', () => {
|
|
|
131
131
|
is_leader: false,
|
|
132
132
|
leader_id: null,
|
|
133
133
|
leader_url: null,
|
|
134
|
+
fencing_token: 0,
|
|
135
|
+
leader_lease_valid: false,
|
|
136
|
+
leader_lease_duration_ms: 0,
|
|
134
137
|
raft_commit_index: 12,
|
|
135
138
|
raft_last_applied: 0,
|
|
136
139
|
raft_last_log_index: 9,
|
|
137
140
|
peers: [{ id: 'peer-a', url: 'http://127.0.0.1:8080' }],
|
|
141
|
+
is_catching_up: false,
|
|
138
142
|
} satisfies ClusterStatus);
|
|
139
143
|
});
|
|
140
144
|
|
|
@@ -477,6 +481,11 @@ describe('distributed runtime sync behavior', () => {
|
|
|
477
481
|
imported_events_applied: 0,
|
|
478
482
|
merged_duplicate_stories: 0,
|
|
479
483
|
durable_log_entries_appended: 0,
|
|
484
|
+
log_entries_compacted: 0,
|
|
485
|
+
cluster_events_pruned: 0,
|
|
486
|
+
used_snapshot_recovery: false,
|
|
487
|
+
catch_up_applied: 0,
|
|
488
|
+
catch_up_total: 0,
|
|
480
489
|
});
|
|
481
490
|
|
|
482
491
|
db.close();
|
|
@@ -646,6 +646,174 @@ describe('durable raft metadata store', () => {
|
|
|
646
646
|
});
|
|
647
647
|
});
|
|
648
648
|
|
|
649
|
+
describe('log compaction and snapshotting', () => {
|
|
650
|
+
it('creates a snapshot and compacts the raft log', () => {
|
|
651
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-compaction-'));
|
|
652
|
+
tempDirs.push(dir);
|
|
653
|
+
|
|
654
|
+
const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-compact' });
|
|
655
|
+
store.setState({ current_term: 5 });
|
|
656
|
+
|
|
657
|
+
// Append 20 entries
|
|
658
|
+
for (let i = 0; i < 20; i++) {
|
|
659
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: i } });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
expect(store.getLogEntryCount()).toBe(20);
|
|
663
|
+
expect(store.getState().last_log_index).toBe(20);
|
|
664
|
+
|
|
665
|
+
// Create snapshot at current state
|
|
666
|
+
const snapshot = store.createSnapshot({ 'node-compact': 10 });
|
|
667
|
+
expect(snapshot.last_included_index).toBe(20);
|
|
668
|
+
expect(snapshot.last_included_term).toBe(5);
|
|
669
|
+
expect(snapshot.version_vector).toEqual({ 'node-compact': 10 });
|
|
670
|
+
expect(existsSync(join(dir, 'raft-snapshot.json'))).toBe(true);
|
|
671
|
+
|
|
672
|
+
// Compact the log
|
|
673
|
+
const result = store.compactLog();
|
|
674
|
+
expect(result.entries_removed).toBe(20);
|
|
675
|
+
expect(result.entries_retained).toBe(0);
|
|
676
|
+
expect(result.snapshot_index).toBe(20);
|
|
677
|
+
expect(store.getLogEntryCount()).toBe(0);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it('retains entries after the snapshot index during compaction', () => {
|
|
681
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-compaction-'));
|
|
682
|
+
tempDirs.push(dir);
|
|
683
|
+
|
|
684
|
+
const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-retain' });
|
|
685
|
+
|
|
686
|
+
// Append 10 entries
|
|
687
|
+
for (let i = 0; i < 10; i++) {
|
|
688
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: i } });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Snapshot at index 5 (manually set to test partial compaction)
|
|
692
|
+
const snapshot = store.createSnapshot({ 'node-retain': 5 });
|
|
693
|
+
expect(snapshot.last_included_index).toBe(10);
|
|
694
|
+
|
|
695
|
+
// Append 5 more entries AFTER snapshot
|
|
696
|
+
for (let i = 0; i < 5; i++) {
|
|
697
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: 10 + i } });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
expect(store.getLogEntryCount()).toBe(15);
|
|
701
|
+
|
|
702
|
+
const result = store.compactLog();
|
|
703
|
+
expect(result.entries_removed).toBe(10);
|
|
704
|
+
expect(result.entries_retained).toBe(5);
|
|
705
|
+
expect(store.getLogEntryCount()).toBe(5);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it('restores snapshot and event IDs across restarts', () => {
|
|
709
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-snapshot-restart-'));
|
|
710
|
+
tempDirs.push(dir);
|
|
711
|
+
|
|
712
|
+
const first = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-snap' });
|
|
713
|
+
first.setState({ current_term: 3 });
|
|
714
|
+
|
|
715
|
+
const events: ClusterEvent[] = [
|
|
716
|
+
buildStoryEvent({
|
|
717
|
+
event_id: 'node-a:1',
|
|
718
|
+
row_id: 'STORY-A',
|
|
719
|
+
version: { actor_id: 'node-a', actor_counter: 1, logical_ts: 1000 },
|
|
720
|
+
}),
|
|
721
|
+
buildStoryEvent({
|
|
722
|
+
event_id: 'node-b:1',
|
|
723
|
+
row_id: 'STORY-B',
|
|
724
|
+
version: { actor_id: 'node-b', actor_counter: 1, logical_ts: 2000 },
|
|
725
|
+
}),
|
|
726
|
+
];
|
|
727
|
+
first.appendClusterEvents(events, 3);
|
|
728
|
+
first.createSnapshot({ 'node-a': 1, 'node-b': 1 });
|
|
729
|
+
first.compactLog();
|
|
730
|
+
|
|
731
|
+
// Restart
|
|
732
|
+
const second = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-snap' });
|
|
733
|
+
const restored = second.getState();
|
|
734
|
+
const snap = second.getSnapshot();
|
|
735
|
+
|
|
736
|
+
expect(restored.last_log_index).toBeGreaterThanOrEqual(2);
|
|
737
|
+
expect(snap).not.toBeNull();
|
|
738
|
+
expect(snap!.version_vector).toEqual({ 'node-a': 1, 'node-b': 1 });
|
|
739
|
+
|
|
740
|
+
// Event IDs should be restored from snapshot
|
|
741
|
+
expect(second.hasEvent('node-a:1')).toBe(true);
|
|
742
|
+
expect(second.hasEvent('node-b:1')).toBe(true);
|
|
743
|
+
|
|
744
|
+
// Deduplication should still work
|
|
745
|
+
const deduped = second.appendClusterEvents(events, 3);
|
|
746
|
+
expect(deduped).toBe(0);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it('compaction without snapshot is a no-op', () => {
|
|
750
|
+
const dir = mkdtempSync(join(tmpdir(), 'hive-no-snap-'));
|
|
751
|
+
tempDirs.push(dir);
|
|
752
|
+
|
|
753
|
+
const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-nosn' });
|
|
754
|
+
for (let i = 0; i < 5; i++) {
|
|
755
|
+
store.appendEntry({ type: 'runtime', metadata: { seq: i } });
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const result = store.compactLog();
|
|
759
|
+
expect(result.entries_removed).toBe(0);
|
|
760
|
+
expect(result.entries_retained).toBe(0);
|
|
761
|
+
expect(store.getLogEntryCount()).toBe(5);
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
describe('cluster_events pruning', () => {
|
|
766
|
+
it('prunes old events keeping the most recent ones', async () => {
|
|
767
|
+
const db = await createTestDatabase();
|
|
768
|
+
ensureClusterTables(db, 'node-prune');
|
|
769
|
+
|
|
770
|
+
// Emit 10 events
|
|
771
|
+
for (let i = 0; i < 10; i++) {
|
|
772
|
+
const { emitLocalEvent } = await import('./events.js');
|
|
773
|
+
emitLocalEvent(db, 'node-prune', {
|
|
774
|
+
table_name: 'stories',
|
|
775
|
+
row_id: `STORY-${i}`,
|
|
776
|
+
op: 'upsert',
|
|
777
|
+
payload: storyPayload(`STORY-${i}`),
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
const { getClusterEventCount, pruneClusterEvents } = await import('./events.js');
|
|
782
|
+
expect(getClusterEventCount(db)).toBe(10);
|
|
783
|
+
|
|
784
|
+
// Prune to keep only 5
|
|
785
|
+
const pruned = pruneClusterEvents(db, 5);
|
|
786
|
+
expect(pruned).toBe(5);
|
|
787
|
+
expect(getClusterEventCount(db)).toBe(5);
|
|
788
|
+
|
|
789
|
+
db.close();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('does not prune when count is below threshold', async () => {
|
|
793
|
+
const db = await createTestDatabase();
|
|
794
|
+
ensureClusterTables(db, 'node-no-prune');
|
|
795
|
+
|
|
796
|
+
const { emitLocalEvent } = await import('./events.js');
|
|
797
|
+
for (let i = 0; i < 3; i++) {
|
|
798
|
+
emitLocalEvent(db, 'node-no-prune', {
|
|
799
|
+
table_name: 'stories',
|
|
800
|
+
row_id: `STORY-${i}`,
|
|
801
|
+
op: 'upsert',
|
|
802
|
+
payload: storyPayload(`STORY-${i}`),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const { getClusterEventCount, pruneClusterEvents } = await import('./events.js');
|
|
807
|
+
expect(getClusterEventCount(db)).toBe(3);
|
|
808
|
+
|
|
809
|
+
const pruned = pruneClusterEvents(db, 10);
|
|
810
|
+
expect(pruned).toBe(0);
|
|
811
|
+
expect(getClusterEventCount(db)).toBe(3);
|
|
812
|
+
|
|
813
|
+
db.close();
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
649
817
|
function storyPayload(
|
|
650
818
|
id: string,
|
|
651
819
|
overrides: Partial<Record<string, unknown>> = {}
|
package/src/cluster/events.ts
CHANGED
|
@@ -96,6 +96,13 @@ export function ensureClusterTables(db: Database, nodeId: string): void {
|
|
|
96
96
|
`
|
|
97
97
|
);
|
|
98
98
|
|
|
99
|
+
// Add snapshot_version_vector column if it doesn't exist yet (backward-compat migration)
|
|
100
|
+
try {
|
|
101
|
+
run(db, 'ALTER TABLE cluster_state ADD COLUMN snapshot_version_vector TEXT');
|
|
102
|
+
} catch {
|
|
103
|
+
// Column already exists — ignore
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
const state = queryOne<{ id: number }>(db, 'SELECT id FROM cluster_state WHERE id = 1');
|
|
100
107
|
const now = new Date().toISOString();
|
|
101
108
|
|
|
@@ -128,6 +135,53 @@ export function getVersionVector(db: Database): VersionVector {
|
|
|
128
135
|
return vector;
|
|
129
136
|
}
|
|
130
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Returns the snapshot version vector stored after the last snapshot-based recovery.
|
|
140
|
+
* Empty object if no snapshot has been applied.
|
|
141
|
+
*/
|
|
142
|
+
export function getSnapshotVersionVector(db: Database): VersionVector {
|
|
143
|
+
const row = queryOne<{ snapshot_version_vector: string | null }>(
|
|
144
|
+
db,
|
|
145
|
+
'SELECT snapshot_version_vector FROM cluster_state WHERE id = 1'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
if (!row?.snapshot_version_vector) return {};
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(row.snapshot_version_vector) as VersionVector;
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Persists the snapshot version vector so that future delta requests start
|
|
159
|
+
* from this point rather than from the (empty) event log.
|
|
160
|
+
*/
|
|
161
|
+
export function setSnapshotVersionVector(db: Database, vector: VersionVector): void {
|
|
162
|
+
run(db, 'UPDATE cluster_state SET snapshot_version_vector = ? WHERE id = 1', [
|
|
163
|
+
JSON.stringify(vector),
|
|
164
|
+
]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Returns the effective version vector for delta-sync requests.
|
|
169
|
+
* Takes the max per actor between the event-log-derived vector and any
|
|
170
|
+
* snapshot vector stored from a previous snapshot-based recovery.
|
|
171
|
+
* This prevents re-requesting events that were already covered by a snapshot.
|
|
172
|
+
*/
|
|
173
|
+
export function getEffectiveVersionVector(db: Database): VersionVector {
|
|
174
|
+
const eventVector = getVersionVector(db);
|
|
175
|
+
const snapshotVector = getSnapshotVersionVector(db);
|
|
176
|
+
|
|
177
|
+
const effective: VersionVector = { ...snapshotVector };
|
|
178
|
+
for (const [actor, counter] of Object.entries(eventVector)) {
|
|
179
|
+
effective[actor] = Math.max(effective[actor] ?? 0, counter);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return effective;
|
|
183
|
+
}
|
|
184
|
+
|
|
131
185
|
export function getAllClusterEvents(db: Database): ClusterEvent[] {
|
|
132
186
|
const rows = queryAll<ClusterEventRow>(
|
|
133
187
|
db,
|
|
@@ -204,6 +258,42 @@ export function emitLocalEvent(
|
|
|
204
258
|
);
|
|
205
259
|
}
|
|
206
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Prune old cluster_events rows, retaining only the most recent `retainCount` events.
|
|
263
|
+
* Returns the number of rows deleted.
|
|
264
|
+
*/
|
|
265
|
+
export function pruneClusterEvents(db: Database, retainCount: number): number {
|
|
266
|
+
if (retainCount <= 0) return 0;
|
|
267
|
+
|
|
268
|
+
const countRow = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
269
|
+
const total = countRow?.total || 0;
|
|
270
|
+
|
|
271
|
+
if (total <= retainCount) return 0;
|
|
272
|
+
|
|
273
|
+
// Delete events that are not in the most recent `retainCount` by logical_ts ordering.
|
|
274
|
+
// We keep the newest events and delete the oldest.
|
|
275
|
+
run(
|
|
276
|
+
db,
|
|
277
|
+
`
|
|
278
|
+
DELETE FROM cluster_events
|
|
279
|
+
WHERE event_id NOT IN (
|
|
280
|
+
SELECT event_id FROM cluster_events
|
|
281
|
+
ORDER BY logical_ts DESC, actor_id DESC, actor_counter DESC
|
|
282
|
+
LIMIT ?
|
|
283
|
+
)
|
|
284
|
+
`,
|
|
285
|
+
[retainCount]
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const afterRow = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
289
|
+
return total - (afterRow?.total || 0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function getClusterEventCount(db: Database): number {
|
|
293
|
+
const row = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
|
|
294
|
+
return row?.total || 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
207
297
|
export function fetchTableSnapshots(db: Database, adapter: TableAdapter): TableRowSnapshot[] {
|
|
208
298
|
const rows = queryAll<Record<string, unknown>>(db, adapter.selectSql);
|
|
209
299
|
|
|
@@ -6,11 +6,14 @@ import type { RaftStateMachine } from './raft-state-machine.js';
|
|
|
6
6
|
interface HeartbeatRequest {
|
|
7
7
|
term: number;
|
|
8
8
|
leader_id: string;
|
|
9
|
+
fencing_token: number;
|
|
10
|
+
peers?: Array<{ id: string; url: string }>;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
interface HeartbeatResponse {
|
|
12
14
|
term: number;
|
|
13
15
|
success: boolean;
|
|
16
|
+
fencing_token: number;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export interface HeartbeatManagerDeps {
|
|
@@ -18,6 +21,7 @@ export interface HeartbeatManagerDeps {
|
|
|
18
21
|
postJson: <T>(peer: ClusterPeerConfig, path: string, body: unknown) => Promise<T | null>;
|
|
19
22
|
isActive: () => boolean;
|
|
20
23
|
handleBackgroundError: (error: unknown) => void;
|
|
24
|
+
onPeersUpdated?: (peers: ClusterPeerConfig[]) => void;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export class HeartbeatManager {
|
|
@@ -47,20 +51,23 @@ export class HeartbeatManager {
|
|
|
47
51
|
if (!this.deps.isActive()) return;
|
|
48
52
|
|
|
49
53
|
const { raft } = this.deps;
|
|
54
|
+
const peers = raft.getPeers();
|
|
50
55
|
|
|
51
56
|
const heartbeat: HeartbeatRequest = {
|
|
52
57
|
term: raft.currentTerm,
|
|
53
58
|
leader_id: this.config.node_id,
|
|
59
|
+
fencing_token: raft.getFencingToken(),
|
|
60
|
+
peers: peers.map(p => ({ id: p.id, url: p.url })),
|
|
54
61
|
};
|
|
55
62
|
|
|
56
63
|
raft.appendDurableEntry('heartbeat_sent', {
|
|
57
64
|
term: raft.currentTerm,
|
|
58
65
|
leader_id: this.config.node_id,
|
|
59
|
-
peer_count:
|
|
66
|
+
peer_count: peers.filter(peer => peer.id !== this.config.node_id).length,
|
|
60
67
|
});
|
|
61
68
|
|
|
62
69
|
await Promise.all(
|
|
63
|
-
|
|
70
|
+
peers
|
|
64
71
|
.filter(peer => peer.id !== this.config.node_id)
|
|
65
72
|
.map(async peer => {
|
|
66
73
|
const response = await this.deps.postJson<HeartbeatResponse>(
|
|
@@ -69,8 +76,11 @@ export class HeartbeatManager {
|
|
|
69
76
|
heartbeat
|
|
70
77
|
);
|
|
71
78
|
|
|
72
|
-
if (response
|
|
73
|
-
|
|
79
|
+
if (response) {
|
|
80
|
+
const remoteTerm = Math.max(response.term, response.fencing_token ?? 0);
|
|
81
|
+
if (remoteTerm > raft.currentTerm) {
|
|
82
|
+
raft.stepDown(remoteTerm, peer.id);
|
|
83
|
+
}
|
|
74
84
|
}
|
|
75
85
|
})
|
|
76
86
|
);
|
|
@@ -82,9 +92,16 @@ export class HeartbeatManager {
|
|
|
82
92
|
const request = body as Partial<HeartbeatRequest>;
|
|
83
93
|
const term = Number(request.term || 0);
|
|
84
94
|
const leaderId = typeof request.leader_id === 'string' ? request.leader_id : null;
|
|
95
|
+
const fencingToken = Number(request.fencing_token ?? term);
|
|
85
96
|
|
|
97
|
+
// Reject heartbeats from stale leaders
|
|
86
98
|
if (term < raft.currentTerm) {
|
|
87
|
-
return { term: raft.currentTerm, success: false };
|
|
99
|
+
return { term: raft.currentTerm, success: false, fencing_token: raft.getFencingToken() };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Reject if fencing token doesn't match the heartbeat term
|
|
103
|
+
if (fencingToken < term) {
|
|
104
|
+
return { term: raft.currentTerm, success: false, fencing_token: raft.getFencingToken() };
|
|
88
105
|
}
|
|
89
106
|
|
|
90
107
|
const changed =
|
|
@@ -98,15 +115,40 @@ export class HeartbeatManager {
|
|
|
98
115
|
raft.persistRaftState();
|
|
99
116
|
}
|
|
100
117
|
|
|
118
|
+
// Update lease: record that we received a valid heartbeat now
|
|
119
|
+
raft.lastHeartbeatReceivedAt = Date.now();
|
|
101
120
|
raft.resetElectionDeadline();
|
|
102
121
|
|
|
122
|
+
// Apply peer list from leader if present
|
|
123
|
+
const requestPeers = (request as { peers?: unknown }).peers;
|
|
124
|
+
if (Array.isArray(requestPeers)) {
|
|
125
|
+
const parsed = parsePeerList(requestPeers);
|
|
126
|
+
if (parsed.length > 0) {
|
|
127
|
+
raft.setPeers(parsed);
|
|
128
|
+
this.deps.onPeersUpdated?.(parsed);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
103
132
|
if (changed) {
|
|
104
133
|
raft.appendDurableEntry('heartbeat_received', {
|
|
105
134
|
term,
|
|
106
135
|
leader_id: leaderId,
|
|
136
|
+
fencing_token: fencingToken,
|
|
107
137
|
});
|
|
108
138
|
}
|
|
109
139
|
|
|
110
|
-
return { term: raft.currentTerm, success: true };
|
|
140
|
+
return { term: raft.currentTerm, success: true, fencing_token: raft.getFencingToken() };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parsePeerList(input: unknown[]): ClusterPeerConfig[] {
|
|
145
|
+
const peers: ClusterPeerConfig[] = [];
|
|
146
|
+
for (const item of input) {
|
|
147
|
+
if (!item || typeof item !== 'object') continue;
|
|
148
|
+
const p = item as { id?: unknown; url?: unknown };
|
|
149
|
+
if (typeof p.id === 'string' && typeof p.url === 'string') {
|
|
150
|
+
peers.push({ id: p.id, url: p.url });
|
|
151
|
+
}
|
|
111
152
|
}
|
|
153
|
+
return peers;
|
|
112
154
|
}
|