@optimystic/db-p2p 0.9.3 → 0.10.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.
Files changed (62) hide show
  1. package/README.md +32 -6
  2. package/dist/src/cluster/cluster-repo.d.ts +23 -1
  3. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  4. package/dist/src/cluster/cluster-repo.js +110 -8
  5. package/dist/src/cluster/cluster-repo.js.map +1 -1
  6. package/dist/src/cluster/i-transaction-state-store.d.ts +36 -0
  7. package/dist/src/cluster/i-transaction-state-store.d.ts.map +1 -0
  8. package/dist/src/cluster/i-transaction-state-store.js +2 -0
  9. package/dist/src/cluster/i-transaction-state-store.js.map +1 -0
  10. package/dist/src/cluster/memory-transaction-state-store.d.ts +19 -0
  11. package/dist/src/cluster/memory-transaction-state-store.d.ts.map +1 -0
  12. package/dist/src/cluster/memory-transaction-state-store.js +44 -0
  13. package/dist/src/cluster/memory-transaction-state-store.js.map +1 -0
  14. package/dist/src/cluster/persistent-transaction-state-store.d.ts +26 -0
  15. package/dist/src/cluster/persistent-transaction-state-store.d.ts.map +1 -0
  16. package/dist/src/cluster/persistent-transaction-state-store.js +79 -0
  17. package/dist/src/cluster/persistent-transaction-state-store.js.map +1 -0
  18. package/dist/src/cluster/spread-on-churn.d.ts +67 -0
  19. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -0
  20. package/dist/src/cluster/spread-on-churn.js +192 -0
  21. package/dist/src/cluster/spread-on-churn.js.map +1 -0
  22. package/dist/src/index.d.ts +6 -0
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +6 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/libp2p-node-base.d.ts +3 -0
  27. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  28. package/dist/src/libp2p-node-base.js +16 -2
  29. package/dist/src/libp2p-node-base.js.map +1 -1
  30. package/dist/src/network/network-manager-service.d.ts +8 -0
  31. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  32. package/dist/src/network/network-manager-service.js +21 -0
  33. package/dist/src/network/network-manager-service.js.map +1 -1
  34. package/dist/src/repo/cluster-coordinator.d.ts +12 -1
  35. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  36. package/dist/src/repo/cluster-coordinator.js +67 -2
  37. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  38. package/dist/src/repo/coordinator-repo.d.ts +5 -2
  39. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  40. package/dist/src/repo/coordinator-repo.js +8 -4
  41. package/dist/src/repo/coordinator-repo.js.map +1 -1
  42. package/dist/src/storage/i-kv-store.d.ts +9 -0
  43. package/dist/src/storage/i-kv-store.d.ts.map +1 -0
  44. package/dist/src/storage/i-kv-store.js +2 -0
  45. package/dist/src/storage/i-kv-store.js.map +1 -0
  46. package/dist/src/storage/memory-kv-store.d.ts +10 -0
  47. package/dist/src/storage/memory-kv-store.d.ts.map +1 -0
  48. package/dist/src/storage/memory-kv-store.js +23 -0
  49. package/dist/src/storage/memory-kv-store.js.map +1 -0
  50. package/package.json +2 -2
  51. package/src/cluster/cluster-repo.ts +112 -8
  52. package/src/cluster/i-transaction-state-store.ts +43 -0
  53. package/src/cluster/memory-transaction-state-store.ts +56 -0
  54. package/src/cluster/persistent-transaction-state-store.ts +92 -0
  55. package/src/cluster/spread-on-churn.ts +285 -0
  56. package/src/index.ts +6 -0
  57. package/src/libp2p-node-base.ts +23 -2
  58. package/src/network/network-manager-service.ts +32 -0
  59. package/src/repo/cluster-coordinator.ts +72 -2
  60. package/src/repo/coordinator-repo.ts +13 -4
  61. package/src/storage/i-kv-store.ts +8 -0
  62. package/src/storage/memory-kv-store.ts +28 -0
@@ -14,6 +14,7 @@ import type { PartitionDetector } from "./partition-detector.js";
14
14
  import type { FretService } from "p2p-fret";
15
15
  import type { IPeerReputation } from "../reputation/types.js";
16
16
  import { PenaltyReason } from "../reputation/types.js";
