@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
@@ -19,6 +19,7 @@ import { coordinatorRepo } from './repo/coordinator-repo.js';
19
19
  import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
20
20
  import { ClusterClient } from './cluster/client.js';
21
21
  import type { IRepo, ICluster, ITransactionValidator } from '@optimystic/db-core';
22
+ import type { ITransactionStateStore } from './cluster/i-transaction-state-store.js';
22
23
  import { networkManagerService } from './network/network-manager-service.js';
23
24
  import { fretService, Libp2pFretService } from 'p2p-fret';
24
25
  import { syncService } from './sync/service.js';
@@ -93,6 +94,9 @@ export type NodeOptions = {
93
94
 
94
95
  /** Dispute protocol configuration */
95
96
  dispute?: Partial<DisputeConfig>;
97
+
98
+ /** Optional persistent store for 2PC transaction state (enables crash recovery) */
99
+ transactionStateStore?: ITransactionStateStore;
96
100
  };
97
101
 
98
102
  function resolveStorage(provider: RawStorageProvider | undefined): IRawStorage {
@@ -300,7 +304,8 @@ export async function createLibp2pNodeBase(
300
304
  fretService: fretSvc,
301
305
  validator: options.validator,
302
306
  reputation,
303
- consensusConfig
307
+ consensusConfig,
308
+ stateStore: options.transactionStateStore
304
309
  });
305
310
 
306
311
  const coordinatorRepoFactory = coordinatorRepo(
@@ -311,7 +316,8 @@ export async function createLibp2pNodeBase(
311
316
  ...consensusConfig
312
317
  },
313
318
  fretSvc,
314
- reputation
319
+ reputation,
320
+ options.transactionStateStore
315
321
  );
316
322
 
317
323
  // Create callback for querying cluster peers for their latest block revision
@@ -342,6 +348,12 @@ export async function createLibp2pNodeBase(
342
348
  clusterLatestCallback
343
349
  });
344
350
 
351
+ // Recover persisted transaction state before accepting new requests
352
+ if (options.transactionStateStore) {
353
+ await (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).recoverTransactions();
354
+ await (coordinatedRepo as import('./repo/coordinator-repo.js').CoordinatorRepo).recoverTransactions();
355
+ }
356
+
345
357
  // Initialize Arachnode ring membership and restoration
346
358
  const enableArachnode = options.arachnode?.enableRingZulu ?? true;
347
359
  if (enableArachnode) {
@@ -439,6 +451,15 @@ export async function createLibp2pNodeBase(
439
451
  });
440
452
  }
441
453
 
454
+ // Cleanup cluster member intervals on node stop
455
+ {
456
+ const previousStop = node.stop.bind(node);
457
+ node.stop = async () => {
458
+ (clusterImpl as import('./cluster/cluster-repo.js').ClusterMember).dispose();
459
+ await previousStop();
460
+ };
461
+ }
462
+
442
463
  // Expose coordinated repo and storage for external use
443
464
  (node as any).coordinatedRepo = coordinatedRepo;
444
465
  (node as any).storageRepo = storageRepo;
@@ -6,6 +6,7 @@ import { toString as u8ToString } from 'uint8arrays/to-string'
6
6
  import type { IPeerReputation } from '../reputation/types.js'
7
7
  import { PenaltyReason } from '../reputation/types.js'
8
8
  import { RebalanceMonitor, type RebalanceMonitorConfig } from '../cluster/rebalance-monitor.js'
9
+ import { SpreadOnChurnMonitor, type SpreadOnChurnConfig, type SpreadOnChurnDeps } from '../cluster/spread-on-churn.js'
9
10
  import type { PartitionDetector } from '../cluster/partition-detector.js'
10
11
  import type { ArachnodeFretAdapter } from '../storage/arachnode-fret-adapter.js'
11
12
 
