@optimystic/db-p2p 0.17.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.
Files changed (66) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +10 -0
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +2 -1
  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 +35 -11
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +95 -19
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +25 -3
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +27 -3
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/reconcile-block.d.ts +10 -2
  18. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  19. package/dist/src/cluster/reconcile-block.js +38 -18
  20. package/dist/src/cluster/reconcile-block.js.map +1 -1
  21. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  22. package/dist/src/cluster/spread-on-churn.js +8 -0
  23. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  24. package/dist/src/inbound-authorization.d.ts +6 -0
  25. package/dist/src/inbound-authorization.d.ts.map +1 -1
  26. package/dist/src/inbound-authorization.js +6 -0
  27. package/dist/src/inbound-authorization.js.map +1 -1
  28. package/dist/src/libp2p-key-network.d.ts +66 -4
  29. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  30. package/dist/src/libp2p-key-network.js +130 -17
  31. package/dist/src/libp2p-key-network.js.map +1 -1
  32. package/dist/src/libp2p-node-base.d.ts +22 -23
  33. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  34. package/dist/src/libp2p-node-base.js +45 -34
  35. package/dist/src/libp2p-node-base.js.map +1 -1
  36. package/dist/src/repo/cluster-coordinator.d.ts +21 -3
  37. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  38. package/dist/src/repo/cluster-coordinator.js +27 -5
  39. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  40. package/dist/src/repo/coordinator-repo.d.ts +88 -28
  41. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  42. package/dist/src/repo/coordinator-repo.js +287 -81
  43. package/dist/src/repo/coordinator-repo.js.map +1 -1
  44. package/dist/src/storage/storage-repo.d.ts +9 -0
  45. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  46. package/dist/src/storage/storage-repo.js +77 -7
  47. package/dist/src/storage/storage-repo.js.map +1 -1
  48. package/dist/src/testing/mesh-harness.d.ts +17 -0
  49. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  50. package/dist/src/testing/mesh-harness.js +27 -4
  51. package/dist/src/testing/mesh-harness.js.map +1 -1
  52. package/package.json +2 -2
  53. package/readme.md +20 -0
  54. package/src/cluster/block-transfer-service.ts +9 -1
  55. package/src/cluster/cluster-policy.ts +152 -0
  56. package/src/cluster/cluster-repo.ts +100 -22
  57. package/src/cluster/quorum-restore.ts +28 -3
  58. package/src/cluster/reconcile-block.ts +52 -19
  59. package/src/cluster/spread-on-churn.ts +8 -0
  60. package/src/inbound-authorization.ts +6 -0
  61. package/src/libp2p-key-network.ts +958 -807
  62. package/src/libp2p-node-base.ts +65 -57
  63. package/src/repo/cluster-coordinator.ts +30 -6
  64. package/src/repo/coordinator-repo.ts +329 -91
  65. package/src/storage/storage-repo.ts +81 -10
  66. package/src/testing/mesh-harness.ts +34 -4