17
+ import type { ITransactionStateStore } from "./i-transaction-state-store.js";
17
18
 
18
19
  const log = createLogger('cluster-member')
19
20
 
@@ -45,6 +46,7 @@ interface ClusterMemberComponents {
45
46
  validator?: ITransactionValidator;
46
47
  reputation?: IPeerReputation;
47
48
  consensusConfig?: ClusterConsensusConfig;
49
+ stateStore?: ITransactionStateStore;
48
50
  }
49
51
 
50
52
  export function clusterMember(components: ClusterMemberComponents): ClusterMember {
@@ -58,7 +60,8 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
58
60
  components.fretService,
59
61
  components.validator,
60
62
  components.reputation,
61
- components.consensusConfig
63
+ components.consensusConfig,
64
+ components.stateStore
62
65
  );
63
66
  }
64
67
 
@@ -80,6 +83,9 @@ export class ClusterMember implements ICluster {
80
83
  private pendingUpdates: Map<string, Promise<ClusterRecord>> = new Map();
81
84
  // Temporarily set during validateSignatures so verifySignature can access the record
82
85
  private currentValidationRecord?: ClusterRecord;
86
+ // Interval handles for periodic cleanup (stored so dispose() can clear them)
87
+ private readonly expirationInterval: NodeJS.Timeout;
88
+ private readonly cleanupInterval: NodeJS.Timeout;
83
89
 
84
90
  /** Effective super-majority threshold. Defaults to 1.0 (unanimity) for backward compatibility. */
85
91
  private readonly superMajorityThreshold: number;
@@ -94,13 +100,31 @@ export class ClusterMember implements ICluster {
94
100
  private readonly fretService?: FretService,
95
101
  private readonly validator?: ITransactionValidator,
96
102
  private readonly reputation?: IPeerReputation,
97
- consensusConfig?: ClusterConsensusConfig
103
+ consensusConfig?: ClusterConsensusConfig,
104
+ private readonly stateStore?: ITransactionStateStore
98
105
  ) {
99
106
  this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? 1.0;
100
107
  // Periodically clean up expired transactions (.unref() so tests/short-lived processes can exit)
101
- setInterval(() => this.queueExpiredTransactions(), 60000).unref();
108
+ this.expirationInterval = setInterval(() => this.queueExpiredTransactions(), 60000);
109
+ this.expirationInterval.unref();
102
110
  // Process cleanup queue
103
- setInterval(() => this.processCleanupQueue(), 1000).unref();
111
+ this.cleanupInterval = setInterval(() => this.processCleanupQueue(), 1000);
112
+ this.cleanupInterval.unref();
113
+ }
114
+
115
+ /**
116
+ * Clears all interval and timeout handles and empties active state.
117
+ * Called during node shutdown to prevent leaked timers.
118
+ */
119
+ dispose(): void {
120
+ clearInterval(this.expirationInterval);
121
+ clearInterval(this.cleanupInterval);
122
+ for (const [, state] of this.activeTransactions) {
123
+ if (state.promiseTimeout) clearTimeout(state.promiseTimeout);
124
+ if (state.resolutionTimeout) clearTimeout(state.resolutionTimeout);
125
+ }
126
+ this.activeTransactions.clear();
127
+ this.cleanupQueue.length = 0;
104
128
  }
105
129
 
106
130
  /**
@@ -228,7 +252,10 @@ export class ClusterMember implements ICluster {
228
252
  log('cluster-member:action-consensus-after-commit', {
229
253
  messageHash: record.messageHash
230
254
  });
231
- await this.handleConsensus(currentRecord);
255
+ // Check persistent store for post-recovery dedup before synchronous guard
256
+ if (!await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
257
+ await this.handleConsensus(currentRecord);
258
+ }
232
259
  }
233
260
  }
234
261
  shouldPersist = false;
@@ -237,8 +264,12 @@ export class ClusterMember implements ICluster {
237
264
  log('cluster-member:action-consensus', {
238
265
  messageHash: record.messageHash
239
266
  });
240
- // handleConsensus has its own idempotency guard via executedTransactions
241
- await this.handleConsensus(currentRecord);
267
+ // Check persistent store for post-recovery dedup before synchronous guard
268
+ if (await this.wasTransactionExecutedAsync(currentRecord.messageHash)) {
269
+ log('cluster-member:consensus-already-executed', { messageHash: record.messageHash });
270
+ } else {
271
+ await this.handleConsensus(currentRecord);
272
+ }
242
273
  // Don't call clearTransaction here - it happens in handleConsensus
243
274
  shouldPersist = false;
244
275
  break;
@@ -275,6 +306,7 @@ export class ClusterMember implements ICluster {
275
306
  promiseTimeout: timeouts.promiseTimeout,
276
307
  resolutionTimeout: timeouts.resolutionTimeout
277
308
  });
309
+ this.persistParticipantState(record.messageHash, currentRecord);
278
310
  log('cluster-member:state-persist', {
279
311
  messageHash: record.messageHash,
280
312
  storedPromises: Object.keys(currentRecord.promises ?? {}),
@@ -637,7 +669,10 @@ export class ClusterMember implements ICluster {
637
669
  return;
638
670
  }
639
671
  // Mark as executing IMMEDIATELY before any async operations
640
- this.executedTransactions.set(record.messageHash, Date.now());
672
+ const executedAt = Date.now();
673
+ this.executedTransactions.set(record.messageHash, executedAt);
674
+ this.stateStore?.markExecuted(record.messageHash, executedAt)
675
+ .catch(err => log('cluster-member:persist-executed-error', { messageHash: record.messageHash, error: (err as Error).message }));
641
676
 
642
677
  try {
643
678
  // Execute the operations - check return values for failures
@@ -902,6 +937,8 @@ export class ClusterMember implements ICluster {
902
937
  this.executedTransactions.delete(messageHash);
903
938
  }
904
939
  }
940
+ this.stateStore?.pruneExecuted(expirationThreshold)
941
+ .catch(err => log('cluster-member:prune-executed-error', { error: (err as Error).message }));
905
942
  }
906
943
 
907
944
  private async processCleanupQueue(): Promise<void> {
@@ -937,10 +974,77 @@ export class ClusterMember implements ICluster {
937
974
  clearTimeout(state.resolutionTimeout);
938
975
  }
939
976
  this.activeTransactions.delete(messageHash);
977
+ this.stateStore?.deleteParticipantState(messageHash)
978
+ .catch(err => log('cluster-member:persist-delete-error', { messageHash, error: (err as Error).message }));
940
979
  log('cluster-member:clear-done', {
941
980
  messageHash,
942
981
  remaining: Array.from(this.activeTransactions.keys())
943
982
  });
944
983
  }
984
+
985
+ /** Fire-and-forget persist — errors are logged, never thrown. */
986
+ private persistParticipantState(messageHash: string, record: ClusterRecord): void {
987
+ if (!this.stateStore) return;
988
+ this.stateStore.saveParticipantState(messageHash, {
989
+ messageHash,
990
+ record,
991
+ lastUpdate: Date.now()
992
+ }).catch(err => log('cluster-member:persist-error', { messageHash, error: (err as Error).message }));
993
+ }
994
+
995
+ /**
996
+ * Recover member transactions from persistent store after a restart.
997
+ * Called during node startup, before accepting new requests.
998
+ */
999
+ async recoverTransactions(): Promise<void> {
1000
+ if (!this.stateStore) return;
1001
+ const now = Date.now();
1002
+
1003
+ // 1. Prune expired executed entries from persistent store
1004
+ await this.stateStore.pruneExecuted(now - ExecutedTransactionTtlMs);
1005
+ // Note: executed transactions are checked via wasTransactionExecutedAsync() at runtime,
1006
+ // which falls back to the persistent store when the in-memory map misses.
1007
+
1008
+ // 2. Restore active participant states
1009
+ const participantStates = await this.stateStore.getAllParticipantStates();
1010
+ for (const state of participantStates) {
1011
+ const { messageHash } = state;
1012
+ // Expired — clean up
1013
+ if (state.record.message.expiration && state.record.message.expiration < now) {
1014
+ log('cluster-member:recovery-expired', { messageHash });
1015
+ await this.stateStore.deleteParticipantState(messageHash);
1016
+ continue;
1017
+ }
1018
+ // Restore into activeTransactions with fresh timeouts
1019
+ log('cluster-member:recovery-restore', { messageHash });
1020
+ const timeouts = this.setupTimeouts(state.record);
1021
+ this.activeTransactions.set(messageHash, {
1022
+ record: state.record,
1023
+ lastUpdate: state.lastUpdate,
1024
+ promiseTimeout: timeouts.promiseTimeout,
1025
+ resolutionTimeout: timeouts.resolutionTimeout
1026
+ });
1027
+ }
1028
+
1029
+ log('cluster-member:recovery-complete', {
1030
+ restoredActive: this.activeTransactions.size,
1031
+ restoredExecuted: this.executedTransactions.size
1032
+ });
1033
+ }
1034
+
1035
+ /**
1036
+ * Checks if a transaction's operations were already executed during consensus.
1037
+ * Falls back to the persistent store when the in-memory map misses.
1038
+ */
1039
+ async wasTransactionExecutedAsync(messageHash: string): Promise<boolean> {
1040
+ if (this.executedTransactions.has(messageHash)) return true;
1041
+ if (!this.stateStore) return false;
1042
+ const persisted = await this.stateStore.wasExecuted(messageHash);
1043
+ if (persisted) {
1044
+ // Re-populate in-memory map for future synchronous checks
1045
+ this.executedTransactions.set(messageHash, Date.now());
1046
+ }
1047
+ return persisted;
1048
+ }
945
1049
  }
