@optimystic/db-p2p 0.16.3 → 0.18.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 (90) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +14 -1
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +12 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/cluster-policy.d.ts +112 -0
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -0
  7. package/dist/src/cluster/cluster-policy.js +88 -0
  8. package/dist/src/cluster/cluster-policy.js.map +1 -0
  9. package/dist/src/cluster/cluster-repo.d.ts +41 -13
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +116 -23
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +64 -14
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +0 -0
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/reconcile-block.d.ts +60 -0
  18. package/dist/src/cluster/reconcile-block.d.ts.map +1 -0
  19. package/dist/src/cluster/reconcile-block.js +133 -0
  20. package/dist/src/cluster/reconcile-block.js.map +1 -0
  21. package/dist/src/cluster/service.d.ts +4 -1
  22. package/dist/src/cluster/service.d.ts.map +1 -1
  23. package/dist/src/cluster/service.js +9 -1
  24. package/dist/src/cluster/service.js.map +1 -1
  25. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  26. package/dist/src/cluster/spread-on-churn.js +8 -0
  27. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  28. package/dist/src/inbound-authorization.d.ts +117 -0
  29. package/dist/src/inbound-authorization.d.ts.map +1 -0
  30. package/dist/src/inbound-authorization.js +149 -0
  31. package/dist/src/inbound-authorization.js.map +1 -0
  32. package/dist/src/index.d.ts +1 -0
  33. package/dist/src/index.d.ts.map +1 -1
  34. package/dist/src/index.js +1 -0
  35. package/dist/src/index.js.map +1 -1
  36. package/dist/src/libp2p-key-network.d.ts +14 -0
  37. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  38. package/dist/src/libp2p-key-network.js +54 -4
  39. package/dist/src/libp2p-key-network.js.map +1 -1
  40. package/dist/src/libp2p-node-base.d.ts +48 -7
  41. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  42. package/dist/src/libp2p-node-base.js +61 -83
  43. package/dist/src/libp2p-node-base.js.map +1 -1
  44. package/dist/src/repo/cluster-coordinator.d.ts +21 -3
  45. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  46. package/dist/src/repo/cluster-coordinator.js +27 -5
  47. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  48. package/dist/src/repo/coordinator-repo.d.ts +123 -16
  49. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  50. package/dist/src/repo/coordinator-repo.js +354 -49
  51. package/dist/src/repo/coordinator-repo.js.map +1 -1
  52. package/dist/src/repo/service.d.ts +4 -1
  53. package/dist/src/repo/service.d.ts.map +1 -1
  54. package/dist/src/repo/service.js +9 -1
  55. package/dist/src/repo/service.js.map +1 -1
  56. package/dist/src/storage/block-storage.d.ts.map +1 -1
  57. package/dist/src/storage/block-storage.js +11 -0
  58. package/dist/src/storage/block-storage.js.map +1 -1
  59. package/dist/src/storage/storage-repo.d.ts +56 -0
  60. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  61. package/dist/src/storage/storage-repo.js +155 -13
  62. package/dist/src/storage/storage-repo.js.map +1 -1
  63. package/dist/src/sync/service.d.ts +4 -7
  64. package/dist/src/sync/service.d.ts.map +1 -1
  65. package/dist/src/sync/service.js +13 -10
  66. package/dist/src/sync/service.js.map +1 -1
  67. package/dist/src/testing/mesh-harness.d.ts +10 -0
  68. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  69. package/dist/src/testing/mesh-harness.js +55 -49
  70. package/dist/src/testing/mesh-harness.js.map +1 -1
  71. package/package.json +2 -2
  72. package/{README.md → readme.md} +37 -0
  73. package/src/cluster/block-transfer-service.ts +20 -5
  74. package/src/cluster/cluster-policy.ts +152 -0
  75. package/src/cluster/cluster-repo.ts +121 -26
  76. package/src/cluster/quorum-restore.ts +0 -0
  77. package/src/cluster/reconcile-block.ts +191 -0
  78. package/src/cluster/service.ts +10 -3
  79. package/src/cluster/spread-on-churn.ts +8 -0
  80. package/src/inbound-authorization.ts +190 -0
  81. package/src/index.ts +1 -0
  82. package/src/libp2p-key-network.ts +54 -4
  83. package/src/libp2p-node-base.ts +111 -94
  84. package/src/repo/cluster-coordinator.ts +30 -6
  85. package/src/repo/coordinator-repo.ts +419 -58
  86. package/src/repo/service.ts +10 -3
  87. package/src/storage/block-storage.ts +11 -0
  88. package/src/storage/storage-repo.ts +172 -15
  89. package/src/sync/service.ts +14 -12
  90. package/src/testing/mesh-harness.ts +55 -49