@@ -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";
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";
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, highestStaleAt, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
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,8 +8,9 @@ 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 { quorumSize, selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
11
+ import { quorumSize, corroboratorCapacity, selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
13
12
  import { RECONCILE_TIMEOUT_MS } from "../cluster/reconcile-block.js";
13
+ import { isMissingBaseRevisionFailure, MISSING_BASE_REVISION_REASON } from "../storage/storage-repo.js";
14
14
  import type { ReconcileBlockCallback } from "../cluster/cluster-repo.js";
15
15
 
16
16
  const log = createLogger('coordinator-repo');
@@ -27,9 +27,12 @@ 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
- function isAdvanceOver(rev: number | undefined, baseline: ActionRev | undefined): boolean {
32
- return typeof rev === 'number' && (baseline === undefined || rev > baseline.rev);
34
+ function isAdvanceOver(rev: number | undefined, baselineRev: number | undefined): boolean {
35
+ return typeof rev === 'number' && (baselineRev === undefined || rev > baselineRev);
33
36
  }
34
37
 
35
38
  /**
@@ -58,6 +61,14 @@ interface ClusterLatestQuery {
58
61
  corroborated?: ActionRev;
59
62
  /** This node's own latest for the block, as answered by the callback's self short-circuit. */
60
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[];
61
72
  }
62
73
 
63
74
  /**
@@ -69,8 +80,18 @@ interface LocalClusterWithExecutionTracking extends ICluster {
69
80
  }
70
81
 
71
82
  /**
72
- * Callback to query a cluster peer for their latest revision of a block.
73
- * 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.
74
95
  */
75
96
  export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId, context?: ActionContext) => Promise<ActionRev | undefined>;
76
97
 
@@ -92,10 +113,22 @@ interface CoordinatorRepoComponents {
92
113
  acquireBlockFromCohort?: AcquireBlockCallback;
93
114
  }
94
115
 
116
+ /**
117
+ * Consensus config for the coordinator side, plus the repair yardstick the read-repair path measures
118
+ * a (possibly shrunken) cohort view against. `repairCorroborationClusterSize` is deliberately its own
119
+ * field rather than an overload of {@link ClusterConsensusConfig.assumedClusterSize}: this same object
120
+ * also builds the `ClusterCoordinator`, and a field whose value silently differed from the cluster
121
+ * member's copy of it would be a trap. See `cluster/cluster-policy.ts` for why the two differ.
122
+ */
123
+ export type CoordinatorRepoConfig = Partial<ClusterConsensusConfig> & {
124
+ clusterSize?: number;
125
+ repairCorroborationClusterSize?: number;
126
+ };
127
+
95
128
  export function coordinatorRepo(
96
129
  keyNetwork: IKeyNetwork,
97
- createClusterClient: (peerId: PeerId) => ClusterClient,
98
- cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
130
+ createClusterClient: (peerId: PeerId) => ICluster,
131
+ cfg?: CoordinatorRepoConfig,
99
132
  fretService?: FretService,
100
133
  reputation?: IPeerReputation,
101
134
  stateStore?: ITransactionStateStore
@@ -128,8 +161,13 @@ export class CoordinatorRepo implements IRepo {
128
161
  private readonly readRepairSampleRate: number;
129
162
  /** Simple-majority threshold from the consensus policy; drives the read-repair corroboration quorum. */
130
163
  private readonly simpleMajorityThreshold: number;
131
- /** Configured full cluster size; the operator's declaration of how many corroborators should exist. */
132
- private readonly clusterSize: number;
164
+ /**
165
+ * Yardstick the read-repair corroboration floor is measured against; the floor for
166
+ * {@link corroboratorCapacity}. Resolved by `resolveClusterPolicy` for a real node; falls back to
167
+ * `assumedClusterSize` and then `clusterSize` for direct constructors (see the constructor), so a
168
+ * caller that has adopted neither field keeps today's behavior exactly.
169
+ */
170
+ private readonly repairCorroborationClusterSize: number;
133
171
  /** Resolved super-majority threshold the coordinator commits on (mirrors the value handed to ClusterCoordinator). */
134
172
  private readonly superMajorityThreshold: number;
135
173
  private readonly reputation?: IPeerReputation;
@@ -140,9 +178,9 @@ export class CoordinatorRepo implements IRepo {
140
178
 
141
179
  constructor(
142
180
  readonly keyNetwork: IKeyNetwork,
143
- readonly createClusterClient: (peerId: PeerId) => ClusterClient,
181
+ readonly createClusterClient: (peerId: PeerId) => ICluster,
144
182
  private readonly storageRepo: IRepo,
145
- cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
183
+ cfg?: CoordinatorRepoConfig,
146
184
  localCluster?: LocalClusterWithExecutionTracking,
147
185
  localPeerId?: PeerId,
148
186
  fretService?: FretService,
@@ -154,6 +192,7 @@ export class CoordinatorRepo implements IRepo {
154
192
  this.localPeerId = localPeerId;
155
193
  const policy: ClusterConsensusConfig & { clusterSize: number } = {
156
194
  clusterSize: cfg?.clusterSize ?? 10,
195
+ assumedClusterSize: cfg?.assumedClusterSize,
157
196
  superMajorityThreshold: cfg?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
158
197
  simpleMajorityThreshold: cfg?.simpleMajorityThreshold ?? 0.51,
159
198
  minAbsoluteClusterSize: cfg?.minAbsoluteClusterSize ?? 3,
@@ -178,7 +217,15 @@ export class CoordinatorRepo implements IRepo {
178
217
  this.readRepairSampleRate = policy.readRepairSampleRate!;
179
218
  this.simpleMajorityThreshold = policy.simpleMajorityThreshold;
180
219
  this.superMajorityThreshold = policy.superMajorityThreshold;
181
- this.clusterSize = policy.clusterSize;
220
+ // Unlike the membership admission gate (which treats an absent assumedClusterSize as "unknown"
221
+ // and admits — refusing writes outright is unacceptable), this falls back to the replication
222
+ // factor and stays strict: the failure mode of getting this wrong is a block that goes
223
+ // unrepaired, degraded rather than dead, so there is no reason to relax it for a caller that
224
+ // has not adopted the new field. A real node is handed an explicit
225
+ // `repairCorroborationClusterSize` by `resolveClusterPolicy`; the `assumedClusterSize` middle
226
+ // term keeps direct constructors (embedders, existing tests) behaving as before.
227
+ this.repairCorroborationClusterSize =
228
+ cfg?.repairCorroborationClusterSize ?? policy.assumedClusterSize ?? policy.clusterSize;
182
229
  this.reputation = reputation;
183
230
  const localClusterRef = localCluster && localPeerId ? {
184
231
  update: localCluster.update.bind(localCluster),
@@ -251,6 +298,13 @@ export class CoordinatorRepo implements IRepo {
251
298
 
252
299
  async get(blockGets: BlockGets, options?: MessageOptions): Promise<GetBlockResults> {
253
300
  // Soft proximity check — warn but still serve reads for graceful degradation
301
+ // NOTE: a soft-served read now also *acquires* the block durably (see restoreCorroborated), where
302
+ // before it could at most promote a pending this node already held. So a soft serve leaves behind
303
+ // a replica of a block this node is not responsible for, and nothing sweeps those: ring-shift
304
+ // sheds a keyspace RANGE, not "blocks outside my cohort". Fine while soft serves are what they
305
+ // are meant to be — a rare degradation during routing churn — since routing already placed this
306
+ // node near the block. If they ever become routine, gate acquisition (not the serve itself) on
307
+ // isResponsibleForBlock.
254
308
  for (const blockId of blockGets.blockIds) {
255
309
  if (!await this.isResponsibleForBlock(blockId)) {
256
310
  log('proximity:get-warning', { blockId, msg: 'serving read for non-responsible block' });
@@ -264,14 +318,22 @@ export class CoordinatorRepo implements IRepo {
264
318
  // (a) Missing — block isn't present locally at all (legacy behavior).
265
319
  // (b) Stale-by-policy — block is present but read-repair policy says verify.
266
320
  // Skip cluster fetch if this is already a sync request (to prevent recursive queries).
321
+ // A sync read is also never marked `unavailable` here — the consult it skips is the
322
+ // one whose failure the flag reports, and flagging would feed the recursion this
323
+ // bypass exists to prevent. (Storage-level 'unmaterializable' flags still pass
324
+ // through untouched; they report local state, not the consult.)
267
325
  const skipClusterFetch = (options as any)?.skipClusterFetch;
268
326
  // NOTE: NetworkTransactor.get treats an authoritative "absent" ({ state: {} })
269
327
  // as final and no longer retries it (ticket txn-perf-authoritative-notfound),
270
- // relying on this cluster reconciliation to have already run. If a coordinator
271
- // is ever configured WITHOUT clusterLatestCallback, a missing block is answered
272
- // from local state alone with no transactor-level retry to compensate. That is
273
- // fine today (such a coordinator has no cluster to reconcile against), but keep
274
- // this coupling in mind if a partial-cluster read path is added.
328
+ // relying on this cluster reconciliation to have already run. When the consult
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,
331
+ // which re-enables the transactor-level retry against a different peer. If a
332
+ // coordinator is configured WITHOUT clusterLatestCallback, there is no cohort to
333
+ // consult and the local answer IS the whole truth — it stays authoritative, with
334
+ // no flag and no transactor-level retry to compensate. That is fine (such a
335
+ // coordinator has no cluster to reconcile against), but keep this coupling in
336
+ // mind if a partial-cluster read path is added.
275
337
  if (this.clusterLatestCallback && !skipClusterFetch) {
276
338
  for (const blockId of blockGets.blockIds) {
277
339
  const localEntry = localResult[blockId];
@@ -290,7 +352,7 @@ export class CoordinatorRepo implements IRepo {
290
352
  }
291
353
 
292
354
  try {
293
- await this.fetchBlockFromCluster(blockId, blockGets.context);
355
+ const { inconclusive } = await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
294
356
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
295
357
  const newRev = refreshed[blockId]?.state?.latest?.rev;
296
358
  if (refreshed[blockId]) {
@@ -303,8 +365,21 @@ export class CoordinatorRepo implements IRepo {
303
365
  log('cluster-tx:read-repair-noop', { blockId });
304
366
  }
305
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
+ }
306
377
  } catch (err) {
307
378
  log('cluster-fetch:error', { blockId, error: (err as Error).message });
379
+ // The consult that was supposed to make this answer trustworthy did not run.
380
+ if (isMissing) {
381
+ this.flagUnconfirmedAbsence(localResult, blockId);
382
+ }
308
383
  }
309
384
  }
310
385
  }
@@ -312,6 +387,22 @@ export class CoordinatorRepo implements IRepo {
312
387
  return localResult;
313
388
  }
314
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
+
315
406
  /** Decide whether the read-repair policy wants us to consult the cluster for a present-but-possibly-stale block. */
316
407
  private shouldReadRepair(blockId: BlockId): boolean {
317
408
  switch (this.readRepairMode) {
@@ -350,13 +441,26 @@ export class CoordinatorRepo implements IRepo {
350
441
  this.lastSeenCommitMs.set(blockId, ts);
351
442
  }
352
443
 
353
- private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext): Promise<void> {
354
- if (!this.clusterLatestCallback) return;
444
+ /**
445
+ * One repair pass for a block: ask the cohort what it holds, and converge onto that if it is
446
+ * ahead of `localRev` — the revision the caller's read already loaded, and the baseline every
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.
456
+ */
457
+ private async fetchBlockFromCluster(blockId: BlockId, context?: ActionContext, localRev?: number): Promise<{ inconclusive: boolean }> {
458
+ if (!this.clusterLatestCallback) return { inconclusive: false };
355
459
 
356
460
  const blockIdBytes = new TextEncoder().encode(blockId);
357
461
  const peers = await this.keyNetwork.findCluster(blockIdBytes);
358
462
  const peerIds = peers ? Object.keys(peers) : [];
359
- if (peerIds.length === 0) return;
463
+ if (peerIds.length === 0) return { inconclusive: false };
360
464
 
361
465
  // Solo-cluster short-circuit: the only responsible peer is us. There is no
362
466
  // remote to sync from, so skip the callback entirely. Querying ourselves
@@ -368,26 +472,44 @@ export class CoordinatorRepo implements IRepo {
368
472
  && peerIds[0] === this.localPeerId.toString()
369
473
  ) {
370
474
  log('cluster-fetch:solo-self-skip', { blockId });
371
- return;
475
+ return { inconclusive: false };
372
476
  }
373
477
 
374
- 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;
375
483
  // Nothing corroborated: keep local data AND stay eligible for repair — marking the
376
484
  // block seen here would suppress the next attempt for the whole read-repair window.
377
- if (!corroborated) return;
485
+ if (!corroborated) return { inconclusive: cohortSilent };
486
+
487
+ // The self answer is the sharper baseline (same storage, same context, read alongside the
488
+ // cohort's), but it exists only when `findCluster` returned this node. A soft serve for a
489
+ // block this node is no longer responsible for is absent from its own cohort view, so fall
490
+ // back to the revision the caller's read already loaded. Without the fallback both decisions
491
+ // below degrade to "any local revision is an advance", which restores backwards and reports
492
+ // a sync at the revision the pass started from.
493
+ const baselineRev = local?.rev ?? localRev;
378
494
 
379
495
  // Never restore backwards. With this node's own claim excluded from the quorum, a
380
496
  // cohort that lags behind the reader corroborates an OLDER revision; adopting it
381
497
  // would be a regression, and logging it as a sync would be a lie. The cohort did
382
498
  // answer, so the block is verified fresh — mark it seen.
383
- if (local && corroborated.rev <= local.rev) {
384
- log('cluster-fetch:local-current', { blockId, localRev: local.rev, clusterRev: corroborated.rev });
499
+ // NOTE: in a cohort of two, that sole peer is the only corroborator, so a lying one can park
500
+ // the reader here corroborating the revision it already holds — and re-arm the lazy window
501
+ // on every pass, hiding a real divergence. Bounded by `readRepairWindowMs` (10s default) and
502
+ // no worse than the peer simply staying silent. If two-member cohorts become a supported
503
+ // production topology rather than a dev convenience, stop re-arming the window on a
504
+ // corroboration that came from a single voter.
505
+ if (baselineRev !== undefined && corroborated.rev <= baselineRev) {
506
+ log('cluster-fetch:local-current', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
385
507
  this.markBlocksSeen([blockId]);
386
- return;
508
+ return { inconclusive: cohortSilent };
387
509
  }
388
510
 
389
511
  // Corroborated revision is ahead of ours — converge onto it.
390
- const rev = await this.restoreCorroborated(blockId, corroborated, local, peerIds);
512
+ const rev = await this.restoreCorroborated(blockId, corroborated, baselineRev, peerIds);
391
513
 
392
514
  // Log the OUTCOME, not the attempt. Logging `synced` unconditionally reported hundreds of
393
515
  // phantom convergences per run and made a real replication defect invisible for two debugging
@@ -395,24 +517,29 @@ export class CoordinatorRepo implements IRepo {
395
517
  if (rev !== undefined) {
396
518
  log('cluster-fetch:synced', { blockId, rev });
397
519
  } else {
398
- log('cluster-fetch:not-restored', { blockId, localRev: local?.rev, clusterRev: corroborated.rev });
520
+ log('cluster-fetch:not-restored', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
399
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;
400
526
  // The block is marked seen either way — the cohort DID answer, so its freshness was checked,
401
527
  // which is what the read-repair window tracks. A failed convergence therefore waits out the
402
528
  // window before retrying.
403
529
  // NOTE: that damping covers only a block this node holds at an OLDER revision. A block entirely
404
530
  // missing locally never consults the window (`get` triggers on `isMissing` before
405
- // `shouldReadRepair`), so a persistently failing acquisition — e.g. a two-node deployment left
406
- // at the default `clusterSize: 10`, where the content quorum can never be met — re-fetches an
531
+ // `shouldReadRepair`), so a persistently failing acquisition — e.g. a two-node deployment that
532
+ // never set `assumedClusterSize`, where the content quorum can never be met — re-fetches an
407
533
  // archive on every read of that block. Correct, and self-limiting once the cohort can agree; if
408
534
  // it ever shows as read amplification, gate the acquisition step (not the latest-query) on the
409
535
  // same window rather than widening `isMissing`.
410
536
  this.markBlocksSeen([blockId]);
537
+ return { inconclusive };
411
538
  }
412
539
 
413
540
  /**
414
541
  * Bring this node up to the cohort-corroborated `corroborated`, returning the revision it holds
415
- * afterwards when that is an advance over `local`, else `undefined`.
542
+ * afterwards when that is an advance over `baselineRev`, else `undefined`.
416
543
  *
417
544
  * Two mechanisms, cheapest first:
418
545
  * 1. **Promote a local pending** — free, no network, and the only mechanism that existed before
@@ -435,11 +562,11 @@ export class CoordinatorRepo implements IRepo {
435
562
  private async restoreCorroborated(
436
563
  blockId: BlockId,
437
564
  corroborated: ActionRev,
438
- local: ActionRev | undefined,
565
+ baselineRev: number | undefined,
439
566
  cohortPeerIds: string[]
440
567
  ): Promise<number | undefined> {
441
568
  const promoted = await this.promoteCorroborated(blockId, corroborated);
442
- if (isAdvanceOver(promoted, local)) {
569
+ if (isAdvanceOver(promoted, baselineRev)) {
443
570
  return promoted;
444
571
  }
445
572
 
@@ -451,6 +578,12 @@ export class CoordinatorRepo implements IRepo {
451
578
  // inside the callback via `saveReplicatedBlock`, which takes the per-block commit latch —
452
579
  // safe to call from here because the read path holds no latch of its own (`StorageRepo.get`
453
580
  // acquires and releases it around the promotion above, and nothing wraps this method).
581
+ // NOTE: `get` walks its block ids sequentially, so the bound is per block, not per call — a
582
+ // multi-block read that is missing N blocks against a wholly stalled cohort waits N × this.
583
+ // Acceptable today (the underlying per-peer archive fetch is itself 1s-bounded and runs the
584
+ // cohort in parallel, so the 5s is a stall ceiling, not a typical cost). If a cold reader
585
+ // batching a wide read ever times out above this layer, repair the block ids concurrently
586
+ // rather than shortening the bound.
454
587
  await withDeadline(
455
588
  this.acquireBlockFromCohort(blockId, corroborated, cohortPeerIds),
456
589
  RECONCILE_TIMEOUT_MS,
@@ -462,7 +595,7 @@ export class CoordinatorRepo implements IRepo {
462
595
  return undefined;
463
596
  }
464
597
  const acquired = await this.readLocalRev(blockId);
465
- return isAdvanceOver(acquired, local) ? acquired : undefined;
598
+ return isAdvanceOver(acquired, baselineRev) ? acquired : undefined;
466
599
  }
467
600
 
468
601
  /**
@@ -470,41 +603,36 @@ export class CoordinatorRepo implements IRepo {
470
603
  * the repair. Returns the local revision afterwards.
471
604
  *
472
605
  * A pending-only block (metadata seeded by `savePendingTransaction`, no committed revision) asked
473
- * for a forward revision throws out of `BlockStorage.ensureRevision` when no restore can supply it.
474
- * On THIS path that is an absence, not a read failure — acquisition is precisely the mechanism that
475
- * can supply it so the throw is logged and swallowed rather than short-circuiting the caller.
606
+ * for a forward revision no promotion can reach used to throw out of `BlockStorage.ensureRevision`;
607
+ * `StorageRepo.get` now reports it as an entry flagged `unavailable` instead (ticket
608
+ * repo-reports-unavailable-vs-absent). On THIS path either shape is an absence, not a read failure
609
+ * acquisition is precisely the mechanism that can supply the revision — so both are logged as
610
+ * `promote-unavailable` and stepped over rather than short-circuiting the caller.
476
611
  */
477
612
  private async promoteCorroborated(blockId: BlockId, corroborated: ActionRev): Promise<number | undefined> {
478
613
  try {
479
- return await this.readLocalRev(blockId, { committed: [corroborated], rev: corroborated.rev });
614
+ const entry = await this.readLocalEntry(blockId, { committed: [corroborated], rev: corroborated.rev });
615
+ if (entry?.unavailable !== undefined) {
616
+ log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: entry.unavailable });
617
+ return undefined;
618
+ }
619
+ return entry?.state?.latest?.rev;
480
620
  } catch (err) {
481
621
  log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: (err as Error).message });
482
622
  return undefined;
483
623
  }
484
624
  }
485
625
 
486
- /** This node's own `latest.rev` for a block, optionally driving a promotion context through the read. */
487
- private async readLocalRev(blockId: BlockId, context?: ActionContext): Promise<number | undefined> {
626
+ /** This node's own answer for a block, optionally driving a promotion context through the read.
627
+ * Callers that care whether the answer is authoritative inspect `entry.unavailable`. */
628
+ private async readLocalEntry(blockId: BlockId, context?: ActionContext) {
488
629
  const result = await this.storageRepo.get({ blockIds: [blockId], context });
489
- return result[blockId]?.state?.latest?.rev;
630
+ return result[blockId];
490
631
  }
491
632
 
492
- /**
493
- * How many peers other than this node could corroborate a claim about a block, given a
494
- * cohort view of `peerIds`. Deliberately the MAX of what we observe and what the
495
- * configured cluster size implies: the corroboration floor may only be relaxed for a
496
- * cohort that is genuinely small, never for one that merely *looks* small. `findCluster`
497
- * results are unauthenticated, so a partition — or an attacker with routing influence —
498
- * can shrink this node's view to itself plus one peer; measuring against the configured
499
- * size keeps that shrunken view from talking the requirement down to a single voter.
500
- * The escape hatch for a real two-node deployment is therefore to configure
501
- * `clusterSize: 2`, an explicit operator declaration, mirroring how
502
- * `allowUnvalidatedSmallCluster` gates the membership admission floor.
503
- */
504
- private corroboratorCapacity(peerIds: string[]): number {
505
- const selfId = this.localPeerId?.toString();
506
- const observed = peerIds.filter(id => id !== selfId).length;
507
- return Math.max(observed, this.clusterSize - 1);
633
+ /** This node's own `latest.rev` for a block, optionally driving a promotion context through the read. */
634
+ private async readLocalRev(blockId: BlockId, context?: ActionContext): Promise<number | undefined> {
635
+ return (await this.readLocalEntry(blockId, context))?.state?.latest?.rev;
508
636
  }
509
637
 
510
638
  /**
@@ -528,37 +656,60 @@ export class CoordinatorRepo implements IRepo {
528
656
  * `debt-read-repair-commit-cert-verification`.
529
657
  */
530
658
  private async queryClusterForLatest(peerIds: string[], blockId: BlockId, context?: ActionContext): Promise<ClusterLatestQuery> {
531
- // Add timeout wrapper to prevent hanging on unresponsive peers
532
- const withTimeout = <T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> =>
533
- Promise.race([
534
- promise,
535
- new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), timeoutMs))
536
- ]);
537
-
538
- // Query peers in parallel for their latest revision (with 1s timeout per peer),
539
- // 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.
540
668
  const latestResults = await Promise.allSettled(
541
669
  peerIds.map(async peerIdStr => {
542
670
  const peerId = peerIdFromString(peerIdStr);
543
- const value = await withTimeout(this.clusterLatestCallback!(peerId, blockId, context), 1000);
544
- return { peerIdStr, value };
671
+ return await withDeadline(
672
+ this.clusterLatestCallback!(peerId, blockId, context),
673
+ LATEST_QUERY_TIMEOUT_MS,
674
+ `latest query to ${peerIdStr}`
675
+ );
545
676
  })
546
677
  );
547
678
 
679
+ // NOTE: self-exclusion is keyed on `localPeerId`, which is optional for the single-node/test
680
+ // construction this class has always tolerated. Left unset, this node's own answer is counted
681
+ // as a peer claim again. Harmless today — the self answer can only ever corroborate the
682
+ // revision already held, so the pass declines as `local-current` — but if a future caller can
683
+ // make self report something the reader does not hold, make `localPeerId` required instead.
548
684
  const selfId = this.localPeerId?.toString();
549
685
  let local: ActionRev | undefined;
550
686
  const claims: RevClaim[] = [];
551
- for (const result of latestResults) {
552
- if (result.status !== 'fulfilled' || !result.value.value) continue;
553
- 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;
554
701
  if (peerIdStr === selfId) {
555
702
  local = value;
556
703
  continue;
557
704
  }
705
+ if (!value) continue; // responded, holds nothing — an absent claim, not silence
558
706
  claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
559
707
  }
708
+ if (silent.length > 0) {
709
+ log('cluster-fetch:peers-silent', { blockId, silent: silent.length, consulted: peerIds.length });
710
+ }
560
711
 
561
- const capacity = this.corroboratorCapacity(peerIds);
712
+ const capacity = corroboratorCapacity(peerIds.filter(id => id !== selfId).length, this.repairCorroborationClusterSize);
562
713
  const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
563
714
  if (!selected) {
564
715
  log('cluster-fetch:no-quorum', {
@@ -566,7 +717,7 @@ export class CoordinatorRepo implements IRepo {
566
717
  responders: claims.length,
567
718
  required: quorumSize(claims.length, this.simpleMajorityThreshold, capacity)
568
719
  });
569
- return { local };
720
+ return { local, silent };
570
721
  }
571
722
 
572
723
  // Best-effort: penalize peers whose claim contradicts the corroborated pair
@@ -574,7 +725,7 @@ export class CoordinatorRepo implements IRepo {
574
725
  // rev). A lower rev is just lag, never penalized. Never let this throw.
575
726
  this.penalizeContradictingRevClaims(claims, selected, blockId);
576
727
 
577
- return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local };
728
+ return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local, silent };
578
729
  }
579
730
 
580
731
  /**
@@ -644,10 +795,77 @@ export class CoordinatorRepo implements IRepo {
644
795
  };
645
796
  } catch (error) {
646
797
  log('coordinator-repo:pend-error', { actionId: request.actionId, error: (error as Error).message });
798
+ const stale = await this.classifyStaleRejection(error, request, allBlockIds);
799
+ if (stale) return stale;
647
800
  throw error;
648
801
  }
649
802
  }
650
803
 
804
+ /**
805
+ * Decide whether a cluster validator rejection was an optimistic-concurrency loss — the block
806
+ * already advanced past the requested revision — rather than a genuine validation fault.
807
+ * A confirmed loss returns a {@link StaleFailure} carrying `conflict: true` so the caller
808
+ * receives a non-success *response* that says plainly it is a lost race: network-transactor's
809
+ * pend then takes its stale branch and both writers (`Collection.sync`, and the coordinator's
810
+ * multi-collection pendPhase via `isConflictFailure`) retry, instead of a thrown error escaping
811
+ * mid-batch (which splits multi-tree commits — see PartialCommitError).
812
+ *
813
+ * The failure carries no `missing` list: confirmation is a local re-read that reveals the
814
+ * revision is taken but not which actions took it, and no consumer rebases from `missing`
815
+ * anyway (it is only counted or logged). `conflict` conveys retryability directly instead.
816
+ *
817
+ * Confirmation is purely local: re-read the affected blocks from our own storage and require
818
+ * `latest.rev >= request.rev`. The signed reject-reason text is never consulted — it is
819
+ * free-form wire-visible prose and must not become control flow. Anything unconfirmed
820
+ * (including read errors during confirmation) stays a throw, preserving fail-fast for
821
+ * genuine validation faults.
822
+ */
823
+ private async classifyStaleRejection(error: unknown, request: PendRequest, blockIds: BlockId[]): Promise<StaleFailure | undefined> {
824
+ const requestedRev = request.rev;
825
+ if (!(error instanceof ValidatorRejectionError) || requestedRev === undefined) return undefined;
826
+ let results: GetBlockResults;
827
+ try {
828
+ results = await this.storageRepo.get({ blockIds });
829
+ } catch (readError) {
830
+ log('coordinator-repo:pend-stale-classify-read-error', {
831
+ actionId: request.actionId,
832
+ error: (readError as Error).message
833
+ });
834
+ return undefined;
835
+ }
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 => {
841
+ const latest = results[blockId]?.state.latest;
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
+ };
860
+ }
861
+ // NOTE: conservative — when only remote members saw the newer revision (local storage still
862
+ // behind), staleness can't be confirmed locally and the rejection stays a throw. If that
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.
866
+ return undefined;
867
+ }
868
+
651
869
  async cancel(actionRef: ActionBlocks, options?: MessageOptions): Promise<void> {
652
870
  const blockIds = actionRef.blockIds;
653
871
  await this.verifyResponsibility(blockIds);
@@ -700,23 +918,32 @@ export class CoordinatorRepo implements IRepo {
700
918
  this.markBlocksSeen(blockIds);
701
919
  return { success: true };
702
920
  }
703
- // Local cluster didn't execute during consensus. Attempt a local commit,
704
- // but tolerate failure (e.g., "pending action not found") when the cluster
705
- // already reached consensus this coordinator was likely picked for commit
706
- // after missing the pend phase (unreachable during pend, fresh join, etc.).
707
- // The cluster's majority is authoritative; this peer will catch up via sync.
921
+ // Local cluster didn't execute during consensus. Attempt a local commit, but tolerate
922
+ // local divergence when the cluster already reached consensus this coordinator was
923
+ // likely picked for commit after missing the pend phase (unreachable during pend, fresh
924
+ // join, etc.). The cluster's majority is authoritative; this peer catches up via sync.
925
+ //
926
+ // Divergence reaches us in BOTH shapes and both must be tolerated identically:
927
+ // - a THROW ("Pending action … not found"), when we never saw the pend;
928
+ // - a RETURNED `success:false` carrying `missing-base-revision`, when we saw the pend
929
+ // but not the revision that created the block (see StorageRepo.internalCommit).
930
+ // Only the throw was tolerated before the refusal existed. Reporting the refusal to the
931
+ // caller instead would surface a committed transaction as a stale loss: db-core's
932
+ // commitPhase treats any returned `success:false` as a permanent stale failure, so the
933
+ // client would retry an action the cluster already landed until it exhausted its budget.
708
934
  try {
709
935
  const result = await this.storageRepo.commit(request, options);
710
- if (result.success) this.markBlocksSeen(blockIds);
936
+ if (result.success) {
937
+ this.markBlocksSeen(blockIds);
938
+ return result;
939
+ }
940
+ if (isMissingBaseRevisionFailure(result) && clusterReachedCommitConsensus(record)) {
941
+ return this.tolerateLocalCommitDivergence(request, blockIds, result.reason ?? MISSING_BASE_REVISION_REASON);
942
+ }
711
943
  return result;
712
944
  } catch (err) {
713
945
  if (clusterReachedCommitConsensus(record)) {
714
- log('coordinator-repo:commit-local-failed-cluster-succeeded', {
715
- actionId: request.actionId,
716
- error: (err as Error).message
717
- });
718
- this.markBlocksSeen(blockIds);
719
- return { success: true };
946
+ return this.tolerateLocalCommitDivergence(request, blockIds, (err as Error).message);
720
947
  }
721
948
  throw err;
722
949
  }
@@ -725,6 +952,17 @@ export class CoordinatorRepo implements IRepo {
725
952
  throw error;
726
953
  }
727
954
  }
955
+
956
+ /**
957
+ * Report success for a commit the cluster carried but this peer could not apply locally. The
958
+ * blocks are marked seen so the read path treats them as freshness-checked; convergence comes
959
+ * from replication (cohort reconcile, or read-driven acquisition), not from replay here.
960
+ */
961
+ private tolerateLocalCommitDivergence(request: CommitRequest, blockIds: BlockId[], detail: string): CommitResult {
962
+ log('coordinator-repo:commit-local-failed-cluster-succeeded', { actionId: request.actionId, error: detail });
963
+ this.markBlocksSeen(blockIds);
964
+ return { success: true };
965
+ }
728
966
  }
729
967
 
730
968
  /** True if a simple majority of cluster peers signed an approving commit. */