946
1050
 
@@ -0,0 +1,43 @@
1
+ import type { ClusterRecord } from "@optimystic/db-core";
2
+
3
+ /** Serializable coordinator transaction state (excludes timers, Pending wrapper) */
4
+ export interface PersistedCoordinatorState {
5
+ messageHash: string;
6
+ record: ClusterRecord;
7
+ lastUpdate: number;
8
+ /** Which phase was reached when last persisted */
9
+ phase: 'promising' | 'committing' | 'broadcasting';
10
+ /** Retry state for commit broadcast failures (excludes timer) */
11
+ retryState?: {
12
+ pendingPeers: string[];
13
+ attempt: number;
14
+ intervalMs: number;
15
+ };
16
+ }
17
+
18
+ /** Serializable participant transaction state (excludes timers) */
19
+ export interface PersistedParticipantState {
20
+ messageHash: string;
21
+ record: ClusterRecord;
22
+ lastUpdate: number;
23
+ }
24
+
25
+ /** Platform-agnostic store for persisting 2PC transaction state. */
26
+ export interface ITransactionStateStore {
27
+ // --- Coordinator state (keyed by messageHash) ---
28
+ saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void>;
29
+ getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined>;
30
+ deleteCoordinatorState(messageHash: string): Promise<void>;
31
+ getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]>;
32
+
33
+ // --- Participant state (keyed by messageHash) ---
34
+ saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void>;
35
+ getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined>;
36
+ deleteParticipantState(messageHash: string): Promise<void>;
37
+ getAllParticipantStates(): Promise<PersistedParticipantState[]>;
38
+
39
+ // --- Executed transaction dedup guard ---
40
+ markExecuted(messageHash: string, timestamp: number): Promise<void>;
41
+ wasExecuted(messageHash: string): Promise<boolean>;
42
+ pruneExecuted(olderThan: number): Promise<void>;
43
+ }
@@ -0,0 +1,56 @@
1
+ import type { ITransactionStateStore, PersistedCoordinatorState, PersistedParticipantState } from "./i-transaction-state-store.js";
2
+
3
+ /** In-memory ITransactionStateStore. Default when no persistent store is injected. */
4
+ export class MemoryTransactionStateStore implements ITransactionStateStore {
5
+ private readonly coordinatorStates = new Map<string, PersistedCoordinatorState>();
6
+ private readonly participantStates = new Map<string, PersistedParticipantState>();
7
+ private readonly executedMap = new Map<string, number>();
8
+
9
+ async saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void> {
10
+ this.coordinatorStates.set(messageHash, state);
11
+ }
12
+
13
+ async getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined> {
14
+ return this.coordinatorStates.get(messageHash);
15
+ }
16
+
17
+ async deleteCoordinatorState(messageHash: string): Promise<void> {
18
+ this.coordinatorStates.delete(messageHash);
19
+ }
20
+
21
+ async getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]> {
22
+ return Array.from(this.coordinatorStates.values());
23
+ }
24
+
25
+ async saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void> {
26
+ this.participantStates.set(messageHash, state);
27
+ }
28
+
29
+ async getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined> {
30
+ return this.participantStates.get(messageHash);
31
+ }
32
+
33
+ async deleteParticipantState(messageHash: string): Promise<void> {
34
+ this.participantStates.delete(messageHash);
35
+ }
36
+
37
+ async getAllParticipantStates(): Promise<PersistedParticipantState[]> {
38
+ return Array.from(this.participantStates.values());
39
+ }
40
+
41
+ async markExecuted(messageHash: string, timestamp: number): Promise<void> {
42
+ this.executedMap.set(messageHash, timestamp);
43
+ }
44
+
45
+ async wasExecuted(messageHash: string): Promise<boolean> {
46
+ return this.executedMap.has(messageHash);
47
+ }
48
+
49
+ async pruneExecuted(olderThan: number): Promise<void> {
50
+ for (const [hash, ts] of this.executedMap) {
51
+ if (ts < olderThan) {
52
+ this.executedMap.delete(hash);
53
+ }
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,92 @@
1
+ import type { IKVStore } from "../storage/i-kv-store.js";
2
+ import type { ITransactionStateStore, PersistedCoordinatorState, PersistedParticipantState } from "./i-transaction-state-store.js";
3
+
4
+ /**
5
+ * ITransactionStateStore backed by an IKVStore for cross-platform persistence.
6
+ *
7
+ * Key namespace:
8
+ * coordinator/{messageHash} → JSON(PersistedCoordinatorState)
9
+ * participant/{messageHash} → JSON(PersistedParticipantState)
10
+ * executed/{messageHash} → JSON({ timestamp: number })
11
+ */
12
+ export class PersistentTransactionStateStore implements ITransactionStateStore {
13
+ constructor(private readonly kv: IKVStore) {}
14
+
15
+ // --- Coordinator ---
16
+
17
+ async saveCoordinatorState(messageHash: string, state: PersistedCoordinatorState): Promise<void> {
18
+ await this.kv.set(`coordinator/${messageHash}`, JSON.stringify(state));
19
+ }
20
+
21
+ async getCoordinatorState(messageHash: string): Promise<PersistedCoordinatorState | undefined> {
22
+ const raw = await this.kv.get(`coordinator/${messageHash}`);
23
+ return raw ? JSON.parse(raw) as PersistedCoordinatorState : undefined;
24
+ }
25
+
26
+ async deleteCoordinatorState(messageHash: string): Promise<void> {
27
+ await this.kv.delete(`coordinator/${messageHash}`);
28
+ }
29
+
30
+ async getAllCoordinatorStates(): Promise<PersistedCoordinatorState[]> {
31
+ const keys = await this.kv.list('coordinator/');
32
+ const results: PersistedCoordinatorState[] = [];
33
+ for (const key of keys) {
34
+ const raw = await this.kv.get(key);
35
+ if (raw) {
36
+ results.push(JSON.parse(raw) as PersistedCoordinatorState);
37
+ }
38
+ }
39
+ return results;
40
+ }
41
+
42
+ // --- Participant ---
43
+
44
+ async saveParticipantState(messageHash: string, state: PersistedParticipantState): Promise<void> {
45
+ await this.kv.set(`participant/${messageHash}`, JSON.stringify(state));
46
+ }
47
+
48
+ async getParticipantState(messageHash: string): Promise<PersistedParticipantState | undefined> {
49
+ const raw = await this.kv.get(`participant/${messageHash}`);
50
+ return raw ? JSON.parse(raw) as PersistedParticipantState : undefined;
51
+ }
52
+
53
+ async deleteParticipantState(messageHash: string): Promise<void> {
54
+ await this.kv.delete(`participant/${messageHash}`);
55
+ }
56
+
57
+ async getAllParticipantStates(): Promise<PersistedParticipantState[]> {
58
+ const keys = await this.kv.list('participant/');
59
+ const results: PersistedParticipantState[] = [];
60
+ for (const key of keys) {
61
+ const raw = await this.kv.get(key);
62
+ if (raw) {
63
+ results.push(JSON.parse(raw) as PersistedParticipantState);
64
+ }
65
+ }
66
+ return results;
67
+ }
68
+
69
+ // --- Executed ---
70
+
71
+ async markExecuted(messageHash: string, timestamp: number): Promise<void> {
72
+ await this.kv.set(`executed/${messageHash}`, JSON.stringify({ timestamp }));
73
+ }
74
+
75
+ async wasExecuted(messageHash: string): Promise<boolean> {
76
+ const raw = await this.kv.get(`executed/${messageHash}`);
77
+ return raw !== undefined;
78
+ }
79
+
80
+ async pruneExecuted(olderThan: number): Promise<void> {
81
+ const keys = await this.kv.list('executed/');
82
+ for (const key of keys) {
83
+ const raw = await this.kv.get(key);
84
+ if (raw) {
85
+ const { timestamp } = JSON.parse(raw) as { timestamp: number };
86
+ if (timestamp < olderThan) {
87
+ await this.kv.delete(key);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,285 @@
1
+ import type { Startable, Libp2p } from '@libp2p/interface'
2
+ import type { IRepo, IPeerNetwork } from '@optimystic/db-core'
3
+ import { hashKey } from 'p2p-fret'
4
+ import type { FretService } from 'p2p-fret'
5
+ import { peerIdFromString } from '@libp2p/peer-id'
6
+ import type { PartitionDetector } from './partition-detector.js'
7
+ import { BlockTransferClient } from './block-transfer-service.js'
8
+ import { createLogger } from '../logger.js'
9
+
10
+ const log = createLogger('spread-on-churn')
11
+ const textEncoder = new TextEncoder()
12
+
13
+ // ── Types ────────────────────────────────────────────────────────────
14
+
15
+ export interface SpreadOnChurnConfig {
16
+ /** Enable the churn-resilient spread protocol. Default: true */
17
+ enabled: boolean
18
+ /** Number of middle-closest peers eligible to spread (d). Default: 3 */
19
+ spreadDistance: number
20
+ /** Enable dynamic d scaling based on cluster health. Default: true */
21
+ dynamicSpreadDistance: boolean
22
+ /** Cluster size ratio below which spread becomes more aggressive. Default: 0.6 */
23
+ healthThreshold: number
24
+ /** Debounce window for departure detection (ms). Default: 5000 */
25
+ departureDebounceMs: number
26
+ /** Number of peers beyond cluster boundary to target. Default: 4 */
27
+ expansionStep: number
28
+ }
29
+
30
+ export interface SpreadOnChurnDeps {
31
+ libp2p: Libp2p
32
+ fret: FretService
33
+ partitionDetector: PartitionDetector
34
+ repo: IRepo
35
+ peerNetwork: IPeerNetwork
36
+ clusterSize: number
37
+ protocolPrefix?: string
38
+ }
39
+
40
+ export interface SpreadEvent {
41
+ /** Blocks that were spread */
42
+ spread: Array<{
43
+ blockId: string
44
+ targets: string[]
45
+ succeeded: string[]
46
+ failed: string[]
47
+ }>
48
+ /** Current effective d */
49
+ effectiveD: number
50
+ /** Timestamp of the departure that triggered this */
51
+ triggeredAt: number
52
+ }
53
+
54
+ type SpreadHandler = (event: SpreadEvent) => void
55
+
56
+ // ── Defaults ─────────────────────────────────────────────────────────
57
+
58
+ const DEFAULT_CONFIG: SpreadOnChurnConfig = {
59
+ enabled: true,
60
+ spreadDistance: 3,
61
+ dynamicSpreadDistance: true,
62
+ healthThreshold: 0.6,
63
+ departureDebounceMs: 5000,
64
+ expansionStep: 4,
65
+ }
66
+
67
+ // ── Monitor ──────────────────────────────────────────────────────────
68
+
69
+ export class SpreadOnChurnMonitor implements Startable {
70
+ private running = false
71
+ private readonly trackedBlocks = new Set<string>()
72
+ private readonly handlers: SpreadHandler[] = []
73
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null
74
+ private departureTimestamps: number[] = []
75
+ private departureTimestamp = 0
76
+
77
+ private readonly config: SpreadOnChurnConfig
78
+ private readonly onConnectionClose: () => void
79
+
80
+ constructor(
81
+ private readonly deps: SpreadOnChurnDeps,
82
+ config: Partial<SpreadOnChurnConfig> = {}
83
+ ) {
84
+ this.config = { ...DEFAULT_CONFIG, ...config }
85
+ this.onConnectionClose = () => this.handleDeparture()
86
+ }
87
+
88
+ // ── Startable ────────────────────────────────────────────────────
89
+
90
+ async start(): Promise<void> {
91
+ if (this.running) return
92
+ this.running = true
93
+
94
+ this.deps.libp2p.addEventListener('connection:close', this.onConnectionClose)
95
+
96
+ log('started, tracking %d blocks', this.trackedBlocks.size)
97
+ }
98
+
99
+ async stop(): Promise<void> {
100
+ if (!this.running) return
101
+ this.running = false
102
+
103
+ this.deps.libp2p.removeEventListener('connection:close', this.onConnectionClose)
104
+
105
+ if (this.debounceTimer) {
106
+ clearTimeout(this.debounceTimer)
107
+ this.debounceTimer = null
108
+ }
109
+
110
+ log('stopped')
111
+ }
112
+
113
+ // ── Public API ───────────────────────────────────────────────────
114
+
115
+ onSpread(handler: SpreadHandler): void {
116
+ this.handlers.push(handler)
117
+ }
118
+
119
+ trackBlock(blockId: string): void {
120
+ this.trackedBlocks.add(blockId)
121
+ }
122
+
123
+ untrackBlock(blockId: string): void {
124
+ this.trackedBlocks.delete(blockId)
125
+ }
126
+
127
+ getTrackedBlockCount(): number {
128
+ return this.trackedBlocks.size
129
+ }
130
+
131
+ /** Force an immediate spread check (useful for testing). */
132
+ async checkNow(): Promise<SpreadEvent | null> {
133
+ return this.performSpread(Date.now())
134
+ }
135
+
136
+ // ── Internal ─────────────────────────────────────────────────────
137
+
138
+ private handleDeparture(): void {
139
+ if (!this.running) return
140
+ if (!this.config.enabled) return
141
+
142
+ if (!this.departureTimestamp) {
143
+ this.departureTimestamp = Date.now()
144
+ }
145
+
146
+ // Record for dynamic-d sliding window
147
+ this.departureTimestamps.push(Date.now())
148
+
149
+ if (this.debounceTimer) {
150
+ clearTimeout(this.debounceTimer)
151
+ }
152
+
153
+ this.debounceTimer = setTimeout(() => {
154
+ this.debounceTimer = null
155
+ const ts = this.departureTimestamp
156
+ this.departureTimestamp = 0
157
+ if (this.running) {
158
+ this.performSpread(ts).catch(err => {
159
+ log('spread error: %O', err)
160
+ })
161
+ }
162
+ }, this.config.departureDebounceMs)
163
+ }
164
+
165
+ private async performSpread(triggeredAt: number): Promise<SpreadEvent | null> {
166
+ if (!this.config.enabled) return null
167
+
168
+ if (this.deps.partitionDetector.detectPartition()) {
169
+ log('partition detected, suppressing spread')
170
+ return null
171
+ }
172
+
173
+ if (this.trackedBlocks.size === 0) return null
174
+
175
+ const selfId = this.deps.libp2p.peerId.toString()
176
+ const effectiveD = this.computeEffectiveD()
177
+ const spreadResults: SpreadEvent['spread'] = []
178
+
179
+ for (const blockId of this.trackedBlocks) {
180
+ const key = textEncoder.encode(blockId)
181
+ const coord = await hashKey(key)
182
+
183
+ // Check eligibility: only middle peers spread
184
+ const rank = this.deps.fret.neighborDistance(selfId, coord, this.deps.clusterSize)
185
+ if (rank >= effectiveD) continue
186
+
187
+ // Get current cohort and expansion targets
188
+ const cohort = this.deps.fret.assembleCohort(coord, this.deps.clusterSize)
189
+ const cohortSet = new Set(cohort)
190
+ const expanded = this.deps.fret.expandCohort(
191
+ cohort, coord, this.config.expansionStep
192
+ )
193
+ const targets = expanded.filter(id => !cohortSet.has(id) && id !== selfId)
194
+ if (targets.length === 0) continue
195
+
196
+ // Read block data from local storage
197
+ const result = await this.deps.repo.get({ blockIds: [blockId] })
198
+ const blockResult = result[blockId]
199
+ if (!blockResult?.block) {
200
+ log('no-local-data block=%s', blockId)
201
+ continue
202
+ }
203
+
204
+ const blockData = textEncoder.encode(JSON.stringify(blockResult.block))
205
+
206
+ // Push to each target
207
+ const succeeded: string[] = []
208
+ const failed: string[] = []
209
+
210
+ for (const targetId of targets) {
211
+ try {
212
+ const peerId = peerIdFromString(targetId)
213
+ const client = new BlockTransferClient(
214
+ peerId,
215
+ this.deps.peerNetwork,
216
+ this.deps.protocolPrefix
217
+ )
218
+ await client.pushBlocks([blockId], [blockData], 'replication')
219
+ succeeded.push(targetId)
220
+ log('push:ok block=%s target=%s', blockId, targetId)
221
+ } catch (err) {
222
+ failed.push(targetId)
223
+ log('push:fail block=%s target=%s err=%s',
224
+ blockId, targetId, (err as Error).message)
225
+ }
226
+ }
227
+
228
+ spreadResults.push({ blockId, targets, succeeded, failed })
229
+ }
230
+
231
+ if (spreadResults.length === 0) return null
232
+
233
+ const event: SpreadEvent = {
234
+ spread: spreadResults,
235
+ effectiveD,
236
+ triggeredAt,
237
+ }
238
+
239
+ this.emitEvent(event)
240
+ return event
241
+ }
242
+
243
+ private computeEffectiveD(): number {
244
+ const d = this.config.spreadDistance
245
+ if (!this.config.dynamicSpreadDistance) return d
246
+
247
+ const maxD = Math.max(d, Math.floor(this.deps.clusterSize / 2))
248
+ const windowMs = this.config.departureDebounceMs * 4
249
+ const now = Date.now()
250
+
251
+ // Prune old departure timestamps
252
+ this.departureTimestamps = this.departureTimestamps.filter(
253
+ ts => now - ts < windowMs
254
+ )
255
+
256
+ // Rapid churn: 3+ departures in window → increase d by 1
257
+ if (this.departureTimestamps.length >= 3) {
258
+ return Math.min(d + 1, maxD)
259
+ }
260
+
261
+ // Low cluster health: observed cohort shrunk relative to expected
262
+ // We approximate observed cohort size from FRET diagnostics
263
+ const diag: any = (this.deps.fret as any).getDiagnostics?.()
264
+ const estimate = diag?.estimate ?? diag?.n
265
+ if (typeof estimate === 'number' && Number.isFinite(estimate) && estimate > 0) {
266
+ const ratio = estimate / this.deps.clusterSize
267
+ if (ratio < this.config.healthThreshold) {
268
+ const scaled = Math.ceil(d * (this.deps.clusterSize / estimate))
269
+ return Math.min(scaled, maxD)
270
+ }
271
+ }
272
+
273
+ return d
274
+ }
275
+
276
+ private emitEvent(event: SpreadEvent): void {
277
+ for (const handler of this.handlers) {
278
+ try {
279
+ handler(event)
280
+ } catch (err) {
281
+ log('handler error: %O', err)
282
+ }
283
+ }
284
+ }
285
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./cluster/client.js";
2
2
  export * from "./cluster/cluster-repo.js";
3
3
  export * from "./cluster/service.js";
4
4
  export * from "./cluster/rebalance-monitor.js";
5
+ export * from "./cluster/spread-on-churn.js";
5
6
  export * from "./cluster/block-transfer.js";
6
7
  export * from "./cluster/block-transfer-service.js";
7
8
  export * from "./protocol-client.js";
@@ -31,3 +32,8 @@ export * from "./network/network-manager-service.js";
31
32
  export * from "./network/get-network-manager.js";
32
33
  export * from "./reputation/index.js";
33
34
  export * from "./dispute/index.js";
35
+ export * from "./cluster/i-transaction-state-store.js";
36
+ export * from "./cluster/memory-transaction-state-store.js";
37
+ export * from "./cluster/persistent-transaction-state-store.js";
38
+ export * from "./storage/i-kv-store.js";
39
+ export * from "./storage/memory-kv-store.js";