hungry-ghost-hive 0.45.0 → 0.47.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/manager/index.js +6 -4
- package/dist/cli/commands/manager/index.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/cli/dashboard/index.d.ts.map +1 -1
- package/dist/cli/dashboard/index.js +14 -2
- package/dist/cli/dashboard/index.js.map +1 -1
- package/dist/cli/dashboard/panels/agents.d.ts +1 -1
- package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
- package/dist/cli/dashboard/panels/agents.js +7 -3
- package/dist/cli/dashboard/panels/agents.js.map +1 -1
- package/dist/cli/dashboard/panels/escalations.d.ts +1 -1
- package/dist/cli/dashboard/panels/escalations.d.ts.map +1 -1
- package/dist/cli/dashboard/panels/escalations.js +7 -1
- package/dist/cli/dashboard/panels/escalations.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/connectors/auth/jira.js +1 -1
- package/dist/connectors/auth/jira.js.map +1 -1
- package/dist/connectors/auth/jira.test.js +18 -0
- package/dist/connectors/auth/jira.test.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/manager/index.ts +6 -4
- package/src/cli/commands/req-spawn.test.ts +153 -0
- package/src/cli/commands/req.ts +31 -18
- package/src/cli/dashboard/index.ts +14 -2
- package/src/cli/dashboard/panels/agents.ts +12 -3
- package/src/cli/dashboard/panels/escalations.ts +12 -1
- 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/connectors/auth/jira.test.ts +21 -0
- package/src/connectors/auth/jira.ts +1 -1
- 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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Database } from 'sql.js';
|
|
2
2
|
import type { ClusterConfig } from '../config/schema.js';
|
|
3
|
+
import { type MembershipJoinRequest, type MembershipJoinResponse, type MembershipLeaveRequest, type MembershipLeaveResponse } from './cluster-http-server.js';
|
|
4
|
+
import { type ClusterEvent, type VersionVector } from './replication.js';
|
|
3
5
|
type NodeRole = 'leader' | 'follower' | 'candidate';
|
|
4
6
|
interface ClusterRuntimeOptions {
|
|
5
7
|
hiveDir?: string;
|
|
@@ -17,6 +19,9 @@ export interface ClusterStatus {
|
|
|
17
19
|
is_leader: boolean;
|
|
18
20
|
leader_id: string | null;
|
|
19
21
|
leader_url: string | null;
|
|
22
|
+
fencing_token: number;
|
|
23
|
+
leader_lease_valid: boolean;
|
|
24
|
+
leader_lease_duration_ms: number;
|
|
20
25
|
raft_commit_index: number;
|
|
21
26
|
raft_last_applied: number;
|
|
22
27
|
raft_last_log_index: number;
|
|
@@ -24,12 +29,38 @@ export interface ClusterStatus {
|
|
|
24
29
|
id: string;
|
|
25
30
|
url: string;
|
|
26
31
|
}>;
|
|
32
|
+
/** True while the node is performing snapshot-based catch-up and not yet election-eligible. */
|
|
33
|
+
is_catching_up: boolean;
|
|
27
34
|
}
|
|
28
35
|
export interface ClusterSyncResult {
|
|
29
36
|
local_events_emitted: number;
|
|
30
37
|
imported_events_applied: number;
|
|
31
38
|
merged_duplicate_stories: number;
|
|
32
39
|
durable_log_entries_appended: number;
|
|
40
|
+
log_entries_compacted: number;
|
|
41
|
+
cluster_events_pruned: number;
|
|
42
|
+
/** True when this sync triggered snapshot-based recovery rather than delta sync. */
|
|
43
|
+
used_snapshot_recovery: boolean;
|
|
44
|
+
/** Number of rows applied from the snapshot (0 when delta sync was used). */
|
|
45
|
+
catch_up_applied: number;
|
|
46
|
+
/** Total rows in the snapshot (0 when delta sync was used). */
|
|
47
|
+
catch_up_total: number;
|
|
48
|
+
}
|
|
49
|
+
export interface PeerReplicationLag {
|
|
50
|
+
peer_id: string;
|
|
51
|
+
peer_url: string;
|
|
52
|
+
reachable: boolean;
|
|
53
|
+
events_behind: number;
|
|
54
|
+
last_sync_at: string | null;
|
|
55
|
+
last_sync_duration_ms: number | null;
|
|
56
|
+
last_sync_events_applied: number;
|
|
57
|
+
}
|
|
58
|
+
export interface ReplicationLagSummary {
|
|
59
|
+
node_id: string;
|
|
60
|
+
total_local_events: number;
|
|
61
|
+
version_vector: VersionVector;
|
|
62
|
+
peers: PeerReplicationLag[];
|
|
63
|
+
last_sync_at: string | null;
|
|
33
64
|
}
|
|
34
65
|
export declare class ClusterRuntime {
|
|
35
66
|
private readonly config;
|
|
@@ -38,6 +69,11 @@ export declare class ClusterRuntime {
|
|
|
38
69
|
private stopping;
|
|
39
70
|
private eventCache;
|
|
40
71
|
private versionVectorCache;
|
|
72
|
+
private lastCompactionAt;
|
|
73
|
+
private peerLagMap;
|
|
74
|
+
private lastSyncAt;
|
|
75
|
+
/** Cached full snapshot refreshed on every sync, served to recovering nodes. */
|
|
76
|
+
private cachedSnapshot;
|
|
41
77
|
private readonly raft;
|
|
42
78
|
private readonly heartbeat;
|
|
43
79
|
private readonly httpServer;
|
|
@@ -46,16 +82,58 @@ export declare class ClusterRuntime {
|
|
|
46
82
|
stop(): Promise<void>;
|
|
47
83
|
isEnabled(): boolean;
|
|
48
84
|
isLeader(): boolean;
|
|
85
|
+
getReplicationLag(): ReplicationLagSummary;
|
|
49
86
|
getStatus(): ClusterStatus;
|
|
50
87
|
sync(db: Database): Promise<ClusterSyncResult>;
|
|
88
|
+
handleMembershipJoin(request: MembershipJoinRequest): MembershipJoinResponse;
|
|
89
|
+
handleMembershipLeave(request: MembershipLeaveRequest): MembershipLeaveResponse;
|
|
90
|
+
private maybeCompact;
|
|
51
91
|
private refreshCache;
|
|
52
92
|
private pullEventsFromPeers;
|
|
93
|
+
/**
|
|
94
|
+
* Returns true when the delta response is missing events the peer should have.
|
|
95
|
+
* This happens when the peer's event cache has been truncated (log compaction)
|
|
96
|
+
* and can no longer provide all events since our last known version.
|
|
97
|
+
*/
|
|
98
|
+
private isDeltaInsufficient;
|
|
99
|
+
/**
|
|
100
|
+
* Requests a full snapshot from the given peer and applies it locally.
|
|
101
|
+
* Marks the node as no longer catching up once complete.
|
|
102
|
+
* Returns { applied, total } on success, null on failure.
|
|
103
|
+
*/
|
|
104
|
+
private recoverFromSnapshot;
|
|
105
|
+
/**
|
|
106
|
+
* Applies a snapshot to the local database, upserting all rows from all tables.
|
|
107
|
+
* Stores the snapshot's version vector so future delta requests start from here.
|
|
108
|
+
*/
|
|
109
|
+
private applySnapshot;
|
|
110
|
+
/**
|
|
111
|
+
* Builds a full snapshot of all replicated tables from the current db state.
|
|
112
|
+
* Called during sync to keep cachedSnapshot fresh for the HTTP endpoint.
|
|
113
|
+
*/
|
|
114
|
+
private buildSnapshot;
|
|
53
115
|
private requestDelta;
|
|
116
|
+
private requestSnapshot;
|
|
54
117
|
private getDeltaFromCache;
|
|
55
118
|
private postJson;
|
|
119
|
+
private getJson;
|
|
56
120
|
private handleBackgroundError;
|
|
57
121
|
private validateNetworkSecurity;
|
|
58
122
|
}
|
|
123
|
+
export declare function fetchReplicationLag(config: ClusterConfig): Promise<ReplicationLagSummary | null>;
|
|
124
|
+
/**
|
|
125
|
+
* Fetches recent cluster events from the local runtime via the delta endpoint.
|
|
126
|
+
* Uses an empty version vector to request recent events up to the given limit.
|
|
127
|
+
*/
|
|
128
|
+
export declare function fetchLocalClusterEvents(config: ClusterConfig, limit?: number): Promise<ClusterEvent[] | null>;
|
|
129
|
+
/**
|
|
130
|
+
* POSTs to the local cluster runtime at the given path.
|
|
131
|
+
*/
|
|
132
|
+
export declare function postToLocalCluster<T>(config: ClusterConfig, path: string, body: unknown): Promise<T | null>;
|
|
133
|
+
/**
|
|
134
|
+
* POSTs to a peer cluster node at the given URL and path.
|
|
135
|
+
*/
|
|
136
|
+
export declare function postToPeerCluster<T>(peerUrl: string, path: string, body: unknown, options: ClusterStatusFetchOptions): Promise<T | null>;
|
|
59
137
|
export declare function fetchLocalClusterStatus(config: ClusterConfig): Promise<ClusterStatus | null>;
|
|
60
138
|
export declare function fetchClusterStatusFromUrl(url: string, options: ClusterStatusFetchOptions): Promise<ClusterStatus | null>;
|
|
61
139
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/cluster/runtime.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,aAAa,EAAqB,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/cluster/runtime.ts"],"names":[],"mappings":"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"}
|
package/dist/cluster/runtime.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Licensed under the Hungry Ghost Hive License. See LICENSE.
|
|
2
2
|
import { join } from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { queryAll } from '../db/client.js';
|
|
4
|
+
import { REPLICATED_TABLES } from './adapters.js';
|
|
5
|
+
import { ClusterHttpServer, } from './cluster-http-server.js';
|
|
4
6
|
import { HeartbeatManager } from './heartbeat-manager.js';
|
|
5
7
|
import { RaftStateMachine } from './raft-state-machine.js';
|
|
6
|
-
import { applyRemoteEvents, ensureClusterTables, getAllClusterEvents, getVersionVector, mergeSimilarStories, scanLocalChanges, } from './replication.js';
|
|
8
|
+
import { applyRemoteEvents, ensureClusterTables, getAllClusterEvents, getClusterEventCount, getEffectiveVersionVector, getVersionVector, mergeSimilarStories, pruneClusterEvents, scanLocalChanges, setSnapshotVersionVector, } from './replication.js';
|
|
7
9
|
export class ClusterRuntime {
|
|
8
10
|
config;
|
|
9
11
|
options;
|
|
@@ -11,6 +13,11 @@ export class ClusterRuntime {
|
|
|
11
13
|
stopping = false;
|
|
12
14
|
eventCache = [];
|
|
13
15
|
versionVectorCache = {};
|
|
16
|
+
lastCompactionAt = 0;
|
|
17
|
+
peerLagMap = new Map();
|
|
18
|
+
lastSyncAt = null;
|
|
19
|
+
/** Cached full snapshot refreshed on every sync, served to recovering nodes. */
|
|
20
|
+
cachedSnapshot = null;
|
|
14
21
|
raft;
|
|
15
22
|
heartbeat;
|
|
16
23
|
httpServer;
|
|
@@ -27,6 +34,10 @@ export class ClusterRuntime {
|
|
|
27
34
|
postJson: (peer, path, body) => this.postJson(peer, path, body),
|
|
28
35
|
isActive: () => this.started && !this.stopping,
|
|
29
36
|
handleBackgroundError: error => this.handleBackgroundError(error),
|
|
37
|
+
onPeersUpdated: peers => {
|
|
38
|
+
// Follower received updated peer list from leader via heartbeat
|
|
39
|
+
this.raft.setPeers(peers);
|
|
40
|
+
},
|
|
30
41
|
});
|
|
31
42
|
this.httpServer = new ClusterHttpServer(config, {
|
|
32
43
|
getStatus: () => this.getStatus(),
|
|
@@ -34,6 +45,13 @@ export class ClusterRuntime {
|
|
|
34
45
|
handleHeartbeat: body => this.heartbeat.handleHeartbeat(body),
|
|
35
46
|
getDeltaFromCache: (vector, limit) => this.getDeltaFromCache(vector, limit),
|
|
36
47
|
getVersionVectorCache: () => this.versionVectorCache,
|
|
48
|
+
getReplicationLag: () => this.getReplicationLag(),
|
|
49
|
+
getFencingToken: () => this.raft.getFencingToken(),
|
|
50
|
+
validateFencingToken: token => this.raft.validateFencingToken(token),
|
|
51
|
+
isLeaderLeaseValid: () => this.raft.isLeaderLeaseValid(),
|
|
52
|
+
handleMembershipJoin: body => this.handleMembershipJoin(body),
|
|
53
|
+
handleMembershipLeave: body => this.handleMembershipLeave(body),
|
|
54
|
+
getSnapshot: () => this.cachedSnapshot ?? { version_vector: {}, tables: {} },
|
|
37
55
|
});
|
|
38
56
|
}
|
|
39
57
|
async start() {
|
|
@@ -72,6 +90,26 @@ export class ClusterRuntime {
|
|
|
72
90
|
return true;
|
|
73
91
|
return this.raft.role === 'leader';
|
|
74
92
|
}
|
|
93
|
+
getReplicationLag() {
|
|
94
|
+
return {
|
|
95
|
+
node_id: this.config.node_id,
|
|
96
|
+
total_local_events: this.eventCache.length,
|
|
97
|
+
version_vector: { ...this.versionVectorCache },
|
|
98
|
+
peers: this.raft
|
|
99
|
+
.getPeers()
|
|
100
|
+
.filter(p => p.id !== this.config.node_id)
|
|
101
|
+
.map(p => this.peerLagMap.get(p.id) || {
|
|
102
|
+
peer_id: p.id,
|
|
103
|
+
peer_url: p.url,
|
|
104
|
+
reachable: false,
|
|
105
|
+
events_behind: 0,
|
|
106
|
+
last_sync_at: null,
|
|
107
|
+
last_sync_duration_ms: null,
|
|
108
|
+
last_sync_events_applied: 0,
|
|
109
|
+
}),
|
|
110
|
+
last_sync_at: this.lastSyncAt,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
75
113
|
getStatus() {
|
|
76
114
|
const raftState = this.raft.getRaftStoreState();
|
|
77
115
|
return {
|
|
@@ -83,10 +121,14 @@ export class ClusterRuntime {
|
|
|
83
121
|
is_leader: this.isLeader(),
|
|
84
122
|
leader_id: this.raft.leaderId,
|
|
85
123
|
leader_url: this.raft.getLeaderUrl(),
|
|
124
|
+
fencing_token: this.raft.getFencingToken(),
|
|
125
|
+
leader_lease_valid: this.raft.isLeaderLeaseValid(),
|
|
126
|
+
leader_lease_duration_ms: this.raft.leaderLeaseDurationMs,
|
|
86
127
|
raft_commit_index: raftState?.commit_index || 0,
|
|
87
128
|
raft_last_applied: raftState?.last_applied || 0,
|
|
88
129
|
raft_last_log_index: raftState?.last_log_index || 0,
|
|
89
|
-
peers: this.
|
|
130
|
+
peers: this.raft.getPeers().map(peer => ({ id: peer.id, url: peer.url })),
|
|
131
|
+
is_catching_up: this.raft.isCatchingUp,
|
|
90
132
|
};
|
|
91
133
|
}
|
|
92
134
|
async sync(db) {
|
|
@@ -96,49 +138,339 @@ export class ClusterRuntime {
|
|
|
96
138
|
imported_events_applied: 0,
|
|
97
139
|
merged_duplicate_stories: 0,
|
|
98
140
|
durable_log_entries_appended: 0,
|
|
141
|
+
log_entries_compacted: 0,
|
|
142
|
+
cluster_events_pruned: 0,
|
|
143
|
+
used_snapshot_recovery: false,
|
|
144
|
+
catch_up_applied: 0,
|
|
145
|
+
catch_up_total: 0,
|
|
99
146
|
};
|
|
100
147
|
}
|
|
101
148
|
const hiveDir = this.options.hiveDir || join(process.cwd(), '.hive');
|
|
102
149
|
this.raft.initializeRaftStore(hiveDir);
|
|
103
150
|
ensureClusterTables(db, this.config.node_id);
|
|
151
|
+
// Refresh snapshot cache so the HTTP endpoint always serves current data
|
|
152
|
+
this.cachedSnapshot = this.buildSnapshot(db);
|
|
104
153
|
const localEventsBefore = scanLocalChanges(db, this.config.node_id);
|
|
105
|
-
const imported = await this.pullEventsFromPeers(db);
|
|
154
|
+
const { imported, usedSnapshot, catchUpApplied, catchUpTotal } = await this.pullEventsFromPeers(db);
|
|
106
155
|
const merged = mergeSimilarStories(db, this.config.story_similarity_threshold);
|
|
107
|
-
const localEventsAfter = imported > 0 || merged > 0 ? scanLocalChanges(db, this.config.node_id) : 0;
|
|
156
|
+
const localEventsAfter = imported > 0 || merged > 0 || usedSnapshot ? scanLocalChanges(db, this.config.node_id) : 0;
|
|
108
157
|
this.refreshCache(db);
|
|
109
158
|
const durableLogEntriesAppended = this.raft.appendClusterEventsToDurableLog(getAllClusterEvents(db));
|
|
159
|
+
// Run compaction if thresholds are met and enough time has elapsed
|
|
160
|
+
const { logCompacted, eventsPruned } = this.maybeCompact(db);
|
|
110
161
|
return {
|
|
111
162
|
local_events_emitted: localEventsBefore + localEventsAfter,
|
|
112
163
|
imported_events_applied: imported,
|
|
113
164
|
merged_duplicate_stories: merged,
|
|
114
165
|
durable_log_entries_appended: durableLogEntriesAppended,
|
|
166
|
+
log_entries_compacted: logCompacted,
|
|
167
|
+
cluster_events_pruned: eventsPruned,
|
|
168
|
+
used_snapshot_recovery: usedSnapshot,
|
|
169
|
+
catch_up_applied: catchUpApplied,
|
|
170
|
+
catch_up_total: catchUpTotal,
|
|
115
171
|
};
|
|
116
172
|
}
|
|
173
|
+
handleMembershipJoin(request) {
|
|
174
|
+
const peers = this.raft.getPeers();
|
|
175
|
+
const leaderUrl = this.raft.getLeaderUrl();
|
|
176
|
+
// If not the leader, redirect to leader
|
|
177
|
+
if (this.raft.role !== 'leader') {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
leader_id: this.raft.leaderId,
|
|
181
|
+
leader_url: leaderUrl,
|
|
182
|
+
peers: peers.map(p => ({ id: p.id, url: p.url })),
|
|
183
|
+
term: this.raft.currentTerm,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
// Check if peer already exists
|
|
187
|
+
const existing = peers.find(p => p.id === request.node_id);
|
|
188
|
+
if (existing) {
|
|
189
|
+
// Update URL if changed
|
|
190
|
+
if (existing.url !== request.url) {
|
|
191
|
+
const updated = peers.map(p => p.id === request.node_id ? { id: p.id, url: request.url } : p);
|
|
192
|
+
this.raft.setPeers(updated);
|
|
193
|
+
this.raft.appendDurableEntry('membership_change', {
|
|
194
|
+
action: 'update',
|
|
195
|
+
node_id: request.node_id,
|
|
196
|
+
url: request.url,
|
|
197
|
+
peer_count: updated.length,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
success: true,
|
|
202
|
+
leader_id: this.raft.leaderId,
|
|
203
|
+
leader_url: this.config.public_url,
|
|
204
|
+
peers: this.raft.getPeers().map(p => ({ id: p.id, url: p.url })),
|
|
205
|
+
term: this.raft.currentTerm,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// Add new peer
|
|
209
|
+
const newPeer = { id: request.node_id, url: request.url };
|
|
210
|
+
const updated = [...peers, newPeer];
|
|
211
|
+
this.raft.setPeers(updated);
|
|
212
|
+
this.raft.appendDurableEntry('membership_change', {
|
|
213
|
+
action: 'join',
|
|
214
|
+
node_id: request.node_id,
|
|
215
|
+
url: request.url,
|
|
216
|
+
peer_count: updated.length,
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
success: true,
|
|
220
|
+
leader_id: this.raft.leaderId,
|
|
221
|
+
leader_url: this.config.public_url,
|
|
222
|
+
peers: updated.map(p => ({ id: p.id, url: p.url })),
|
|
223
|
+
term: this.raft.currentTerm,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
handleMembershipLeave(request) {
|
|
227
|
+
const peers = this.raft.getPeers();
|
|
228
|
+
// If not the leader, cannot process leave
|
|
229
|
+
if (this.raft.role !== 'leader') {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
peers: peers.map(p => ({ id: p.id, url: p.url })),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// Cannot remove self (leader) — leader must transfer leadership first
|
|
236
|
+
if (request.node_id === this.config.node_id) {
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
peers: peers.map(p => ({ id: p.id, url: p.url })),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const existing = peers.find(p => p.id === request.node_id);
|
|
243
|
+
if (!existing) {
|
|
244
|
+
// Already gone
|
|
245
|
+
return {
|
|
246
|
+
success: true,
|
|
247
|
+
peers: peers.map(p => ({ id: p.id, url: p.url })),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const updated = peers.filter(p => p.id !== request.node_id);
|
|
251
|
+
this.raft.setPeers(updated);
|
|
252
|
+
this.raft.appendDurableEntry('membership_change', {
|
|
253
|
+
action: 'leave',
|
|
254
|
+
node_id: request.node_id,
|
|
255
|
+
peer_count: updated.length,
|
|
256
|
+
});
|
|
257
|
+
return {
|
|
258
|
+
success: true,
|
|
259
|
+
peers: updated.map(p => ({ id: p.id, url: p.url })),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
maybeCompact(db) {
|
|
263
|
+
const now = Date.now();
|
|
264
|
+
const interval = this.config.compaction_interval_ms ?? 300000;
|
|
265
|
+
// Respect minimum interval between compaction runs
|
|
266
|
+
if (interval > 0 && now - this.lastCompactionAt < interval) {
|
|
267
|
+
return { logCompacted: 0, eventsPruned: 0 };
|
|
268
|
+
}
|
|
269
|
+
let logCompacted = 0;
|
|
270
|
+
let eventsPruned = 0;
|
|
271
|
+
// Compact raft log if threshold exceeded
|
|
272
|
+
const maxLogEntries = this.config.max_log_entries ?? 10000;
|
|
273
|
+
if (maxLogEntries > 0) {
|
|
274
|
+
const logCount = this.raft.getLogEntryCount();
|
|
275
|
+
if (logCount > maxLogEntries) {
|
|
276
|
+
const versionVector = getVersionVector(db);
|
|
277
|
+
const result = this.raft.createSnapshotAndCompact(versionVector);
|
|
278
|
+
logCompacted = result.entries_removed;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Prune cluster_events if threshold exceeded
|
|
282
|
+
const maxEvents = this.config.max_cluster_events ?? 50000;
|
|
283
|
+
if (maxEvents > 0) {
|
|
284
|
+
const eventCount = getClusterEventCount(db);
|
|
285
|
+
if (eventCount > maxEvents) {
|
|
286
|
+
eventsPruned = pruneClusterEvents(db, maxEvents);
|
|
287
|
+
if (eventsPruned > 0) {
|
|
288
|
+
this.refreshCache(db);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (logCompacted > 0 || eventsPruned > 0) {
|
|
293
|
+
this.lastCompactionAt = now;
|
|
294
|
+
}
|
|
295
|
+
return { logCompacted, eventsPruned };
|
|
296
|
+
}
|
|
117
297
|
refreshCache(db) {
|
|
118
298
|
this.eventCache = getAllClusterEvents(db).slice(-20000);
|
|
119
299
|
this.versionVectorCache = getVersionVector(db);
|
|
120
300
|
}
|
|
121
301
|
async pullEventsFromPeers(db) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
302
|
+
const peers = this.raft.getPeers();
|
|
303
|
+
if (peers.length === 0) {
|
|
304
|
+
return { imported: 0, usedSnapshot: false, catchUpApplied: 0, catchUpTotal: 0 };
|
|
305
|
+
}
|
|
306
|
+
let imported = 0;
|
|
307
|
+
const syncTimestamp = new Date().toISOString();
|
|
308
|
+
this.lastSyncAt = syncTimestamp;
|
|
309
|
+
for (const peer of peers) {
|
|
126
310
|
if (peer.id === this.config.node_id)
|
|
127
311
|
continue;
|
|
128
|
-
const localVector =
|
|
312
|
+
const localVector = getEffectiveVersionVector(db);
|
|
313
|
+
const syncStart = Date.now();
|
|
129
314
|
const response = await this.requestDelta(peer, localVector, 4000);
|
|
130
|
-
if (!response
|
|
315
|
+
if (!response) {
|
|
316
|
+
this.peerLagMap.set(peer.id, {
|
|
317
|
+
peer_id: peer.id,
|
|
318
|
+
peer_url: peer.url,
|
|
319
|
+
reachable: false,
|
|
320
|
+
events_behind: 0,
|
|
321
|
+
last_sync_at: syncTimestamp,
|
|
322
|
+
last_sync_duration_ms: Date.now() - syncStart,
|
|
323
|
+
last_sync_events_applied: 0,
|
|
324
|
+
});
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
// If the peer advertises a higher fencing token, step down
|
|
328
|
+
if (typeof response.fencing_token === 'number' &&
|
|
329
|
+
response.fencing_token > this.raft.currentTerm) {
|
|
330
|
+
this.raft.stepDown(response.fencing_token, null);
|
|
331
|
+
}
|
|
332
|
+
// Detect if the delta is insufficient (peer's log was truncated past our position)
|
|
333
|
+
if (this.isDeltaInsufficient(localVector, response.version_vector, response.events)) {
|
|
334
|
+
const recovery = await this.recoverFromSnapshot(db, peer);
|
|
335
|
+
if (recovery !== null) {
|
|
336
|
+
this.peerLagMap.set(peer.id, {
|
|
337
|
+
peer_id: peer.id,
|
|
338
|
+
peer_url: peer.url,
|
|
339
|
+
reachable: true,
|
|
340
|
+
events_behind: 0,
|
|
341
|
+
last_sync_at: syncTimestamp,
|
|
342
|
+
last_sync_duration_ms: Date.now() - syncStart,
|
|
343
|
+
last_sync_events_applied: recovery.applied,
|
|
344
|
+
});
|
|
345
|
+
return {
|
|
346
|
+
imported: 0,
|
|
347
|
+
usedSnapshot: true,
|
|
348
|
+
catchUpApplied: recovery.applied,
|
|
349
|
+
catchUpTotal: recovery.total,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// Snapshot recovery failed — fall through and apply whatever delta we have
|
|
353
|
+
}
|
|
354
|
+
const eventsBehind = response.events.length;
|
|
355
|
+
const peerApplied = eventsBehind > 0 ? applyRemoteEvents(db, this.config.node_id, response.events) : 0;
|
|
356
|
+
imported += peerApplied;
|
|
357
|
+
this.peerLagMap.set(peer.id, {
|
|
358
|
+
peer_id: peer.id,
|
|
359
|
+
peer_url: peer.url,
|
|
360
|
+
reachable: true,
|
|
361
|
+
events_behind: eventsBehind,
|
|
362
|
+
last_sync_at: syncTimestamp,
|
|
363
|
+
last_sync_duration_ms: Date.now() - syncStart,
|
|
364
|
+
last_sync_events_applied: peerApplied,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// If we had been catching up and now the effective vector matches peers, mark done
|
|
368
|
+
if (this.raft.isCatchingUp) {
|
|
369
|
+
this.raft.isCatchingUp = false;
|
|
370
|
+
}
|
|
371
|
+
return { imported, usedSnapshot: false, catchUpApplied: 0, catchUpTotal: 0 };
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Returns true when the delta response is missing events the peer should have.
|
|
375
|
+
* This happens when the peer's event cache has been truncated (log compaction)
|
|
376
|
+
* and can no longer provide all events since our last known version.
|
|
377
|
+
*/
|
|
378
|
+
isDeltaInsufficient(localVector, peerVector, receivedEvents) {
|
|
379
|
+
// Count how many events we actually received per actor
|
|
380
|
+
const received = {};
|
|
381
|
+
for (const event of receivedEvents) {
|
|
382
|
+
received[event.version.actor_id] = (received[event.version.actor_id] ?? 0) + 1;
|
|
383
|
+
}
|
|
384
|
+
for (const [actorId, peerCounter] of Object.entries(peerVector)) {
|
|
385
|
+
const localCounter = localVector[actorId] ?? 0;
|
|
386
|
+
const needed = peerCounter - localCounter;
|
|
387
|
+
if (needed <= 0)
|
|
131
388
|
continue;
|
|
132
|
-
|
|
389
|
+
const receivedCount = received[actorId] ?? 0;
|
|
390
|
+
if (receivedCount < needed) {
|
|
391
|
+
// We're missing events for this actor that the peer should have
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
133
394
|
}
|
|
134
|
-
return
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Requests a full snapshot from the given peer and applies it locally.
|
|
399
|
+
* Marks the node as no longer catching up once complete.
|
|
400
|
+
* Returns { applied, total } on success, null on failure.
|
|
401
|
+
*/
|
|
402
|
+
async recoverFromSnapshot(db, peer) {
|
|
403
|
+
this.raft.isCatchingUp = true;
|
|
404
|
+
this.raft.appendDurableEntry('runtime', {
|
|
405
|
+
event: 'snapshot_recovery_start',
|
|
406
|
+
node_id: this.config.node_id,
|
|
407
|
+
peer_id: peer.id,
|
|
408
|
+
});
|
|
409
|
+
const snapshot = await this.requestSnapshot(peer);
|
|
410
|
+
if (!snapshot) {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
const { applied, total } = this.applySnapshot(db, snapshot);
|
|
414
|
+
this.raft.isCatchingUp = false;
|
|
415
|
+
this.raft.appendDurableEntry('runtime', {
|
|
416
|
+
event: 'snapshot_recovery_complete',
|
|
417
|
+
node_id: this.config.node_id,
|
|
418
|
+
peer_id: peer.id,
|
|
419
|
+
rows_applied: applied,
|
|
420
|
+
rows_total: total,
|
|
421
|
+
});
|
|
422
|
+
return { applied, total };
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Applies a snapshot to the local database, upserting all rows from all tables.
|
|
426
|
+
* Stores the snapshot's version vector so future delta requests start from here.
|
|
427
|
+
*/
|
|
428
|
+
applySnapshot(db, snapshot) {
|
|
429
|
+
let applied = 0;
|
|
430
|
+
let total = 0;
|
|
431
|
+
for (const adapter of REPLICATED_TABLES) {
|
|
432
|
+
const rows = snapshot.tables[adapter.table];
|
|
433
|
+
if (!rows)
|
|
434
|
+
continue;
|
|
435
|
+
total += rows.length;
|
|
436
|
+
for (const row of rows) {
|
|
437
|
+
adapter.upsert(db, row.payload);
|
|
438
|
+
applied++;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Record the snapshot version vector so future delta requests
|
|
442
|
+
// only ask for events newer than this snapshot
|
|
443
|
+
setSnapshotVersionVector(db, snapshot.version_vector);
|
|
444
|
+
return { applied, total };
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Builds a full snapshot of all replicated tables from the current db state.
|
|
448
|
+
* Called during sync to keep cachedSnapshot fresh for the HTTP endpoint.
|
|
449
|
+
*/
|
|
450
|
+
buildSnapshot(db) {
|
|
451
|
+
const tables = {};
|
|
452
|
+
for (const adapter of REPLICATED_TABLES) {
|
|
453
|
+
const rows = queryAll(db, adapter.selectSql);
|
|
454
|
+
tables[adapter.table] = rows.map(row => ({
|
|
455
|
+
rowId: adapter.rowId(row),
|
|
456
|
+
payload: adapter.payload(row),
|
|
457
|
+
}));
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
version_vector: getVersionVector(db),
|
|
461
|
+
tables,
|
|
462
|
+
};
|
|
135
463
|
}
|
|
136
464
|
async requestDelta(peer, versionVector, limit) {
|
|
137
465
|
return this.postJson(peer, '/cluster/v1/events/delta', {
|
|
138
466
|
version_vector: versionVector,
|
|
139
467
|
limit,
|
|
468
|
+
fencing_token: this.raft.getFencingToken(),
|
|
140
469
|
});
|
|
141
470
|
}
|
|
471
|
+
async requestSnapshot(peer) {
|
|
472
|
+
return this.getJson(peer, '/cluster/v1/snapshot');
|
|
473
|
+
}
|
|
142
474
|
getDeltaFromCache(remoteVersionVector, limit) {
|
|
143
475
|
return this.eventCache
|
|
144
476
|
.filter(event => {
|
|
@@ -155,6 +487,11 @@ export class ClusterRuntime {
|
|
|
155
487
|
body,
|
|
156
488
|
});
|
|
157
489
|
}
|
|
490
|
+
async getJson(peer, path) {
|
|
491
|
+
const normalizedBase = peer.url.endsWith('/') ? peer.url : `${peer.url}/`;
|
|
492
|
+
const url = new URL(path.replace(/^\//, ''), normalizedBase).toString();
|
|
493
|
+
return fetchClusterStatusOrPostJson(url, this.config.request_timeout_ms, this.config.auth_token, { method: 'GET' });
|
|
494
|
+
}
|
|
158
495
|
handleBackgroundError(error) {
|
|
159
496
|
if (!this.started || this.stopping)
|
|
160
497
|
return;
|
|
@@ -171,6 +508,48 @@ export class ClusterRuntime {
|
|
|
171
508
|
throw new Error(`Cluster auth_token is required when listen_host is not loopback (received: ${this.config.listen_host})`);
|
|
172
509
|
}
|
|
173
510
|
}
|
|
511
|
+
export async function fetchReplicationLag(config) {
|
|
512
|
+
if (!config.enabled)
|
|
513
|
+
return null;
|
|
514
|
+
const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
|
|
515
|
+
const url = `http://${host}:${config.listen_port}/cluster/v1/replication-lag`;
|
|
516
|
+
return fetchClusterStatusOrPostJson(url, config.request_timeout_ms, config.auth_token, { method: 'GET' });
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Fetches recent cluster events from the local runtime via the delta endpoint.
|
|
520
|
+
* Uses an empty version vector to request recent events up to the given limit.
|
|
521
|
+
*/
|
|
522
|
+
export async function fetchLocalClusterEvents(config, limit = 50) {
|
|
523
|
+
if (!config.enabled)
|
|
524
|
+
return null;
|
|
525
|
+
const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
|
|
526
|
+
const url = `http://${host}:${config.listen_port}/cluster/v1/events/delta`;
|
|
527
|
+
const response = await fetchClusterStatusOrPostJson(url, config.request_timeout_ms, config.auth_token, { method: 'POST', body: { version_vector: {}, limit } });
|
|
528
|
+
return response?.events ?? null;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* POSTs to the local cluster runtime at the given path.
|
|
532
|
+
*/
|
|
533
|
+
export async function postToLocalCluster(config, path, body) {
|
|
534
|
+
if (!config.enabled)
|
|
535
|
+
return null;
|
|
536
|
+
const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
|
|
537
|
+
const url = `http://${host}:${config.listen_port}${path}`;
|
|
538
|
+
return fetchClusterStatusOrPostJson(url, config.request_timeout_ms, config.auth_token, {
|
|
539
|
+
method: 'POST',
|
|
540
|
+
body,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* POSTs to a peer cluster node at the given URL and path.
|
|
545
|
+
*/
|
|
546
|
+
export async function postToPeerCluster(peerUrl, path, body, options) {
|
|
547
|
+
const url = `${peerUrl.replace(/\/$/, '')}${path}`;
|
|
548
|
+
return fetchClusterStatusOrPostJson(url, options.timeoutMs, options.authToken, {
|
|
549
|
+
method: 'POST',
|
|
550
|
+
body,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
174
553
|
export async function fetchLocalClusterStatus(config) {
|
|
175
554
|
if (!config.enabled) {
|
|
176
555
|
return {
|
|
@@ -182,10 +561,14 @@ export async function fetchLocalClusterStatus(config) {
|
|
|
182
561
|
is_leader: true,
|
|
183
562
|
leader_id: config.node_id,
|
|
184
563
|
leader_url: null,
|
|
564
|
+
fencing_token: 0,
|
|
565
|
+
leader_lease_valid: true,
|
|
566
|
+
leader_lease_duration_ms: config.leader_lease_ms ?? config.heartbeat_interval_ms * 3,
|
|
185
567
|
raft_commit_index: 0,
|
|
186
568
|
raft_last_applied: 0,
|
|
187
569
|
raft_last_log_index: 0,
|
|
188
570
|
peers: config.peers.map(peer => ({ id: peer.id, url: peer.url })),
|
|
571
|
+
is_catching_up: false,
|
|
189
572
|
};
|
|
190
573
|
}
|
|
191
574
|
const host = config.listen_host === '0.0.0.0' ? '127.0.0.1' : config.listen_host;
|
|
@@ -228,10 +611,14 @@ function parseClusterStatus(input) {
|
|
|
228
611
|
is_leader: input.is_leader === true,
|
|
229
612
|
leader_id: typeof input.leader_id === 'string' ? input.leader_id : null,
|
|
230
613
|
leader_url: typeof input.leader_url === 'string' ? input.leader_url : null,
|
|
614
|
+
fencing_token: toInt(input.fencing_token),
|
|
615
|
+
leader_lease_valid: input.leader_lease_valid === true,
|
|
616
|
+
leader_lease_duration_ms: toInt(input.leader_lease_duration_ms),
|
|
231
617
|
raft_commit_index: toInt(input.raft_commit_index),
|
|
232
618
|
raft_last_applied: toInt(input.raft_last_applied),
|
|
233
619
|
raft_last_log_index: toInt(input.raft_last_log_index),
|
|
234
620
|
peers,
|
|
621
|
+
is_catching_up: input.is_catching_up === true,
|
|
235
622
|
};
|
|
236
623
|
}
|
|
237
624
|
async function fetchClusterStatusOrPostJson(url, timeoutMs, authToken, options) {
|