hungry-ghost-hive 0.44.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/agents/base-agent.d.ts +1 -0
- package/dist/agents/base-agent.d.ts.map +1 -1
- package/dist/agents/base-agent.js +4 -0
- package/dist/agents/base-agent.js.map +1 -1
- package/dist/agents/intermediate.js +2 -2
- package/dist/agents/intermediate.js.map +1 -1
- package/dist/agents/junior.js +2 -2
- package/dist/agents/junior.js.map +1 -1
- package/dist/agents/qa.d.ts.map +1 -1
- package/dist/agents/qa.js +5 -5
- package/dist/agents/qa.js.map +1 -1
- package/dist/agents/senior.d.ts.map +1 -1
- package/dist/agents/senior.js +5 -5
- package/dist/agents/senior.js.map +1 -1
- package/dist/agents/tech-lead.d.ts.map +1 -1
- package/dist/agents/tech-lead.js +4 -2
- package/dist/agents/tech-lead.js.map +1 -1
- package/dist/cli/commands/assign.d.ts.map +1 -1
- package/dist/cli/commands/assign.js +4 -2
- package/dist/cli/commands/assign.js.map +1 -1
- package/dist/cli/commands/assign.test.js +5 -0
- package/dist/cli/commands/assign.test.js.map +1 -1
- 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/handoff-recovery.d.ts.map +1 -1
- package/dist/cli/commands/manager/handoff-recovery.js +4 -2
- package/dist/cli/commands/manager/handoff-recovery.js.map +1 -1
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +16 -12
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -1
- package/dist/cli/commands/manager/tech-lead-lifecycle.js +4 -2
- package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -1
- package/dist/cli/commands/msg.d.ts.map +1 -1
- package/dist/cli/commands/msg.js +8 -7
- package/dist/cli/commands/msg.js.map +1 -1
- package/dist/cli/commands/my-stories.js +3 -3
- package/dist/cli/commands/my-stories.js.map +1 -1
- package/dist/cli/commands/nuke.d.ts.map +1 -1
- package/dist/cli/commands/nuke.js +18 -7
- package/dist/cli/commands/nuke.js.map +1 -1
- package/dist/cli/commands/nuke.test.js +24 -0
- package/dist/cli/commands/nuke.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 +1 -1
- package/dist/cli/commands/req.d.ts.map +1 -1
- package/dist/cli/commands/req.js +28 -18
- package/dist/cli/commands/req.js.map +1 -1
- package/dist/cli/commands/stories.js +3 -3
- package/dist/cli/commands/stories.js.map +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/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.d.ts +1 -1
- package/dist/context-files/generator.d.ts.map +1 -1
- package/dist/context-files/generator.js +4 -3
- 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/context-files/index.test.js +1 -0
- package/dist/context-files/index.test.js.map +1 -1
- package/dist/db/client.d.ts +1 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +6 -0
- package/dist/db/client.js.map +1 -1
- package/dist/db/migrations/015-add-story-markdown-path.sql +5 -0
- package/dist/db/queries/stories.d.ts +3 -3
- package/dist/db/queries/stories.d.ts.map +1 -1
- package/dist/db/queries/stories.js +23 -5
- package/dist/db/queries/stories.js.map +1 -1
- package/dist/db/queries/test-helpers.d.ts.map +1 -1
- package/dist/db/queries/test-helpers.js +1 -0
- package/dist/db/queries/test-helpers.js.map +1 -1
- package/dist/git/worktree.d.ts.map +1 -1
- package/dist/git/worktree.js +7 -0
- package/dist/git/worktree.js.map +1 -1
- package/dist/git/worktree.test.js +30 -0
- package/dist/git/worktree.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 +6 -2
- package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
- package/dist/orchestrator/prompt-templates.js +61 -16
- package/dist/orchestrator/prompt-templates.js.map +1 -1
- package/dist/orchestrator/prompt-templates.test.js +214 -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 +30 -17
- package/dist/orchestrator/scheduler.js.map +1 -1
- package/dist/orchestrator/scheduler.test.js +98 -6
- package/dist/orchestrator/scheduler.test.js.map +1 -1
- package/dist/tmux/manager.d.ts +7 -6
- package/dist/tmux/manager.d.ts.map +1 -1
- package/dist/tmux/manager.js +29 -13
- package/dist/tmux/manager.js.map +1 -1
- package/dist/utils/instance.d.ts +32 -0
- package/dist/utils/instance.d.ts.map +1 -0
- package/dist/utils/instance.js +82 -0
- package/dist/utils/instance.js.map +1 -0
- package/dist/utils/instance.test.d.ts +2 -0
- package/dist/utils/instance.test.d.ts.map +1 -0
- package/dist/utils/instance.test.js +103 -0
- package/dist/utils/instance.test.js.map +1 -0
- package/dist/utils/paths.d.ts +2 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +2 -0
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/paths.test.js +6 -0
- package/dist/utils/paths.test.js.map +1 -1
- package/dist/utils/story-markdown.d.ts +16 -0
- package/dist/utils/story-markdown.d.ts.map +1 -0
- package/dist/utils/story-markdown.js +82 -0
- package/dist/utils/story-markdown.js.map +1 -0
- package/dist/utils/story-markdown.test.d.ts +2 -0
- package/dist/utils/story-markdown.test.d.ts.map +1 -0
- package/dist/utils/story-markdown.test.js +143 -0
- package/dist/utils/story-markdown.test.js.map +1 -0
- package/package.json +1 -1
- package/src/agents/base-agent.ts +5 -0
- package/src/agents/intermediate.ts +2 -2
- package/src/agents/junior.ts +2 -2
- package/src/agents/qa.ts +13 -8
- package/src/agents/senior.ts +21 -11
- package/src/agents/tech-lead.ts +24 -12
- package/src/cli/commands/assign.test.ts +5 -0
- package/src/cli/commands/assign.ts +4 -2
- package/src/cli/commands/cluster.test.ts +387 -9
- package/src/cli/commands/cluster.ts +486 -1
- package/src/cli/commands/manager/handoff-recovery.ts +4 -2
- package/src/cli/commands/manager/index.ts +16 -11
- package/src/cli/commands/manager/tech-lead-lifecycle.ts +5 -2
- package/src/cli/commands/msg.ts +8 -7
- package/src/cli/commands/my-stories.ts +22 -13
- package/src/cli/commands/nuke.test.ts +31 -0
- package/src/cli/commands/nuke.ts +18 -7
- package/src/cli/commands/req-spawn.test.ts +153 -0
- package/src/cli/commands/req.ts +40 -23
- package/src/cli/commands/stories.ts +22 -13
- package/src/cli/dashboard/panels/agents.ts +7 -3
- 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 +8 -7
- package/src/context-files/index.test.ts +1 -0
- package/src/db/client.ts +7 -0
- package/src/db/migrations/015-add-story-markdown-path.sql +5 -0
- package/src/db/queries/stories.ts +29 -5
- package/src/db/queries/test-helpers.ts +1 -0
- package/src/git/worktree.test.ts +43 -0
- package/src/git/worktree.ts +10 -0
- package/src/orchestrator/orphan-recovery.ts +32 -13
- package/src/orchestrator/prompt-templates.test.ts +267 -0
- package/src/orchestrator/prompt-templates.ts +69 -16
- package/src/orchestrator/scheduler.test.ts +130 -6
- package/src/orchestrator/scheduler.ts +66 -27
- package/src/tmux/manager.ts +42 -13
- package/src/utils/instance.test.ts +129 -0
- package/src/utils/instance.ts +95 -0
- package/src/utils/paths.test.ts +8 -0
- package/src/utils/paths.ts +3 -0
- package/src/utils/story-markdown.test.ts +176 -0
- package/src/utils/story-markdown.ts +94 -0
|
@@ -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
|
}
|