@optimystic/db-p2p 0.18.0 → 0.19.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.
@@ -802,12 +802,20 @@ export async function createLibp2pNodeBase(
802
802
  options.transactionStateStore
803
803
  );
804
804
 
805
- // Create callback for querying cluster peers for their latest block revision
805
+ // Create callback for querying cluster peers for their latest block revision. Three-way
806
+ // contract (see ClusterLatestCallback): an ActionRev is the peer's claim, a resolved
807
+ // `undefined` is the peer answering "I hold nothing", and a REJECTION is silence — the
808
+ // coordinator counts it as "did not answer" and refuses to report an authoritative absent
809
+ // over it. Transport errors must therefore propagate, not collapse into `undefined` (that
810
+ // collapse let a slow two-node cohort report a missing block as authoritatively absent —
811
+ // ticket cluster-read-consult-cannot-report-unreachable).
806
812
  const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId, context?) => {
807
813
  // Self-read short-circuit: dialling self via SyncClient is a round trip
808
814
  // with no remote on the other end, and on nodes without listen addresses
809
815
  // (solo WebSocket-only, bare-RN, etc.) the self-dial can hang the dial
810
- // queue. Read directly from the local storage repo instead.
816
+ // queue. Read directly from the local storage repo instead. The catch stays:
817
+ // a local storage error is not a cohort peer being unreachable, and the
818
+ // coordinator ignores a self rejection anyway.
811
819
  if (peerId.equals(node.peerId)) {
812
820
  try {
813
821
  const result = await storageRepo.get({ blockIds: [blockId], context });
@@ -817,21 +825,22 @@ export async function createLibp2pNodeBase(
817
825
  }
818
826
  }
819
827
  const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
820
- try {
821
- const response = await syncClient.requestBlock({ blockId, rev: undefined });
822
- if (response.success && response.archive) {
823
- const revisions = Object.keys(response.archive.revisions).map(Number);
824
- if (revisions.length > 0) {
825
- const maxRev = Math.max(...revisions);
826
- const revisionData = response.archive.revisions[maxRev];
827
- if (revisionData?.action) {
828
- return { actionId: revisionData.action.actionId, rev: maxRev };
829
- }
828
+ // No try/catch: a dial or protocol failure rejects through to the coordinator, whose
829
+ // per-peer deadline also bounds a hung request — slowness needs no race here.
830
+ const response = await syncClient.requestBlock({ blockId, rev: undefined });
831
+ if (response.success && response.archive) {
832
+ const revisions = Object.keys(response.archive.revisions).map(Number);
833
+ if (revisions.length > 0) {
834
+ const maxRev = Math.max(...revisions);
835
+ const revisionData = response.archive.revisions[maxRev];
836
+ if (revisionData?.action) {
837
+ return { actionId: revisionData.action.actionId, rev: maxRev };
830
838
  }
831
839
  }
832
- } catch {
833
- // Peer may be unreachable - return undefined to skip this peer
834
840
  }
841
+ // The peer DID answer, without data: `success:false` is the sync service's "Block not
842
+ // found in local storage", and an archive with no usable revisions holds nothing either
843
+ // way. Both are absent claims, not silence.
835
844
  return undefined;
836
845
  };
837
846
 
@@ -1,5 +1,5 @@
1
1
  import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, StaleFailure, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext, ClusterRecord } from "@optimystic/db-core";
2
- import { LruMap, blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
+ import { LruMap, blockIdsForTransforms, highestStaleAt, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
3
3
  import { ClusterCoordinator, ValidatorRejectionError } from "./cluster-coordinator.js";
4
4
  import type { PeerId } from "@libp2p/interface";
5
5
  import { peerIdFromString } from "@libp2p/peer-id";
@@ -27,6 +27,9 @@ const log = createLogger('coordinator-repo');
27
27
  */
28
28
  export type AcquireBlockCallback = ReconcileBlockCallback;
29
29
 
30
+ /** How long one cohort peer gets to answer the latest-revision consult before it counts as silent. */
31
+ const LATEST_QUERY_TIMEOUT_MS = 1000;
32
+
30
33
  /** True when a freshly-read local revision is strictly ahead of the baseline the repair started from. */
31
34
  function isAdvanceOver(rev: number | undefined, baselineRev: number | undefined): boolean {
32
35
  return typeof rev === 'number' && (baselineRev === undefined || rev > baselineRev);
@@ -46,22 +49,6 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
46
49
  });
47
50
  }
48
51
 
49
- /**
50
- * Resolve `undefined` if `promise` has not settled within `ms` — a slow peer is skipped, not an
51
- * error. Same timer discipline as {@link withDeadline}: one repair pass races this once per cohort
52
- * peer, so leaving the handles pending would keep the event loop alive for the full timeout after
53
- * every peer has already answered.
54
- */
55
- function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
56
- let timer: ReturnType<typeof setTimeout> | undefined;
57
- const expiry = new Promise<undefined>(resolve => {
58
- timer = setTimeout(() => resolve(undefined), ms);
59
- });
60
- return Promise.race([promise, expiry]).finally(() => {
61
- if (timer !== undefined) clearTimeout(timer);
62
- });
63
- }
64
-
65
52
  /**
66
53
  * What one round of polling the cohort learned about a block: the revision the OTHER
67
54
  * cohort members corroborated, and what this node itself already holds. The two are kept
@@ -74,6 +61,14 @@ interface ClusterLatestQuery {
74
61
  corroborated?: ActionRev;
75
62
  /** This node's own latest for the block, as answered by the callback's self short-circuit. */
76
63
  local?: ActionRev;
64
+ /**
65
+ * Cohort peers (self excluded) that never answered the consult — the callback rejected
66
+ * (dial failure, protocol error) or blew the per-peer deadline. Silence, not evidence:
67
+ * these are never counted as claims, but while this is non-empty a caller must not treat
68
+ * "nothing corroborated" as an authoritative absence, because a silent peer could be the
69
+ * sole holder.
70
+ */
71
+ silent: string[];
77
72
  }
78
73
 
79
74
  /**
@@ -85,8 +80,18 @@ interface LocalClusterWithExecutionTracking extends ICluster {
85
80
  }
86
81
 
87
82
  /**
88
- * Callback to query a cluster peer for their latest revision of a block.
89
- * Returns the peer's latest ActionRev if they have the block, undefined otherwise.
83
+ * Callback to query a cluster peer for their latest revision of a block. Three-way contract:
84
+ * - resolves an `ActionRev` — the peer answered and holds the block at that revision;
85
+ * - resolves `undefined` — the peer answered and holds NOTHING (an absent claim);
86
+ * - REJECTS — the peer could not be asked at all (dial failure, protocol error).
87
+ *
88
+ * The distinction between the last two is load-bearing: `queryClusterForLatest` counts a
89
+ * rejection as a SILENT peer, which stops `CoordinatorRepo.get` from reporting a locally-missing
90
+ * block as an authoritative absent, while a resolved `undefined` is a real answer that keeps the
91
+ * absent authoritative. Implementations must therefore let transport errors propagate rather than
92
+ * swallowing them into `undefined` (see the implementations in `libp2p-node-base` and the mesh
93
+ * harness's `silentPeers` failure knob). Slowness needs no handling here — the caller deadlines
94
+ * each query and treats expiry as silence.
90
95
  */
91
96
  export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId, context?: ActionContext) => Promise<ActionRev | undefined>;