@@ -1,7 +1,6 @@
1
- import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext, ClusterRecord } from "@optimystic/db-core";
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
2
  import { LruMap, blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
3
- import { ClusterCoordinator } from "./cluster-coordinator.js";
4
- import type { ClusterClient } from "../cluster/client.js";
3
+ import { ClusterCoordinator, ValidatorRejectionError } from "./cluster-coordinator.js";
5
4
  import type { PeerId } from "@libp2p/interface";
6
5
  import { peerIdFromString } from "@libp2p/peer-id";
7
6
  import type { FretService } from "p2p-fret";
@@ -9,10 +8,74 @@ import { createLogger } from '../logger.js';
9
8
  import type { IPeerReputation } from "../reputation/types.js";
10
9
  import { PenaltyReason } from "../reputation/types.js";
11
10
  import type { ITransactionStateStore } from "../cluster/i-transaction-state-store.js";
12
- import { selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
11
+ import { quorumSize, corroboratorCapacity, selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
12
+ import { RECONCILE_TIMEOUT_MS } from "../cluster/reconcile-block.js";
13
+ import { isMissingBaseRevisionFailure, MISSING_BASE_REVISION_REASON } from "../storage/storage-repo.js";
14
+ import type { ReconcileBlockCallback } from "../cluster/cluster-repo.js";
13
15
 
14
16
  const log = createLogger('coordinator-repo');
15
17
 
18
+ /**
19
+ * Acquire a block's content for a cohort-corroborated revision, from the cohort, and persist it.
20
+ *
21
+ * Deliberately the SAME shape as the commit path's {@link ReconcileBlockCallback}, and in the live
22
+ * node the very same instance (`libp2p-node-base` passes its `reconcileBlock` to both): read-driven
23
+ * acquisition needs exactly what reconcile already provides — a per-peer-bounded archive fetch, a
24
+ * quorum vote on the target `(rev, actionId)`, a quorum vote on the *content* at that revision, and a
25
+ * persist through the monotonic, commit-latched `StorageRepo.saveReplicatedBlock` funnel. Reusing it
26
+ * is what keeps read-repair from being a weaker trust path than reconcile.
27
+ */
28
+ export type AcquireBlockCallback = ReconcileBlockCallback;
29
+
30
+ /** True when a freshly-read local revision is strictly ahead of the baseline the repair started from. */
31
+ function isAdvanceOver(rev: number | undefined, baselineRev: number | undefined): boolean {
32
+ return typeof rev === 'number' && (baselineRev === undefined || rev > baselineRev);
33
+ }
34
+
35
+ /**
36
+ * Reject if `promise` has not settled within `ms`. The timer is cleared on either outcome, so no
37
+ * handle outlives the race (hence no `unref`, which does not exist off Node).
38
+ */
39
+ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ const deadline = new Promise<never>((_, reject) => {
42
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
43
+ });
44
+ return Promise.race([promise, deadline]).finally(() => {
45
+ if (timer !== undefined) clearTimeout(timer);
46
+ });
47
+ }
48
+
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
+ /**
66
+ * What one round of polling the cohort learned about a block: the revision the OTHER
67
+ * cohort members corroborated, and what this node itself already holds. The two are kept
68
+ * apart on purpose — the local revision is the baseline being repaired, never evidence
69
+ * about the cluster (see {@link CoordinatorRepo.queryClusterForLatest}) — but the caller
70
+ * still needs it to tell whether the corroborated revision is actually an advance.
71
+ */
72
+ interface ClusterLatestQuery {
73
+ /** Highest `(rev, actionId)` corroborated by peers other than this node, if any. */
74
+ corroborated?: ActionRev;
75
+ /** This node's own latest for the block, as answered by the callback's self short-circuit. */
76
+ local?: ActionRev;
77
+ }
78
+
16
79
  /**
17
80
  * Extended cluster interface that includes the ability to check if a transaction was executed.
18
81
  * This is used by CoordinatorRepo to avoid duplicate execution.
@@ -36,12 +99,31 @@ interface CoordinatorRepoComponents {
36
99
  * Used for read-path cluster verification to discover unknown revisions.
37
100
  */
38
101
  clusterLatestCallback?: ClusterLatestCallback;
102
+ /**
103
+ * Optional callback that actually moves a block's bytes from the cohort into local storage once
104
+ * {@link clusterLatestCallback} has established a corroborated revision this node lacks. Absent →
105
+ * the read path can still *select* the right revision but converges only when the node already
106
+ * holds the corroborated action as a promotable pending. See {@link AcquireBlockCallback}.
107
+ */
108
+ acquireBlockFromCohort?: AcquireBlockCallback;
39
109
  }
40
110
 
111
+ /**
112
+ * Consensus config for the coordinator side, plus the repair yardstick the read-repair path measures
113
+ * a (possibly shrunken) cohort view against. `repairCorroborationClusterSize` is deliberately its own
114
+ * field rather than an overload of {@link ClusterConsensusConfig.assumedClusterSize}: this same object
115
+ * also builds the `ClusterCoordinator`, and a field whose value silently differed from the cluster
116
+ * member's copy of it would be a trap. See `cluster/cluster-policy.ts` for why the two differ.
117
+ */
118
+ export type CoordinatorRepoConfig = Partial<ClusterConsensusConfig> & {
119
+ clusterSize?: number;
120
+ repairCorroborationClusterSize?: number;
121
+ };
122
+
41
123
  export function coordinatorRepo(
42
124
  keyNetwork: IKeyNetwork,
43
- createClusterClient: (peerId: PeerId) => ClusterClient,
44
- cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
125
+ createClusterClient: (peerId: PeerId) => ICluster,
126
+ cfg?: CoordinatorRepoConfig,
45
127
  fretService?: FretService,
46
128
  reputation?: IPeerReputation,
47
129
  stateStore?: ITransactionStateStore
@@ -56,7 +138,8 @@ export function coordinatorRepo(
56
138
  fretService,
57
139
  components.clusterLatestCallback,
58
140
  reputation,
59
- stateStore
141
+ stateStore,
142
+ components.acquireBlockFromCohort
60
143
  );
61
144
  }
62
145
 
@@ -73,6 +156,13 @@ export class CoordinatorRepo implements IRepo {
73
156
  private readonly readRepairSampleRate: number;
74
157
  /** Simple-majority threshold from the consensus policy; drives the read-repair corroboration quorum. */
75
158
  private readonly simpleMajorityThreshold: number;
159
+ /**
160
+ * Yardstick the read-repair corroboration floor is measured against; the floor for
161
+ * {@link corroboratorCapacity}. Resolved by `resolveClusterPolicy` for a real node; falls back to
162
+ * `assumedClusterSize` and then `clusterSize` for direct constructors (see the constructor), so a
163
+ * caller that has adopted neither field keeps today's behavior exactly.
164
+ */
165
+ private readonly repairCorroborationClusterSize: number;
76
166
  /** Resolved super-majority threshold the coordinator commits on (mirrors the value handed to ClusterCoordinator). */
77
167
  private readonly superMajorityThreshold: number;
78
168
  private readonly reputation?: IPeerReputation;
@@ -83,19 +173,21 @@ export class CoordinatorRepo implements IRepo {
83
173
 
84
174
  constructor(
85
175
  readonly keyNetwork: IKeyNetwork,
86
- readonly createClusterClient: (peerId: PeerId) => ClusterClient,
176
+ readonly createClusterClient: (peerId: PeerId) => ICluster,
87
177
  private readonly storageRepo: IRepo,
88
- cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
178
+ cfg?: CoordinatorRepoConfig,
89
179
  localCluster?: LocalClusterWithExecutionTracking,
90
180
  localPeerId?: PeerId,
91
181
  fretService?: FretService,
92
182
  private readonly clusterLatestCallback?: ClusterLatestCallback,
93
183
  reputation?: IPeerReputation,
94
- stateStore?: ITransactionStateStore
184
+ stateStore?: ITransactionStateStore,
185
+ private readonly acquireBlockFromCohort?: AcquireBlockCallback
95
186
  ) {
96
187
  this.localPeerId = localPeerId;
97
188
  const policy: ClusterConsensusConfig & { clusterSize: number } = {
98
189
  clusterSize: cfg?.clusterSize ?? 10,
190
+ assumedClusterSize: cfg?.assumedClusterSize,
99
191
  superMajorityThreshold: cfg?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
100
192
  simpleMajorityThreshold: cfg?.simpleMajorityThreshold ?? 0.51,
101
193
  minAbsoluteClusterSize: cfg?.minAbsoluteClusterSize ?? 3,
@@ -120,6 +212,15 @@ export class CoordinatorRepo implements IRepo {
120
212
  this.readRepairSampleRate = policy.readRepairSampleRate!;
121
213
  this.simpleMajorityThreshold = policy.simpleMajorityThreshold;
122
214
  this.superMajorityThreshold = policy.superMajorityThreshold;
215
+ // Unlike the membership admission gate (which treats an absent assumedClusterSize as "unknown"
216
+ // and admits — refusing writes outright is unacceptable), this falls back to the replication
217
+ // factor and stays strict: the failure mode of getting this wrong is a block that goes
218
+ // unrepaired, degraded rather than dead, so there is no reason to relax it for a caller that
219
+ // has not adopted the new field. A real node is handed an explicit
220
+ // `repairCorroborationClusterSize` by `resolveClusterPolicy`; the `assumedClusterSize` middle
221
+ // term keeps direct constructors (embedders, existing tests) behaving as before.
222
+ this.repairCorroborationClusterSize =
223
+ cfg?.repairCorroborationClusterSize ?? policy.assumedClusterSize ?? policy.clusterSize;
123
224
  this.reputation = reputation;
124
225
  const localClusterRef = localCluster && localPeerId ? {
125
226
  update: localCluster.update.bind(localCluster),
@@ -192,6 +293,13 @@ export class CoordinatorRepo implements IRepo {
192
293
 
193
294
  async get(blockGets: BlockGets, options?: MessageOptions): Promise<GetBlockResults> {
194
295
  // Soft proximity check — warn but still serve reads for graceful degradation
296
+ // NOTE: a soft-served read now also *acquires* the block durably (see restoreCorroborated), where
297
+ // before it could at most promote a pending this node already held. So a soft serve leaves behind
298
+ // a replica of a block this node is not responsible for, and nothing sweeps those: ring-shift
299
+ // sheds a keyspace RANGE, not "blocks outside my cohort". Fine while soft serves are what they
300
+ // are meant to be — a rare degradation during routing churn — since routing already placed this
301
+ // node near the block. If they ever become routine, gate acquisition (not the serve itself) on
302
+ // isResponsibleForBlock.
195
303
  for (const blockId of blockGets.blockIds) {
196
304
  if (!await this.isResponsibleForBlock(blockId)) {
197
305
  log('proximity:get-warning', { blockId, msg: 'serving read for non-responsible block' });
@@ -205,14 +313,21 @@ export class CoordinatorRepo implements IRepo {
205
313
  // (a) Missing — block isn't present locally at all (legacy behavior).
206
314
  // (b) Stale-by-policy — block is present but read-repair policy says verify.
207
315
  // Skip cluster fetch if this is already a sync request (to prevent recursive queries).
316
+ // A sync read is also never marked `unavailable` here — the consult it skips is the
317
+ // one whose failure the flag reports, and flagging would feed the recursion this
318
+ // bypass exists to prevent. (Storage-level 'unmaterializable' flags still pass
319
+ // through untouched; they report local state, not the consult.)
208
320
  const skipClusterFetch = (options as any)?.skipClusterFetch;
209
321
  // NOTE: NetworkTransactor.get treats an authoritative "absent" ({ state: {} })
210
322
  // as final and no longer retries it (ticket txn-perf-authoritative-notfound),
211
- // relying on this cluster reconciliation to have already run. If a coordinator
212
- // is ever configured WITHOUT clusterLatestCallback, a missing block is answered
213
- // from local state alone with no transactor-level retry to compensate. That is
214
- // fine today (such a coordinator has no cluster to reconcile against), but keep
215
- // this coupling in mind if a partial-cluster read path is added.
323
+ // relying on this cluster reconciliation to have already run. When the consult
324
+ // runs and FAILS, the entry is flagged `unavailable: 'peers-unreachable'` below,
325
+ // which re-enables the transactor-level retry against a different peer. If a
326
+ // coordinator is configured WITHOUT clusterLatestCallback, there is no cohort to
327
+ // consult and the local answer IS the whole truth it stays authoritative, with
328
+ // no flag and no transactor-level retry to compensate. That is fine (such a
329
+ // coordinator has no cluster to reconcile against), but keep this coupling in
330
+ // mind if a partial-cluster read path is added.
216
331
  if (this.clusterLatestCallback && !skipClusterFetch) {
217
332
  for (const blockId of blockGets.blockIds) {
218
333
  const localEntry = localResult[blockId];
@@ -231,7 +346,7 @@ export class CoordinatorRepo implements IRepo {
231
346
  }
232
347
 
233
348
  try {
234
- await this.fetchBlockFromCluster(blockId, blockGets.context);
349
+ await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
235
350
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
236
351
  const newRev = refreshed[blockId]?.state?.latest?.rev;
237
352
  if (refreshed[blockId]) {
@@ -246,6 +361,23 @@ export class CoordinatorRepo implements IRepo {
246
361
  }
247
362
  } catch (err) {
248
363
  log('cluster-fetch:error', { blockId, error: (err as Error).message });
364
+ // 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
+ 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
+ }
380
+ }
249
381
  }
250
382
  }
251
383
  }
@@ -291,7 +423,12 @@ export class CoordinatorRepo implements IRepo {
291
423
  this.lastSeenCommitMs.set(blockId, ts);
292
424
  }
293
425
 
294
- private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext): Promise<void> {
426
+ /**
427
+ * One repair pass for a block: ask the cohort what it holds, and converge onto that if it is
428
+ * ahead of `localRev` — the revision the caller's read already loaded, and the baseline every
429
+ * decision below is measured against.
430
+ */
431
+ private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext, localRev?: number): Promise<void> {
295
432
  if (!this.clusterLatestCallback) return;
296
433
 
297
434
  const blockIdBytes = new TextEncoder().encode(blockId);
@@ -312,40 +449,178 @@ export class CoordinatorRepo implements IRepo {
312
449
  return;
313
450
  }
314
451
 
315
- const clusterLatest = await this.queryClusterForLatest(peerIds, blockId, context);
316
- if (clusterLatest) {
317
- // Found on cluster - trigger restoration to sync the block
318
- await this.storageRepo.get({ blockIds: [blockId], context: { committed: [clusterLatest], rev: clusterLatest.rev } });
319
- log('cluster-fetch:synced', { blockId, rev: clusterLatest.rev });
452
+ const { corroborated, local } = await this.queryClusterForLatest(peerIds, blockId, context);
453
+ // Nothing corroborated: keep local data AND stay eligible for repair — marking the
454
+ // block seen here would suppress the next attempt for the whole read-repair window.
455
+ if (!corroborated) return;
456
+
457
+ // The self answer is the sharper baseline (same storage, same context, read alongside the
458
+ // cohort's), but it exists only when `findCluster` returned this node. A soft serve for a
459
+ // block this node is no longer responsible for is absent from its own cohort view, so fall
460
+ // back to the revision the caller's read already loaded. Without the fallback both decisions
461
+ // below degrade to "any local revision is an advance", which restores backwards and reports
462
+ // a sync at the revision the pass started from.
463
+ const baselineRev = local?.rev ?? localRev;
464
+
465
+ // Never restore backwards. With this node's own claim excluded from the quorum, a
466
+ // cohort that lags behind the reader corroborates an OLDER revision; adopting it
467
+ // would be a regression, and logging it as a sync would be a lie. The cohort did
468
+ // answer, so the block is verified fresh — mark it seen.
469
+ // NOTE: in a cohort of two, that sole peer is the only corroborator, so a lying one can park
470
+ // the reader here — corroborating the revision it already holds — and re-arm the lazy window
471
+ // on every pass, hiding a real divergence. Bounded by `readRepairWindowMs` (10s default) and
472
+ // no worse than the peer simply staying silent. If two-member cohorts become a supported
473
+ // production topology rather than a dev convenience, stop re-arming the window on a
474
+ // corroboration that came from a single voter.
475
+ if (baselineRev !== undefined && corroborated.rev <= baselineRev) {
476
+ log('cluster-fetch:local-current', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
320
477
  this.markBlocksSeen([blockId]);
478
+ return;
321
479
  }
480
+
481
+ // Corroborated revision is ahead of ours — converge onto it.
482
+ const rev = await this.restoreCorroborated(blockId, corroborated, baselineRev, peerIds);
483
+
484
+ // Log the OUTCOME, not the attempt. Logging `synced` unconditionally reported hundreds of
485
+ // phantom convergences per run and made a real replication defect invisible for two debugging
486
+ // sessions.
487
+ if (rev !== undefined) {
488
+ log('cluster-fetch:synced', { blockId, rev });
489
+ } else {
490
+ log('cluster-fetch:not-restored', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
491
+ }
492
+ // The block is marked seen either way — the cohort DID answer, so its freshness was checked,
493
+ // which is what the read-repair window tracks. A failed convergence therefore waits out the
494
+ // window before retrying.
495
+ // NOTE: that damping covers only a block this node holds at an OLDER revision. A block entirely
496
+ // missing locally never consults the window (`get` triggers on `isMissing` before
497
+ // `shouldReadRepair`), so a persistently failing acquisition — e.g. a two-node deployment that
498
+ // never set `assumedClusterSize`, where the content quorum can never be met — re-fetches an
499
+ // archive on every read of that block. Correct, and self-limiting once the cohort can agree; if
500
+ // it ever shows as read amplification, gate the acquisition step (not the latest-query) on the
501
+ // same window rather than widening `isMissing`.
502
+ this.markBlocksSeen([blockId]);
322
503
  }
323
504
 
324
505
  /**
325
- * Query cluster peers for their latest revision and return the highest
326
- * revision corroborated by a quorum of distinct peers.
506
+ * Bring this node up to the cohort-corroborated `corroborated`, returning the revision it holds
507
+ * afterwards when that is an advance over `baselineRev`, else `undefined`.
508
+ *
509
+ * Two mechanisms, cheapest first:
510
+ * 1. **Promote a local pending** — free, no network, and the only mechanism that existed before
511
+ * block acquisition. Covers the node that saw the pend and missed the commit broadcast.
512
+ * 2. **Acquire the bytes from the cohort** ({@link AcquireBlockCallback}) — covers everything else,
513
+ * including a block this node has never seen at all.
514
+ *
515
+ * **Why acquisition is gated here and not on a plain local miss.** `BlockStorage.getBlock` returns
516
+ * `undefined` for a block with no local metadata *without* consulting its restore callback, so that
517
+ * an insert probing a fresh random block id for a collision does not cost a network fetch. That
518
+ * remains true: this method runs only after {@link queryClusterForLatest} produced a quorum-
519
+ * corroborated `(rev, actionId)`, which a genuinely non-existent block can never produce (no peer
520
+ * claims it, so `selectQuorumRev` declines and `fetchBlockFromCluster` returns before reaching
521
+ * here). The cost of a genuine absence is unchanged — the latest-query round trip that already
522
+ * happened — while a block the cohort demonstrably holds is no longer thrown away.
523
+ *
524
+ * Cohort peer ids are passed straight through: the callback filters self out and caps its own
525
+ * corroboration quorum by how many peers could answer at all.
526
+ */
527
+ private async restoreCorroborated(
528
+ blockId: BlockId,
529
+ corroborated: ActionRev,
530
+ baselineRev: number | undefined,
531
+ cohortPeerIds: string[]
532
+ ): Promise<number | undefined> {
533
+ const promoted = await this.promoteCorroborated(blockId, corroborated);
534
+ if (isAdvanceOver(promoted, baselineRev)) {
535
+ return promoted;
536
+ }
537
+
538
+ if (!this.acquireBlockFromCohort) {
539
+ return undefined;
540
+ }
541
+ try {
542
+ // Bounded: a stalled cohort peer must not hold up the caller's read. Persisting happens
543
+ // inside the callback via `saveReplicatedBlock`, which takes the per-block commit latch —
544
+ // safe to call from here because the read path holds no latch of its own (`StorageRepo.get`
545
+ // acquires and releases it around the promotion above, and nothing wraps this method).
546
+ // NOTE: `get` walks its block ids sequentially, so the bound is per block, not per call — a
547
+ // multi-block read that is missing N blocks against a wholly stalled cohort waits N × this.
548
+ // Acceptable today (the underlying per-peer archive fetch is itself 1s-bounded and runs the
549
+ // cohort in parallel, so the 5s is a stall ceiling, not a typical cost). If a cold reader
550
+ // batching a wide read ever times out above this layer, repair the block ids concurrently
551
+ // rather than shortening the bound.
552
+ await withDeadline(
553
+ this.acquireBlockFromCohort(blockId, corroborated, cohortPeerIds),
554
+ RECONCILE_TIMEOUT_MS,
555
+ `block acquisition for ${blockId}`
556
+ );
557
+ } catch (err) {
558
+ // Declines are cheap and retryable — nothing was persisted. Report and leave the block behind.
559
+ log('cluster-fetch:acquire-error', { blockId, rev: corroborated.rev, error: (err as Error).message });
560
+ return undefined;
561
+ }
562
+ const acquired = await this.readLocalRev(blockId);
563
+ return isAdvanceOver(acquired, baselineRev) ? acquired : undefined;
564
+ }
565
+
566
+ /**
567
+ * Promote a corroborated action this node already holds as a local pending — the no-network half of
568
+ * the repair. Returns the local revision afterwards.
569
+ *
570
+ * A pending-only block (metadata seeded by `savePendingTransaction`, no committed revision) asked
571
+ * for a forward revision no promotion can reach used to throw out of `BlockStorage.ensureRevision`;
572
+ * `StorageRepo.get` now reports it as an entry flagged `unavailable` instead (ticket
573
+ * repo-reports-unavailable-vs-absent). On THIS path either shape is an absence, not a read failure —
574
+ * acquisition is precisely the mechanism that can supply the revision — so both are logged as
575
+ * `promote-unavailable` and stepped over rather than short-circuiting the caller.
576
+ */
577
+ private async promoteCorroborated(blockId: BlockId, corroborated: ActionRev): Promise<number | undefined> {
578
+ try {
579
+ const entry = await this.readLocalEntry(blockId, { committed: [corroborated], rev: corroborated.rev });
580
+ if (entry?.unavailable !== undefined) {
581
+ log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: entry.unavailable });
582
+ return undefined;
583
+ }
584
+ return entry?.state?.latest?.rev;
585
+ } catch (err) {
586
+ log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: (err as Error).message });
587
+ return undefined;
588
+ }
589
+ }
590
+
591
+ /** This node's own answer for a block, optionally driving a promotion context through the read.
592
+ * Callers that care whether the answer is authoritative inspect `entry.unavailable`. */
593
+ private async readLocalEntry(blockId: BlockId, context?: ActionContext) {
594
+ const result = await this.storageRepo.get({ blockIds: [blockId], context });
595
+ return result[blockId];
596
+ }
597
+
598
+ /** This node's own `latest.rev` for a block, optionally driving a promotion context through the read. */
599
+ private async readLocalRev(blockId: BlockId, context?: ActionContext): Promise<number | undefined> {
600
+ return (await this.readLocalEntry(blockId, context))?.state?.latest?.rev;
601
+ }
602
+
603
+ /**
604
+ * Query cluster peers for their latest revision and return the highest revision
605
+ * corroborated by a quorum of distinct peers, alongside this node's own latest.
327
606
  *
328
607
  * Replaces the old "max rev any single peer reports" — which let one lying
329
608
  * peer over-reporting its revision steer restoration — with quorum
330
- * corroboration on the exact `(rev, actionId)` pair (see
331
- * {@link selectQuorumRev}). The local node's own latest is included as a
332
- * corroborating vote because `clusterLatestCallback` self-short-circuits to
333
- * local storage. Returns `undefined` (keep local, do not restore) when no
334
- * revision is corroborated.
609
+ * corroboration on the exact `(rev, actionId)` pair (see {@link selectQuorumRev}).
610
+ *
611
+ * This node's own answer is split out of the claim set rather than counted in it:
612
+ * `clusterLatestCallback` short-circuits self to local storage, so including it let a
613
+ * reader whose only peer timed out "corroborate" the very revision it was trying to
614
+ * repair. It is returned separately so the caller can compare, not vote.
335
615
  *
336
616
  * NOTE: the quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
337
- * membership — a peer minting fresh keypairs still casts a vote. Commit-cert
338
- * + membership anchoring is deferred to backlog
617
+ * membership — a peer minting fresh keypairs still casts a vote, and the claims
618
+ * themselves are bare assertions (a `BlockArchive` carries no commit certificate, so
619
+ * there is nothing here to verify a `(rev, actionId)` against). Commit-cert +
620
+ * membership anchoring is deferred to backlog
339
621
  * `debt-read-repair-commit-cert-verification`.
340
622
  */
341
- private async queryClusterForLatest(peerIds: string[], blockId: BlockId, context?: ActionContext): Promise<ActionRev | undefined> {
342
- // Add timeout wrapper to prevent hanging on unresponsive peers
343
- const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> =>
344
- Promise.race([
345
- promise,
346
- new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), timeoutMs))
347
- ]);
348
-
623
+ private async queryClusterForLatest(peerIds: string[], blockId: BlockId, context?: ActionContext): Promise<ClusterLatestQuery> {
349
624
  // Query peers in parallel for their latest revision (with 1s timeout per peer),
350
625
  // tagging each response with the peer that made it so votes stay distinct.
351
626
  const latestResults = await Promise.allSettled(
@@ -356,18 +631,33 @@ export class CoordinatorRepo implements IRepo {
356
631
  })
357
632
  );
358
633
 
634
+ // NOTE: self-exclusion is keyed on `localPeerId`, which is optional for the single-node/test
635
+ // construction this class has always tolerated. Left unset, this node's own answer is counted
636
+ // as a peer claim again. Harmless today — the self answer can only ever corroborate the
637
+ // revision already held, so the pass declines as `local-current` — but if a future caller can
638
+ // make self report something the reader does not hold, make `localPeerId` required instead.
639
+ const selfId = this.localPeerId?.toString();
640
+ let local: ActionRev | undefined;
359
641
  const claims: RevClaim[] = [];
360
642
  for (const result of latestResults) {
361
- if (result.status === 'fulfilled' && result.value.value) {
362
- const { peerIdStr, value } = result.value;
363
- claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
643
+ if (result.status !== 'fulfilled' || !result.value.value) continue;
644
+ const { peerIdStr, value } = result.value;
645
+ if (peerIdStr === selfId) {
646
+ local = value;
647
+ continue;
364
648
  }
649
+ claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
365
650
  }
366
651
 
367
- const selected = selectQuorumRev(claims, this.simpleMajorityThreshold);
652
+ const capacity = corroboratorCapacity(peerIds.filter(id => id !== selfId).length, this.repairCorroborationClusterSize);
653
+ const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
368
654
  if (!selected) {
369
- log('cluster-fetch:no-quorum', { blockId, responders: claims.length });
370
- return undefined;
655
+ log('cluster-fetch:no-quorum', {
656
+ blockId,
657
+ responders: claims.length,
658
+ required: quorumSize(claims.length, this.simpleMajorityThreshold, capacity)
659
+ });
660
+ return { local };
371
661
  }
372
662
 
373
663
  // Best-effort: penalize peers whose claim contradicts the corroborated pair
@@ -375,7 +665,7 @@ export class CoordinatorRepo implements IRepo {
375
665
  // rev). A lower rev is just lag, never penalized. Never let this throw.
376
666
  this.penalizeContradictingRevClaims(claims, selected, blockId);
377
667
 
378
- return { actionId: selected.actionId, rev: selected.rev };
668
+ return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local };
379
669
  }
380
670
 
381
671
  /**
@@ -445,10 +735,61 @@ export class CoordinatorRepo implements IRepo {
445
735
  };
446
736
  } catch (error) {
447
737
  log('coordinator-repo:pend-error', { actionId: request.actionId, error: (error as Error).message });
738
+ const stale = await this.classifyStaleRejection(error, request, allBlockIds);
739
+ if (stale) return stale;
448
740
  throw error;
449
741
  }
450
742
  }
451
743
 
744
+ /**
745
+ * Decide whether a cluster validator rejection was an optimistic-concurrency loss — the block
746
+ * already advanced past the requested revision — rather than a genuine validation fault.
747
+ * A confirmed loss returns a {@link StaleFailure} carrying `conflict: true` so the caller
748
+ * receives a non-success *response* that says plainly it is a lost race: network-transactor's
749
+ * pend then takes its stale branch and both writers (`Collection.sync`, and the coordinator's
750
+ * multi-collection pendPhase via `isConflictFailure`) retry, instead of a thrown error escaping
751
+ * mid-batch (which splits multi-tree commits — see PartialCommitError).
752
+ *
753
+ * The failure carries no `missing` list: confirmation is a local re-read that reveals the
754
+ * revision is taken but not which actions took it, and no consumer rebases from `missing`
755
+ * anyway (it is only counted or logged). `conflict` conveys retryability directly instead.
756
+ *
757
+ * Confirmation is purely local: re-read the affected blocks from our own storage and require
758
+ * `latest.rev >= request.rev`. The signed reject-reason text is never consulted — it is
759
+ * free-form wire-visible prose and must not become control flow. Anything unconfirmed
760
+ * (including read errors during confirmation) stays a throw, preserving fail-fast for
761
+ * genuine validation faults.
762
+ */
763
+ private async classifyStaleRejection(error: unknown, request: PendRequest, blockIds: BlockId[]): Promise<StaleFailure | undefined> {
764
+ if (!(error instanceof ValidatorRejectionError) || request.rev === undefined) return undefined;
765
+ let results: GetBlockResults;
766
+ try {
767
+ results = await this.storageRepo.get({ blockIds });
768
+ } catch (readError) {
769
+ log('coordinator-repo:pend-stale-classify-read-error', {
770
+ actionId: request.actionId,
771
+ error: (readError as Error).message
772
+ });
773
+ return undefined;
774
+ }
775
+ for (const blockId of blockIds) {
776
+ 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
+ }
786
+ }
787
+ // NOTE: conservative — when only remote members saw the newer revision (local storage still
788
+ // behind), staleness can't be confirmed locally and the rejection stays a throw. If that
789
+ // shows up in practice, extend confirmation with a quorum read; never trust the reject text.
790
+ return undefined;
791
+ }
792
+
452
793
  async cancel(actionRef: ActionBlocks, options?: MessageOptions): Promise<void> {
453
794
  const blockIds = actionRef.blockIds;
454
795
  await this.verifyResponsibility(blockIds);
@@ -501,23 +842,32 @@ export class CoordinatorRepo implements IRepo {
501
842
  this.markBlocksSeen(blockIds);
502
843
  return { success: true };
503
844
  }
504
- // Local cluster didn't execute during consensus. Attempt a local commit,
505
- // but tolerate failure (e.g., "pending action not found") when the cluster
506
- // already reached consensus this coordinator was likely picked for commit
507
- // after missing the pend phase (unreachable during pend, fresh join, etc.).
508
- // The cluster's majority is authoritative; this peer will catch up via sync.
845
+ // Local cluster didn't execute during consensus. Attempt a local commit, but tolerate
846
+ // local divergence when the cluster already reached consensus this coordinator was
847
+ // likely picked for commit after missing the pend phase (unreachable during pend, fresh
848
+ // join, etc.). The cluster's majority is authoritative; this peer catches up via sync.
849
+ //
850
+ // Divergence reaches us in BOTH shapes and both must be tolerated identically:
851
+ // - a THROW ("Pending action … not found"), when we never saw the pend;
852
+ // - a RETURNED `success:false` carrying `missing-base-revision`, when we saw the pend
853
+ // but not the revision that created the block (see StorageRepo.internalCommit).
854
+ // Only the throw was tolerated before the refusal existed. Reporting the refusal to the
855
+ // caller instead would surface a committed transaction as a stale loss: db-core's
856
+ // commitPhase treats any returned `success:false` as a permanent stale failure, so the
857
+ // client would retry an action the cluster already landed until it exhausted its budget.
509
858
  try {
510
859
  const result = await this.storageRepo.commit(request, options);
511
- if (result.success) this.markBlocksSeen(blockIds);
860
+ if (result.success) {
861
+ this.markBlocksSeen(blockIds);
862
+ return result;
863
+ }
864
+ if (isMissingBaseRevisionFailure(result) && clusterReachedCommitConsensus(record)) {
865
+ return this.tolerateLocalCommitDivergence(request, blockIds, result.reason ?? MISSING_BASE_REVISION_REASON);
866
+ }
512
867
  return result;
513
868
  } catch (err) {
514
869
  if (clusterReachedCommitConsensus(record)) {
515
- log('coordinator-repo:commit-local-failed-cluster-succeeded', {
516
- actionId: request.actionId,
517
- error: (err as Error).message
518
- });
519
- this.markBlocksSeen(blockIds);
520
- return { success: true };
870
+ return this.tolerateLocalCommitDivergence(request, blockIds, (err as Error).message);
521
871
  }
522
872
  throw err;
523
873
  }
@@ -526,6 +876,17 @@ export class CoordinatorRepo implements IRepo {
526
876
  throw error;
527
877
  }
528
878
  }
879
+
880
+ /**
881
+ * Report success for a commit the cluster carried but this peer could not apply locally. The
882
+ * blocks are marked seen so the read path treats them as freshness-checked; convergence comes
883
+ * from replication (cohort reconcile, or read-driven acquisition), not from replay here.
884
+ */
885
+ private tolerateLocalCommitDivergence(request: CommitRequest, blockIds: BlockId[], detail: string): CommitResult {
886
+ log('coordinator-repo:commit-local-failed-cluster-succeeded', { actionId: request.actionId, error: detail });
887
+ this.markBlocksSeen(blockIds);
888
+ return { success: true };
889
+ }
529
890
  }
530
891
 
531
892
  /** True if a simple majority of cluster peers signed an approving commit. */