@optimystic/db-p2p 0.13.4 → 0.13.5

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.
@@ -37,10 +37,11 @@ interface ClusterTransactionState {
37
37
  /** Manages distributed transactions across clusters */
38
38
  export class ClusterCoordinator {
39
39
  private transactions: Map<string, ClusterTransactionState> = new Map();
40
- private readonly retryInitialIntervalMs = 2000;
41
- private readonly retryBackoffFactor = 2;
42
- private readonly retryMaxIntervalMs = 30000;
43
- private readonly retryMaxAttempts = 5;
40
+ private readonly retryInitialIntervalMs: number;
41
+ private readonly retryBackoffFactor: number;
42
+ private readonly retryMaxIntervalMs: number;
43
+ private readonly retryMaxAttempts: number;
44
+ private readonly commitBroadcastImmediateRetries: number;
44
45
 
45
46
  constructor(
46
47
  private readonly keyNetwork: IKeyNetwork,
@@ -54,7 +55,13 @@ export class ClusterCoordinator {
54
55
  private readonly fretService?: FretService,
55
56
  private readonly reputation?: IPeerReputation,
56
57
  private readonly stateStore?: ITransactionStateStore
57
- ) { }
58
+ ) {
59
+ this.retryInitialIntervalMs = cfg.commitBroadcastRetryInitialMs ?? 250;
60
+ this.retryBackoffFactor = cfg.commitBroadcastRetryBackoffFactor ?? 2;
61
+ this.retryMaxIntervalMs = cfg.commitBroadcastRetryMaxIntervalMs ?? 8000;
62
+ this.retryMaxAttempts = cfg.commitBroadcastRetryMaxAttempts ?? 5;
63
+ this.commitBroadcastImmediateRetries = cfg.commitBroadcastImmediateRetries ?? 1;
64
+ }
58
65
 
59
66
  /**
60
67
  * Creates a base 58 BTC string hash for a message to uniquely identify a transaction
@@ -528,27 +535,7 @@ export class ClusterCoordinator {
528
535
  // so each peer can independently reach consensus and execute the operations.
529
536
  // Without this, only the coordinator's local cluster executes — remote peers
530
537
  // never see enough commits to reach consensus on their own.
531
- const broadcastResults = await Promise.allSettled(
532
- peerIds.map(peerIdStr => {
533
- const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
534
- return isLocal
535
- ? this.localCluster!.update(record)
536
- : this.createClusterClient(peerIdFromString(peerIdStr)).update(record).catch(err => {
537
- log('cluster-tx:consensus-broadcast-error', { messageHash: record.messageHash, peerId: peerIdStr, error: (err as Error).message });
538
- return null;
539
- });
540
- })
541
- );
542
-
543
- // Check for broadcast failures (excluding local)
544
- const broadcastFailures: string[] = [];
545
- broadcastResults.forEach((result, idx) => {
546
- const peerId = peerIds[idx]!;
547
- const isLocal = this.localCluster && peerId === this.localCluster.peerId.toString();
548
- if (!isLocal && (result.status === 'rejected' || result.value === null)) {
549
- broadcastFailures.push(peerId);
550
- }
551
- });
538
+ const { failures: broadcastFailures } = await this.broadcastMergedRecord(record, peerIds);
552
539
  if (broadcastFailures.length > 0) {
553
540
  this.scheduleCommitRetry(record.messageHash, record, broadcastFailures);
554
541
  } else {
@@ -565,6 +552,59 @@ export class ClusterCoordinator {
565
552
  return record;
566
553
  }
567
554
 
555
+ /**
556
+ * Broadcast the merged commit record to every peer, with `commitBroadcastImmediateRetries`
557
+ * in-line re-attempts per peer before giving up. The libp2p connection used during
558
+ * the prior commit phase is typically still warm, so a single immediate retry recovers
559
+ * most transient stream errors without falling back to the scheduled retry timer.
560
+ * Local cluster is invoked exactly once — local failures are fatal, not transient.
561
+ */
562
+ private async broadcastMergedRecord(record: ClusterRecord, peerIds: string[]): Promise<{ failures: string[] }> {
563
+ const maxAttempts = 1 + Math.max(0, this.commitBroadcastImmediateRetries);
564
+ const results = await Promise.all(peerIds.map(async peerIdStr => {
565
+ const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
566
+ if (isLocal) {
567
+ try {
568
+ await this.localCluster!.update(record);
569
+ return { peerId: peerIdStr, success: true as const };
570
+ } catch (err) {
571
+ log('cluster-tx:consensus-broadcast-error', {
572
+ messageHash: record.messageHash,
573
+ peerId: peerIdStr,
574
+ error: (err as Error).message
575
+ });
576
+ return { peerId: peerIdStr, success: false as const };
577
+ }
578
+ }
579
+ let lastError: unknown;
580
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
581
+ try {
582
+ await this.createClusterClient(peerIdFromString(peerIdStr)).update(record);
583
+ return { peerId: peerIdStr, success: true as const };
584
+ } catch (err) {
585
+ lastError = err;
586
+ if (attempt < maxAttempts) {
587
+ log('cluster-tx:consensus-broadcast-retry', {
588
+ messageHash: record.messageHash,
589
+ peerId: peerIdStr,
590
+ attempt,
591
+ error: (err as Error).message
592
+ });
593
+ }
594
+ }
595
+ }
596
+ log('cluster-tx:consensus-broadcast-error', {
597
+ messageHash: record.messageHash,
598
+ peerId: peerIdStr,
599
+ attempts: maxAttempts,
600
+ error: lastError instanceof Error ? lastError.message : String(lastError)
601
+ });
602
+ return { peerId: peerIdStr, success: false as const };
603
+ }));
604
+ const failures = results.filter(r => !r.success).map(r => r.peerId);
605
+ return { failures };
606
+ }
607
+
568
608
  private updateTransactionRecord(record: ClusterRecord, stage: string): void {
569
609
  const state = this.transactions.get(record.messageHash);
570
610
  if (!state) {
@@ -65,6 +65,14 @@ export class CoordinatorRepo implements IRepo {
65
65
  private readonly localPeerId?: PeerId;
66
66
  private readonly responsibilityCache = new LruMap<string, { inCluster: boolean, expires: number }>(1000);
67
67
  private static readonly RESPONSIBILITY_TTL_MS = 60_000;
68
+ private readonly lastSeenCommitMs = new LruMap<string, number>(1000);
69
+ private readonly readRepairMode: 'off' | 'lazy' | 'paranoid';
70
+ private readonly readRepairWindowMs: number;
71
+ private readonly readRepairSampleRate: number;
72
+ /** Test seam: overridable clock for window-based read-repair gating. */
73
+ now: () => number = () => Date.now();
74
+ /** Test seam: overridable RNG (0..1) for sample-rate gating. */
75
+ rand: () => number = () => Math.random();
68
76
 
69
77
  constructor(
70
78
  readonly keyNetwork: IKeyNetwork,
@@ -86,8 +94,19 @@ export class CoordinatorRepo implements IRepo {
86
94
  minAbsoluteClusterSize: cfg?.minAbsoluteClusterSize ?? 3,
87
95
  allowClusterDownsize: cfg?.allowClusterDownsize ?? true,
88
96
  clusterSizeTolerance: cfg?.clusterSizeTolerance ?? 0.5,
89
- partitionDetectionWindow: cfg?.partitionDetectionWindow ?? 60000
97
+ partitionDetectionWindow: cfg?.partitionDetectionWindow ?? 60000,
98
+ commitBroadcastRetryInitialMs: cfg?.commitBroadcastRetryInitialMs ?? 250,
99
+ commitBroadcastRetryBackoffFactor: cfg?.commitBroadcastRetryBackoffFactor ?? 2,
100
+ commitBroadcastRetryMaxIntervalMs: cfg?.commitBroadcastRetryMaxIntervalMs ?? 8000,
101
+ commitBroadcastRetryMaxAttempts: cfg?.commitBroadcastRetryMaxAttempts ?? 5,
102
+ commitBroadcastImmediateRetries: cfg?.commitBroadcastImmediateRetries ?? 1,
103
+ readRepairMode: cfg?.readRepairMode ?? 'lazy',
104
+ readRepairWindowMs: cfg?.readRepairWindowMs ?? 10000,
105
+ readRepairSampleRate: cfg?.readRepairSampleRate ?? 0
90
106
  };
107
+ this.readRepairMode = policy.readRepairMode!;
108
+ this.readRepairWindowMs = policy.readRepairWindowMs!;
109
+ this.readRepairSampleRate = policy.readRepairSampleRate!;
91
110
  const localClusterRef = localCluster && localPeerId ? {
92
111
  update: localCluster.update.bind(localCluster),
93
112
  peerId: localPeerId,
@@ -159,24 +178,44 @@ export class CoordinatorRepo implements IRepo {
159
178
  // First try local storage
160
179
  const localResult = await this.storageRepo.get(blockGets, options);
161
180
 
162
- // Check for blocks that weren't found locally - try to fetch from cluster peers
163
- // Skip cluster fetch if this is already a sync request (to prevent recursive queries)
181
+ // Decide per-block whether to consult cluster peers. Two triggers:
182
+ // (a) Missing block isn't present locally at all (legacy behavior).
183
+ // (b) Stale-by-policy — block is present but read-repair policy says verify.
184
+ // Skip cluster fetch if this is already a sync request (to prevent recursive queries).
164
185
  const skipClusterFetch = (options as any)?.skipClusterFetch;
165
186
  if (this.clusterLatestCallback && !skipClusterFetch) {
166
187
  for (const blockId of blockGets.blockIds) {
167
188
  const localEntry = localResult[blockId];
168
- // If block not found locally (no state), try cluster peers
169
- if (!localEntry?.state?.latest) {
170
- try {
171
- await this.fetchBlockFromCluster(blockId, blockGets.context);
172
- // Re-fetch after sync
173
- const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
174
- if (refreshed[blockId]) {
175
- localResult[blockId] = refreshed[blockId];
189
+ const localRev = localEntry?.state?.latest?.rev;
190
+ const isMissing = !localEntry?.state?.latest;
191
+ const isStale = !isMissing && this.shouldReadRepair(blockId);
192
+ if (!isMissing && !isStale) continue;
193
+
194
+ if (isStale) {
195
+ log('cluster-tx:read-repair-triggered', {
196
+ blockId,
197
+ mode: this.readRepairMode,
198
+ ageMs: this.ageMs(blockId),
199
+ localRev
200
+ });
201
+ }
202
+
203
+ try {
204
+ await this.fetchBlockFromCluster(blockId, blockGets.context);
205
+ const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
206
+ const newRev = refreshed[blockId]?.state?.latest?.rev;
207
+ if (refreshed[blockId]) {
208
+ localResult[blockId] = refreshed[blockId];
209
+ }
210
+ if (isStale) {
211
+ if (typeof newRev === 'number' && typeof localRev === 'number' && newRev > localRev) {
212
+ log('cluster-tx:read-repair-applied', { blockId, oldRev: localRev, newRev });
213
+ } else {
214
+ log('cluster-tx:read-repair-noop', { blockId });
176
215
  }
177
- } catch (err) {
178
- log('cluster-fetch:error', { blockId, error: (err as Error).message });
179
216
  }
217
+ } catch (err) {
218
+ log('cluster-fetch:error', { blockId, error: (err as Error).message });
180
219
  }
181
220
  }
182
221
  }
@@ -184,6 +223,44 @@ export class CoordinatorRepo implements IRepo {
184
223
  return localResult;
185
224
  }
186
225
 
226
+ /** Decide whether the read-repair policy wants us to consult the cluster for a present-but-possibly-stale block. */
227
+ private shouldReadRepair(blockId: BlockId): boolean {
228
+ switch (this.readRepairMode) {
229
+ case 'off': return false;
230
+ case 'paranoid': return true;
231
+ case 'lazy': {
232
+ const lastSeen = this.lastSeenCommitMs.get(blockId);
233
+ if (lastSeen == null) return true;
234
+ if (this.now() - lastSeen > this.readRepairWindowMs) return true;
235
+ if (this.readRepairSampleRate > 0 && this.rand() < this.readRepairSampleRate) return true;
236
+ return false;
237
+ }
238
+ }
239
+ }
240
+
241
+ /** Milliseconds since we last marked this block fresh, or undefined if never. */
242
+ private ageMs(blockId: BlockId): number | undefined {
243
+ const lastSeen = this.lastSeenCommitMs.get(blockId);
244
+ return lastSeen == null ? undefined : this.now() - lastSeen;
245
+ }
246
+
247
+ /** Mark blocks as freshly observed from cluster authority (post-commit or post-fetch). */
248
+ private markBlocksSeen(blockIds: BlockId[]): void {
249
+ const now = this.now();
250
+ for (const id of blockIds) {
251
+ this.lastSeenCommitMs.set(id, now);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Test seam: directly set the last-seen timestamp for a block. Used by read-repair
257
+ * specs to simulate "the local commit happened at time T" without needing to drive
258
+ * a full pend/commit cycle through the cluster coordinator.
259
+ */
260
+ setLastSeenForTest(blockId: BlockId, ts: number): void {
261
+ this.lastSeenCommitMs.set(blockId, ts);
262
+ }
263
+
187
264
  private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext): Promise<void> {
188
265
  if (!this.clusterLatestCallback) return;
189
266
 
@@ -210,6 +287,7 @@ export class CoordinatorRepo implements IRepo {
210
287
  // Found on cluster - trigger restoration to sync the block
211
288
  await this.storageRepo.get({ blockIds: [blockId], context: { committed: [clusterLatest], rev: clusterLatest.rev } });
212
289
  log('cluster-fetch:synced', { blockId, rev: clusterLatest.rev });
290
+ this.markBlocksSeen([blockId]);
213
291
  }
214
292
  }
215
293
 
@@ -327,7 +405,9 @@ export class CoordinatorRepo implements IRepo {
327
405
 
328
406
  const peerCount = await this.coordinator.getClusterSize(blockIds[0]!);
329
407
  if (peerCount <= 1) {
330
- return await this.storageRepo.commit(request, options);
408
+ const result = await this.storageRepo.commit(request, options);
409
+ if (result.success) this.markBlocksSeen(blockIds);
410
+ return result;
331
411
  }
332
412
 
333
413
  const message: RepoMessage = {
@@ -338,6 +418,7 @@ export class CoordinatorRepo implements IRepo {
338
418
  try {
339
419
  const { record, localExecuted } = await this.coordinator.executeClusterTransaction(blockIds[0]!, message, options);
340
420
  if (localExecuted) {
421
+ this.markBlocksSeen(blockIds);
341
422
  return { success: true };
342
423
  }
343
424
  // Local cluster didn't execute during consensus. Attempt a local commit,
@@ -346,13 +427,16 @@ export class CoordinatorRepo implements IRepo {
346
427
  // after missing the pend phase (unreachable during pend, fresh join, etc.).
347
428
  // The cluster's majority is authoritative; this peer will catch up via sync.
348
429
  try {
349
- return await this.storageRepo.commit(request, options);
430
+ const result = await this.storageRepo.commit(request, options);
431
+ if (result.success) this.markBlocksSeen(blockIds);
432
+ return result;
350
433
  } catch (err) {
351
434
  if (clusterReachedCommitConsensus(record)) {
352
435
  log('coordinator-repo:commit-local-failed-cluster-succeeded', {
353
436
  actionId: request.actionId,
354
437
  error: (err as Error).message
355
438
  });
439
+ this.markBlocksSeen(blockIds);
356
440
  return { success: true };
357
441
  }
358
442
  throw err;