92
97
 
@@ -321,7 +326,8 @@ export class CoordinatorRepo implements IRepo {
321
326
  // NOTE: NetworkTransactor.get treats an authoritative "absent" ({ state: {} })
322
327
  // as final and no longer retries it (ticket txn-perf-authoritative-notfound),
323
328
  // relying on this cluster reconciliation to have already run. When the consult
324
- // runs and FAILS, the entry is flagged `unavailable: 'peers-unreachable'` below,
329
+ // FAILS outright — or runs while part of the cohort stays SILENT and the block
330
+ // stays missing — the entry is flagged `unavailable: 'peers-unreachable'` below,
325
331
  // which re-enables the transactor-level retry against a different peer. If a
326
332
  // coordinator is configured WITHOUT clusterLatestCallback, there is no cohort to
327
333
  // consult and the local answer IS the whole truth — it stays authoritative, with
@@ -346,7 +352,7 @@ export class CoordinatorRepo implements IRepo {
346
352
  }
347
353
 
348
354
  try {
349
- await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
355
+ const { inconclusive } = await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
350
356
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
351
357
  const newRev = refreshed[blockId]?.state?.latest?.rev;
352
358
  if (refreshed[blockId]) {
@@ -359,24 +365,20 @@ export class CoordinatorRepo implements IRepo {
359
365
  log('cluster-tx:read-repair-noop', { blockId });
360
366
  }
361
367
  }
368
+ // The consult ran but came back INCONCLUSIVE (a silent cohort peer, or a
369
+ // corroborated revision this node could not acquire — see
370
+ // fetchBlockFromCluster). Either way the reader cannot rule the block out,
371
+ // so a still-missing block must not pose as an authoritative absent. When
372
+ // the whole cohort answers "holds nothing" the absent stays authoritative —
373
+ // the new-collection probe against a healthy cohort stays one round-trip.
374
+ if (isMissing && inconclusive) {
375
+ this.flagUnconfirmedAbsence(localResult, blockId);
376
+ }
362
377
  } catch (err) {
363
378
  log('cluster-fetch:error', { blockId, error: (err as Error).message });
364
379
  // The consult that was supposed to make this answer trustworthy did not run.
365
- // Only a locally-MISSING block is downgraded to `unavailable`: a merely-stale
366
- // block still has a real local answer and stays authoritative. A sharper flag
367
- // already on the entry (storage's 'unmaterializable') is never overwritten.
368
- // NOTE: a consult that runs but corroborates nothing (per-peer timeouts and
369
- // "peer holds nothing" both surface as absent claims → no quorum) does NOT
370
- // land here and stays an authoritative absent — the common new-collection
371
- // probe against a healthy cohort takes exactly that path, and the callback
372
- // contract cannot distinguish the two without counting responders.
373
380
  if (isMissing) {
374
- const entry = localResult[blockId];
375
- if (!entry) {
376
- localResult[blockId] = { state: {}, unavailable: 'peers-unreachable' };
377
- } else if (entry.unavailable === undefined) {
378
- entry.unavailable = 'peers-unreachable';
379
- }
381
+ this.flagUnconfirmedAbsence(localResult, blockId);
380
382
  }
381
383
  }
382
384
  }