@@ -41,6 +42,7 @@ export class NetworkManagerService implements Startable {
41
42
  private reputation?: IPeerReputation
42
43
  private libp2pRef: Libp2p | undefined
43
44
  private rebalanceMonitor?: RebalanceMonitor
45
+ private spreadOnChurnMonitor?: SpreadOnChurnMonitor
44
46
 
45
47
  constructor(private readonly components: Components, init: NetworkManagerServiceInit = {}) {
46
48
  this.log = components.logger.forComponent('db-p2p:network-manager')
@@ -88,6 +90,33 @@ export class NetworkManagerService implements Startable {
88
90
  return this.rebalanceMonitor
89
91
  }
90
92
 
93
+ /**
94
+ * Initialize the spread-on-churn monitor. Call after libp2p, FRET are available.
95
+ * Caller provides repo and peerNetwork (not held by NetworkManagerService directly).
96
+ */
97
+ initSpreadOnChurnMonitor(
98
+ partitionDetector: PartitionDetector,
99
+ repo: SpreadOnChurnDeps['repo'],
100
+ peerNetwork: SpreadOnChurnDeps['peerNetwork'],
101
+ clusterSize: number,
102
+ config?: Partial<SpreadOnChurnConfig>
103
+ ): SpreadOnChurnMonitor {
104
+ const libp2p = this.getLibp2p()
105
+ const fret = this.getFret()
106
+ if (!libp2p || !fret) {
107
+ throw new Error('Cannot init SpreadOnChurnMonitor: libp2p or FRET not available')
108
+ }
109
+ this.spreadOnChurnMonitor = new SpreadOnChurnMonitor(
110
+ { libp2p, fret, partitionDetector, repo, peerNetwork, clusterSize },
111
+ config
112
+ )
113
+ return this.spreadOnChurnMonitor
114
+ }
115
+
116
+ getSpreadOnChurnMonitor(): SpreadOnChurnMonitor | undefined {
117
+ return this.spreadOnChurnMonitor
118
+ }
119
+
91
120
  private getLibp2p(): Libp2p | undefined {
92
121
  return this.libp2pRef ?? this.components.libp2p;
93
122
  }
@@ -113,6 +142,9 @@ export class NetworkManagerService implements Startable {
113
142
  if (this.rebalanceMonitor) {
114
143
  await this.rebalanceMonitor.stop()
115
144
  }
145
+ if (this.spreadOnChurnMonitor) {
146
+ await this.spreadOnChurnMonitor.stop()
147
+ }
116
148
  this.running = false
117
149
  }
118
150
 
@@ -10,6 +10,7 @@ import type { ClusterLogPeerOutcome } from './types.js'
10
10
  import type { FretService } from "p2p-fret";
11
11
  import type { IPeerReputation } from "../reputation/types.js";
12
12
  import { PenaltyReason } from "../reputation/types.js";
13
+ import type { ITransactionStateStore } from "../cluster/i-transaction-state-store.js";
13
14
 
14
15
  const log = createLogger('cluster')
15
16
 
@@ -35,7 +36,6 @@ interface ClusterTransactionState {
35
36
 
36
37
  /** Manages distributed transactions across clusters */
37
38
  export class ClusterCoordinator {
38
- // TODO: move this into a state management interface so that transaction state can be persisted
39
39
  private transactions: Map<string, ClusterTransactionState> = new Map();
40
40
  private readonly retryInitialIntervalMs = 2000;
41
41
  private readonly retryBackoffFactor = 2;
@@ -52,7 +52,8 @@ export class ClusterCoordinator {
52
52
  wasTransactionExecuted?: (messageHash: string) => boolean;
53
53
  },
54
54
  private readonly fretService?: FretService,
55
- private readonly reputation?: IPeerReputation
55
+ private readonly reputation?: IPeerReputation,
56
+ private readonly stateStore?: ITransactionStateStore
56
57
  ) { }
57
58
 
58
59
  /**
@@ -156,6 +157,7 @@ export class ClusterCoordinator {
156
157
  lastUpdate: Date.now()
157
158
  };
158
159
  this.transactions.set(messageHash, state);
160
+ this.persistCoordinatorState(messageHash, record, 'promising');
159
161
  log('cluster-tx:transaction-store', {
160
162
  messageHash,
161
163
  transactionKeys: Array.from(this.transactions.keys())
@@ -185,6 +187,7 @@ export class ClusterCoordinator {
185
187
  // Wait a bit before cleanup to allow any in-flight responses to arrive
186
188
  setTimeout(() => {
187
189
  this.transactions.delete(messageHash);
190
+ this.deleteCoordinatorState(messageHash);
188
191
  log('cluster-tx:transaction-remove', {
189
192
  messageHash,
190
193
  remaining: Array.from(this.transactions.keys())
@@ -282,6 +285,7 @@ export class ClusterCoordinator {
282
285
  });
283
286
  }
284
287
 
288
+ this.persistCoordinatorState(promised.record.messageHash, promised.record, 'committing');
285
289
  return await this.commitTransaction(promised.record);
286
290
  }
287
291
 
@@ -606,6 +610,11 @@ export class ClusterCoordinator {
606
610
  intervalMs: baseInterval,
607
611
  timer
608
612
  };
613
+ this.persistCoordinatorState(messageHash, state.record, 'broadcasting', {
614
+ pendingPeers: Array.from(pendingPeers),
615
+ attempt: nextAttempt,
616
+ intervalMs: baseInterval
617
+ });
609
618
  log('cluster-tx:retry-scheduled', { messageHash, attempt: nextAttempt, missingPeers, delayMs: baseInterval });
610
619
  }
611
620
 
@@ -671,10 +680,71 @@ export class ClusterCoordinator {
671
680
  // Clean up the transaction after retry is complete
672
681
  setTimeout(() => {
673
682
  this.transactions.delete(messageHash);
683
+ this.deleteCoordinatorState(messageHash);
674
684
  log('cluster-tx:transaction-remove', {
675
685
  messageHash,
676
686
  remaining: Array.from(this.transactions.keys())
677
687
  });
678
688
  }, 100);
679
689
  }
690
+
691
+ /** Fire-and-forget persist — errors are logged, never thrown. */
692
+ private persistCoordinatorState(
693
+ messageHash: string,
694
+ record: ClusterRecord,
695
+ phase: 'promising' | 'committing' | 'broadcasting',
696
+ retryState?: { pendingPeers: string[]; attempt: number; intervalMs: number }
697
+ ): void {
698
+ if (!this.stateStore) return;
699
+ this.stateStore.saveCoordinatorState(messageHash, {
700
+ messageHash,
701
+ record,
702
+ lastUpdate: Date.now(),
703
+ phase,
704
+ retryState
705
+ }).catch(err => log('cluster-tx:persist-error', { messageHash, error: (err as Error).message }));
706
+ }
707
+
708
+ /** Fire-and-forget delete — errors are logged, never thrown. */
709
+ private deleteCoordinatorState(messageHash: string): void {
710
+ if (!this.stateStore) return;
711
+ this.stateStore.deleteCoordinatorState(messageHash)
712
+ .catch(err => log('cluster-tx:persist-delete-error', { messageHash, error: (err as Error).message }));
713
+ }
714
+
715
+ /**
716
+ * Recover coordinator transactions from persistent store after a restart.
717
+ * Called during node startup, before accepting new requests.
718
+ */
719
+ async recoverTransactions(): Promise<void> {
720
+ if (!this.stateStore) return;
721
+ const states = await this.stateStore.getAllCoordinatorStates();
722
+ for (const state of states) {
723
+ const { messageHash } = state;
724
+ // Expired — clean up
725
+ if (state.record.message.expiration && state.record.message.expiration < Date.now()) {
726
+ log('cluster-tx:recovery-expired', { messageHash });
727
+ await this.stateStore.deleteCoordinatorState(messageHash);
728
+ continue;
729
+ }
730
+ // Broadcasting phase with retry state — resume retries
731
+ if (state.phase === 'broadcasting' && state.retryState) {
732
+ log('cluster-tx:recovery-resume-broadcast', { messageHash, attempt: state.retryState.attempt });
733
+ const pending = new Pending(Promise.resolve(state.record));
734
+ const txState: ClusterTransactionState = {
735
+ messageHash,
736
+ record: state.record,
737
+ pending,
738
+ lastUpdate: state.lastUpdate
739
+ };
740
+ this.transactions.set(messageHash, txState);
741
+ // Schedule retry from where we left off
742
+ this.scheduleCommitRetry(messageHash, state.record, state.retryState.pendingPeers);
743
+ continue;
744
+ }
745
+ // Promising or committing — cannot resume (caller context is gone)
746
+ log('cluster-tx:recovery-stale', { messageHash, phase: state.phase });
747
+ await this.stateStore.deleteCoordinatorState(messageHash);
748
+ }
749
+ }
680
750
  }
@@ -7,6 +7,7 @@ import { peerIdFromString } from "@libp2p/peer-id";
7
7
  import type { FretService } from "p2p-fret";
8
8
  import { createLogger } from '../logger.js';
9
9
  import type { IPeerReputation } from "../reputation/types.js";
10
+ import type { ITransactionStateStore } from "../cluster/i-transaction-state-store.js";
10
11
 
11
12
  const log = createLogger('coordinator-repo');
12
13
 
@@ -40,7 +41,8 @@ export function coordinatorRepo(
40
41
  createClusterClient: (peerId: PeerId) => ClusterClient,
41
42
  cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
42
43
  fretService?: FretService,
43
- reputation?: IPeerReputation
44
+ reputation?: IPeerReputation,
45
+ stateStore?: ITransactionStateStore
44
46
  ): (components: CoordinatorRepoComponents) => CoordinatorRepo {
45
47
  return (components: CoordinatorRepoComponents) => new CoordinatorRepo(
46
48
  keyNetwork,
@@ -51,7 +53,8 @@ export function coordinatorRepo(
51
53
  components.localPeerId,
52
54
  fretService,
53
55
  components.clusterLatestCallback,
54
- reputation
56
+ reputation,
57
+ stateStore
55
58
  );
56
59
  }
57
60
 
@@ -72,7 +75,8 @@ export class CoordinatorRepo implements IRepo {
72
75
  localPeerId?: PeerId,
73
76
  fretService?: FretService,
74
77
  private readonly clusterLatestCallback?: ClusterLatestCallback,
75
- reputation?: IPeerReputation
78
+ reputation?: IPeerReputation,
79
+ stateStore?: ITransactionStateStore
76
80
  ) {
77
81
  this.localPeerId = localPeerId;
78
82
  const policy: ClusterConsensusConfig & { clusterSize: number } = {
@@ -89,7 +93,12 @@ export class CoordinatorRepo implements IRepo {
89
93
  peerId: localPeerId,
90
94
  wasTransactionExecuted: localCluster.wasTransactionExecuted?.bind(localCluster)
91
95
  } : undefined;
92
- this.coordinator = new ClusterCoordinator(keyNetwork, createClusterClient, policy, localClusterRef, fretService, reputation);
96
+ this.coordinator = new ClusterCoordinator(keyNetwork, createClusterClient, policy, localClusterRef, fretService, reputation, stateStore);
97
+ }
98
+
99
+ /** Recover coordinator transactions from persistent store after a restart. */
100
+ async recoverTransactions(): Promise<void> {
101
+ await this.coordinator.recoverTransactions();
93
102
  }
94
103
 
95
104
  /**
@@ -0,0 +1,8 @@
1
+ /** Portable async key-value store. Platform packages provide implementations. */
2
+ export interface IKVStore {
3
+ get(key: string): Promise<string | undefined>;
4
+ set(key: string, value: string): Promise<void>;
5
+ delete(key: string): Promise<void>;
6
+ /** Return all keys matching the given prefix */
7
+ list(prefix: string): Promise<string[]>;
8
+ }
@@ -0,0 +1,28 @@
1
+ import type { IKVStore } from "./i-kv-store.js";
2
+
3
+ /** In-memory IKVStore backed by a Map. Used for testing. */
4
+ export class MemoryKVStore implements IKVStore {
5
+ private readonly store = new Map<string, string>();
6
+
7
+ async get(key: string): Promise<string | undefined> {
8
+ return this.store.get(key);
9
+ }
10
+
11
+ async set(key: string, value: string): Promise<void> {
12
+ this.store.set(key, value);
13
+ }
14
+
15
+ async delete(key: string): Promise<void> {
16
+ this.store.delete(key);
17
+ }
18
+
19
+ async list(prefix: string): Promise<string[]> {
20
+ const result: string[] = [];
21
+ for (const key of this.store.keys()) {
22
+ if (key.startsWith(prefix)) {
23
+ result.push(key);
24
+ }
25
+ }
26
+ return result;
27
+ }
28
+ }