@@ -385,6 +387,22 @@ export class CoordinatorRepo implements IRepo {
385
387
  return localResult;
386
388
  }
387
389
 
390
+ /**
391
+ * Downgrade an absence the coordinator could not confirm to `unavailable: 'peers-unreachable'` —
392
+ * the flag `NetworkTransactor.get` retries against another peer instead of taking as final.
393
+ *
394
+ * No-op once the entry carries a real answer (the consult restored the block) or a sharper flag
395
+ * (storage's `'unmaterializable'`), so callers only need to establish that the answer is a guess.
396
+ */
397
+ private flagUnconfirmedAbsence(results: GetBlockResults, blockId: BlockId): void {
398
+ const entry = results[blockId];
399
+ if (!entry) {
400
+ results[blockId] = { state: {}, unavailable: 'peers-unreachable' };
401
+ } else if (!entry.state?.latest && entry.unavailable === undefined) {
402
+ entry.unavailable = 'peers-unreachable';
403
+ }
404
+ }
405
+
388
406
  /** Decide whether the read-repair policy wants us to consult the cluster for a present-but-possibly-stale block. */
389
407
  private shouldReadRepair(blockId: BlockId): boolean {
390
408
  switch (this.readRepairMode) {
@@ -427,14 +445,22 @@ export class CoordinatorRepo implements IRepo {
427
445
  * One repair pass for a block: ask the cohort what it holds, and converge onto that if it is
428
446
  * ahead of `localRev` — the revision the caller's read already loaded, and the baseline every
429
447
  * decision below is measured against.
448
+ *
449
+ * Returns the one thing `get` needs beyond the storage side effects: whether the pass was
450
+ * INCONCLUSIVE — it neither confirmed the cohort holds nothing nor left this node holding the
451
+ * block. Two ways that happens: a cohort peer other than this node stayed SILENT (rejected
452
+ * callback or per-peer deadline), or a revision WAS corroborated and the convergence onto it
453
+ * failed. In both, `get` has learned that its local absence may be wrong, so it must not report
454
+ * a still-missing block as an authoritative absent. Paths that consult nobody (no cohort,
455
+ * solo-self) are conclusive: there, the local answer genuinely is the whole truth.
430
456
  */
431
- private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext, localRev?: number): Promise<void> {
432
- if (!this.clusterLatestCallback) return;
457
+ private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext, localRev?: number): Promise<{ inconclusive: boolean }> {
458
+ if (!this.clusterLatestCallback) return { inconclusive: false };
433
459
 
434
460
  const blockIdBytes = new TextEncoder().encode(blockId);
435
461
  const peers = await this.keyNetwork.findCluster(blockIdBytes);
436
462
  const peerIds = peers ? Object.keys(peers) : [];
437
- if (peerIds.length === 0) return;
463
+ if (peerIds.length === 0) return { inconclusive: false };
438
464
 
439
465
  // Solo-cluster short-circuit: the only responsible peer is us. There is no
440
466
  // remote to sync from, so skip the callback entirely. Querying ourselves
@@ -446,13 +472,17 @@ export class CoordinatorRepo implements IRepo {
446
472
  && peerIds[0] === this.localPeerId.toString()
447
473
  ) {
448
474
  log('cluster-fetch:solo-self-skip', { blockId });
449
- return;
475
+ return { inconclusive: false };
450
476
  }
451
477
 
452
- const { corroborated, local } = await this.queryClusterForLatest(peerIds, blockId, context);
478
+ const { corroborated, local, silent } = await this.queryClusterForLatest(peerIds, blockId, context);
479
+ // Any silence flags the WHOLE consult, not a fraction of it (fail-closed): one silent
480
+ // peer could be the sole holder, and the cost — an extra transactor-level retry against
481
+ // another coordinator — is paid only while a peer is actually unreachable.
482
+ const cohortSilent = silent.length > 0;
453
483
  // Nothing corroborated: keep local data AND stay eligible for repair — marking the
454
484
  // block seen here would suppress the next attempt for the whole read-repair window.
455
- if (!corroborated) return;
485
+ if (!corroborated) return { inconclusive: cohortSilent };
456
486
 
457
487
  // The self answer is the sharper baseline (same storage, same context, read alongside the
458
488
  // cohort's), but it exists only when `findCluster` returned this node. A soft serve for a
@@ -475,7 +505,7 @@ export class CoordinatorRepo implements IRepo {
475
505
  if (baselineRev !== undefined && corroborated.rev <= baselineRev) {
476
506
  log('cluster-fetch:local-current', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
477
507
  this.markBlocksSeen([blockId]);
478
- return;
508
+ return { inconclusive: cohortSilent };
479
509
  }
480
510
 
481
511
  // Corroborated revision is ahead of ours — converge onto it.
@@ -489,6 +519,10 @@ export class CoordinatorRepo implements IRepo {
489
519
  } else {
490
520
  log('cluster-fetch:not-restored', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
491
521
  }
522
+ // A corroborated revision this node failed to converge onto is inconclusive in its own right,
523
+ // even with the whole cohort answering: the reader has just been TOLD the block exists, so
524
+ // reporting it absent would be a lie of the same kind a silent peer causes (see `get`).
525
+ const inconclusive = cohortSilent || rev === undefined;
492
526
  // The block is marked seen either way — the cohort DID answer, so its freshness was checked,
493
527
  // which is what the read-repair window tracks. A failed convergence therefore waits out the
494
528
  // window before retrying.
@@ -500,6 +534,7 @@ export class CoordinatorRepo implements IRepo {
500
534
  // it ever shows as read amplification, gate the acquisition step (not the latest-query) on the
501
535
  // same window rather than widening `isMissing`.
502
536
  this.markBlocksSeen([blockId]);
537
+ return { inconclusive };
503
538
  }
504
539
 
505
540
  /**
@@ -621,13 +656,23 @@ export class CoordinatorRepo implements IRepo {
621
656
  * `debt-read-repair-commit-cert-verification`.
622
657
  */
623
658
  private async queryClusterForLatest(peerIds: string[], blockId: BlockId, context?: ActionContext): Promise<ClusterLatestQuery> {
624
- // Query peers in parallel for their latest revision (with 1s timeout per peer),
625
- // tagging each response with the peer that made it so votes stay distinct.
659
+ // Query peers in parallel for their latest revision. Each query is DEADLINED (rejects), not
660
+ // raced-to-undefined: a peer that blows the deadline lands in the silent set below exactly
661
+ // like a dial failure, because a slow peer and a peer claiming "I hold nothing" must produce
662
+ // different answers (ticket cluster-read-consult-cannot-report-unreachable).
663
+ // NOTE: LATEST_QUERY_TIMEOUT_MS is a LAN-shaped budget. A cohort whose round trip honestly
664
+ // exceeds it now reads as permanently silent, which is safe (the read is flagged, not
665
+ // mis-reported) but makes every miss cost a transactor-level retry. If a WAN deployment shows
666
+ // steady `cluster-fetch:peers-silent` against healthy peers, raise this rather than softening
667
+ // the deadline back into an absent claim.
626
668
  const latestResults = await Promise.allSettled(
627
669
  peerIds.map(async peerIdStr => {
628
670
  const peerId = peerIdFromString(peerIdStr);
629
- const value = await withTimeout(this.clusterLatestCallback!(peerId, blockId, context), 1000);
630
- return { peerIdStr, value };
671
+ return await withDeadline(
672
+ this.clusterLatestCallback!(peerId, blockId, context),
673
+ LATEST_QUERY_TIMEOUT_MS,
674
+ `latest query to ${peerIdStr}`
675
+ );
631
676
  })
632
677
  );
633
678
 
@@ -639,15 +684,30 @@ export class CoordinatorRepo implements IRepo {
639
684
  const selfId = this.localPeerId?.toString();
640
685
  let local: ActionRev | undefined;
641
686
  const claims: RevClaim[] = [];
642
- for (const result of latestResults) {
643
- if (result.status !== 'fulfilled' || !result.value.value) continue;
644
- const { peerIdStr, value } = result.value;
687
+ const silent: string[] = [];
688
+ // `allSettled` preserves input order, so results correlate to `peerIds` by index — a
689
+ // rejected entry carries no payload of its own, and its peer id is what `silent` records.
690
+ for (let i = 0; i < latestResults.length; i++) {
691
+ const result = latestResults[i]!;
692
+ const peerIdStr = peerIds[i]!;
693
+ if (result.status !== 'fulfilled') {
694
+ // Silence: the callback rejected or the deadline expired. Never a claim. Self is
695
+ // excluded — its short-circuit reads local storage, and a local read error is not a
696
+ // cohort peer being unreachable.
697
+ if (peerIdStr !== selfId) silent.push(peerIdStr);
698
+ continue;
699
+ }
700
+ const value = result.value;
645
701
  if (peerIdStr === selfId) {
646
702
  local = value;
647
703
  continue;
648
704
  }
705
+ if (!value) continue; // responded, holds nothing — an absent claim, not silence
649
706
  claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
650
707
  }
708
+ if (silent.length > 0) {
709
+ log('cluster-fetch:peers-silent', { blockId, silent: silent.length, consulted: peerIds.length });
710
+ }
651
711
 
652
712
  const capacity = corroboratorCapacity(peerIds.filter(id => id !== selfId).length, this.repairCorroborationClusterSize);
653
713
  const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
@@ -657,7 +717,7 @@ export class CoordinatorRepo implements IRepo {
657
717
  responders: claims.length,
658
718
  required: quorumSize(claims.length, this.simpleMajorityThreshold, capacity)
659
719
  });
660
- return { local };
720
+ return { local, silent };
661
721
  }
662
722
 
663
723
  // Best-effort: penalize peers whose claim contradicts the corroborated pair
@@ -665,7 +725,7 @@ export class CoordinatorRepo implements IRepo {
665
725
  // rev). A lower rev is just lag, never penalized. Never let this throw.
666
726
  this.penalizeContradictingRevClaims(claims, selected, blockId);
667
727
 
668
- return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local };
728
+ return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local, silent };
669
729
  }
670
730
 
671
731
  /**
@@ -761,7 +821,8 @@ export class CoordinatorRepo implements IRepo {
761
821
  * genuine validation faults.
762
822
  */
763
823
  private async classifyStaleRejection(error: unknown, request: PendRequest, blockIds: BlockId[]): Promise<StaleFailure | undefined> {
764
- if (!(error instanceof ValidatorRejectionError) || request.rev === undefined) return undefined;
824
+ const requestedRev = request.rev;
825
+ if (!(error instanceof ValidatorRejectionError) || requestedRev === undefined) return undefined;
765
826
  let results: GetBlockResults;
766
827
  try {
767
828
  results = await this.storageRepo.get({ blockIds });
@@ -772,21 +833,36 @@ export class CoordinatorRepo implements IRepo {
772
833
  });
773
834
  return undefined;
774
835
  }
775
- for (const blockId of blockIds) {
836
+ // Scan EVERY block rather than stopping at the first confirmation: several of the request's
837
+ // blocks can be past the requested revision at different revisions, and it is the highest
838
+ // that the loser's next request has to clear (see `highestStaleAt`). Both the reported
839
+ // number and the reason prose name that block, so they never disagree.
840
+ const staleAt = highestStaleAt(blockIds.map(blockId => {
776
841
  const latest = results[blockId]?.state.latest;
777
- if (latest && latest.rev >= request.rev) {
778
- log('coordinator-repo:pend-stale-classified', {
779
- actionId: request.actionId,
780
- blockId,
781
- latestRev: latest.rev,
782
- requestedRev: request.rev
783
- });
784
- return { success: false, conflict: true, reason: `stale revision: block ${blockId} at rev ${latest.rev}, requested rev ${request.rev}` };
785
- }
842
+ return latest && latest.rev >= requestedRev ? { blockId, rev: latest.rev } : undefined;
843
+ }));
844
+ if (staleAt) {
845
+ log('coordinator-repo:pend-stale-classified', {
846
+ actionId: request.actionId,
847
+ blockId: staleAt.blockId,
848
+ latestRev: staleAt.rev,
849
+ requestedRev
850
+ });
851
+ return {
852
+ success: false,
853
+ conflict: true,
854
+ reason: `stale revision: block ${staleAt.blockId} at rev ${staleAt.rev}, requested rev ${requestedRev}`,
855
+ // The same fact as the reason prose, but as data. This is the ONLY place a losing
856
+ // writer can learn the revision it lost to, since this failure deliberately carries
857
+ // no `missing`. Confirmed-local: read out of our own storage just above.
858
+ staleAt
859
+ };
786
860
  }
787
861
  // NOTE: conservative — when only remote members saw the newer revision (local storage still
788
862
  // behind), staleness can't be confirmed locally and the rejection stays a throw. If that
789
863
  // shows up in practice, extend confirmation with a quorum read; never trust the reject text.
864
+ // `staleAt` is absent on this path for the same reason, and deliberately so — there is no
865
+ // confirmed number to report, and the field's contract forbids inferring one from that text.
790
866
  return undefined;
791
867
  }
792
868
 
@@ -3,11 +3,12 @@ import type {
3
3
  ActionId, BlockGets, ActionPending, PendSuccess, ActionTransform, ActionTransforms,
4
4
  GetBlockResult, IBlock, ActionRev, BlockUnavailableReason,
5
5
  PendValidationHook,
6
- CollectionId, IBlockChangeNotifier, CollectionChangeListener, CollectionChangeEvent
6
+ CollectionId, IBlockChangeNotifier, CollectionChangeListener, CollectionChangeEvent,
7
+ StaleFailure
7
8
  } from "@optimystic/db-core";
8
9
  import {
9
10
  Latches, transformForBlockId, applyTransform, groupBy, concatTransform, emptyTransforms,
10
- blockIdsForTransforms, transformsFromTransform
11
+ blockIdsForTransforms, transformsFromTransform, highestStaleAt
11
12
  } from "@optimystic/db-core";
12
13
  import { asyncIteratorToArray } from "../it-utility.js";
13
14
  import type { IBlockStorage } from "./i-block-storage.js";
@@ -374,6 +375,10 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
374
375
  log('pend actionId=%s blockIds=%d rev=%s', request.actionId, blockIds.length, request.rev);
375
376
  const pendings: ActionPending[] = [];
376
377
  const missing: ActionTransforms[] = [];
378
+ // Highest revision this node confirms holding among the blocks that are at or past the
379
+ // requested one — reported as StaleFailure.staleAt so a losing writer learns the number
380
+ // instead of parsing prose. Confirmed-local only: we read it from our own storage below.
381
+ let staleAt: StaleFailure['staleAt'];
377
382
 
378
383
  // Potential race condition: A concurrent commit operation could complete
379
384
  // between the conflict checks (latest.rev, listPendingTransactions) and the
@@ -394,6 +399,13 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
394
399
  if (request.rev !== undefined || transforms.insert) {
395
400
  const latest = await blockStorage.getLatest();
396
401
  if (latest && latest.rev >= (request.rev ?? 0)) {
402
+ // Only a real revision race yields a meaningful `staleAt`. When `request.rev` is
403
+ // undefined this same branch fires for an insert collision (the comparison degrades
404
+ // to `latest.rev >= 0`, true for any existing block), and reporting that block's
405
+ // revision would be a number that answers a question nobody asked.
406
+ if (request.rev !== undefined) {
407
+ staleAt = highestStaleAt([staleAt, { blockId, rev: latest.rev }]);
408
+ }
397
409
  const transforms = await asyncIteratorToArray(blockStorage.listRevisions(request.rev ?? 0, latest.rev));
398
410
  for (const actionRev of transforms) {
399
411
  const transform = await blockStorage.getTransaction(actionRev.actionId);
@@ -415,7 +427,8 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
415
427
  return {
416
428
  success: false,
417
429
  conflict: true,
418
- missing
430
+ missing,
431
+ ...(staleAt === undefined ? {} : { staleAt })
419
432
  };
420
433
  }
421
434
 
@@ -502,6 +515,10 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
502
515
  // - toCommit: latest.rev < request.rev or no latest yet → run internalCommit.
503
516
  const toCommit: { blockId: BlockId, storage: IBlockStorage }[] = [];
504
517
  const missedCommits: { blockId: BlockId, transforms: ActionTransform[] }[] = [];
518
+ // Highest revision among the blocks confirmed lost to a newer one — reported as
519
+ // StaleFailure.staleAt. The idempotent-retry `continue` below is a no-op, not a loss,
520
+ // so it never seeds this.
521
+ let staleAt: StaleFailure['staleAt'];
505
522
  for (const entry of blockStorages) {
506
523
  const { blockId, storage } = entry;
507
524
  const latest = await storage.getLatest();
@@ -510,6 +527,7 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
510
527
  // Idempotent no-op for this block — already committed with this exact (actionId, rev).
511
528
  continue;
512
529
  }
530
+ staleAt = highestStaleAt([staleAt, { blockId, rev: latest.rev }]);
513
531
  const transforms: ActionTransform[] = [];
514
532
  for await (const actionRev of storage.listRevisions(request.rev, latest.rev)) {
515
533
  const transform = await storage.getTransaction(actionRev.actionId);
@@ -532,7 +550,8 @@ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaSt
532
550
  log('commit:stale actionId=%s missed=%d', request.actionId, missedCommits.length);
533
551
  return { // Return directly, locks will be released in finally
534
552
  success: false,
535
- missing: perBlockActionTransformsToPerAction(missedCommits)
553
+ missing: perBlockActionTransformsToPerAction(missedCommits),
554
+ ...(staleAt === undefined ? {} : { staleAt })
536
555
  };
537
556
  }
538
557
 
@@ -42,6 +42,13 @@ export interface MeshFailureConfig {
42
42
  failingPeers?: Set<string>;
43
43
  /** Make findCluster return empty (simulate DHT failure) */
44
44
  findClusterFails?: boolean;
45
+ /**
46
+ * Peers that are unreachable on the READ path: their latest-revision consult
47
+ * (`ClusterLatestCallback`) REJECTS — silence the coordinator must count as "did not
48
+ * answer", never as the peer claiming absence — and the reconcile/acquire transfer skips
49
+ * them as a source. Distinct from `failingPeers`, which fails cluster (write) updates.
50
+ */
51
+ silentPeers?: Set<string>;
45
52
  }
46
53
 
47
54
  class MockPeerNetwork implements IPeerNetwork {
@@ -118,10 +125,13 @@ export interface Mesh {
118
125
  *
119
126
  * `nodes` is captured by reference and is fully populated by the time either caller invokes it.
120
127
  */
121
- const makeReconcileBlock = (nodes: MeshNode[], selfPeerId: string, storageRepo: StorageRepo): ReconcileBlockCallback =>
128
+ const makeReconcileBlock = (nodes: MeshNode[], selfPeerId: string, storageRepo: StorageRepo, failures: MeshFailureConfig): ReconcileBlockCallback =>
122
129
  async (blockId, committed, cohortPeerIds) => {
123
130
  for (const peerIdStr of cohortPeerIds) {
124
131
  if (peerIdStr === selfPeerId) continue;
132
+ // A silent peer cannot serve bytes either — without this, a test that silenced a
133
+ // peer's consult could still accidentally converge THROUGH that peer.
134
+ if (failures.silentPeers?.has(peerIdStr)) continue;
125
135
  const target = nodes.find(n => n.peerId.toString() === peerIdStr);
126
136
  if (!target) continue;
127
137
  const result = await target.storageRepo.get({ blockIds: [blockId] }, { skipClusterFetch: true } as any);
@@ -177,7 +187,7 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
177
187
 
178
188
  // Active reconciliation: when a member commits a block it never pended (cohort drift),
179
189
  // pull the committed revision from a sibling cohort node that holds it.
180
- const reconcileBlock = makeReconcileBlock(nodes, peerId.toString(), storageRepo);
190
+ const reconcileBlock = makeReconcileBlock(nodes, peerId.toString(), storageRepo, failures);
181
191
 
182
192
  const member = clusterMember({
183
193
  storageRepo,
@@ -223,6 +233,12 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
223
233
  // existed to expose. Transfer now happens where it does in production: through
224
234
  // `acquireBlockFromCohort` below, gated on a corroborated revision.
225
235
  const clusterLatestCallback: ClusterLatestCallback = async (peerId: PeerId, blockId: BlockId, context?): Promise<ActionRev | undefined> => {
236
+ // Silence: the peer never answers. REJECTS, mirroring what a dial failure does to the
237
+ // production callback — the coordinator must count this as "did not answer", never as
238
+ // an absent claim (a resolved `undefined` remains the peer answering "I hold nothing").
239
+ if (failures.silentPeers?.has(peerId.toString())) {
240
+ throw new Error(`Peer ${peerId.toString()} is silent`);
241
+ }
226
242
  const target = nodes.find(n => n.peerId.equals(peerId));
227
243
  if (!target) return undefined;
228
244
  const result = await target.storageRepo.get(
@@ -265,7 +281,7 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
265
281
  clusterLatestCallback,
266
282
  // The read path's transfer mechanism — the same callback the member uses on the commit path,
267
283
  // mirroring how `libp2p-node-base` shares one `reconcileBlock` between both.
268
- acquireBlockFromCohort: makeReconcileBlock(nodes, node.peerId.toString(), node.storageRepo)
284
+ acquireBlockFromCohort: makeReconcileBlock(nodes, node.peerId.toString(), node.storageRepo, failures)
269
285
  });
270
286
  }
271
287