@optimystic/db-p2p 0.17.0 → 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 (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 +88 -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 +14 -0
  29. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  30. package/dist/src/libp2p-key-network.js +54 -4
  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 +22 -19
  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 +60 -26
  41. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  42. package/dist/src/repo/coordinator-repo.js +218 -65
  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 +56 -4
  47. package/dist/src/storage/storage-repo.js.map +1 -1
  48. package/dist/src/testing/mesh-harness.d.ts +10 -0
  49. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  50. package/dist/src/testing/mesh-harness.js +14 -1
  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 +93 -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 +54 -4
  62. package/src/libp2p-node-base.ts +42 -43
  63. package/src/repo/cluster-coordinator.ts +30 -6
  64. package/src/repo/coordinator-repo.ts +235 -73
  65. package/src/storage/storage-repo.ts +58 -6
  66. package/src/testing/mesh-harness.ts +15 -1
@@ -1,7 +1,6 @@
1
1
  import { peerIdFromString } from "@libp2p/peer-id";
2
- import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig } from "@optimystic/db-core";
2
+ import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig, ICluster } from "@optimystic/db-core";
3
3
  import { CURRENT_MEMBERSHIP_VERSION, computeClusterMessageHash, membershipDigest } from "@optimystic/db-core";
4
- import { ClusterClient } from "../cluster/client.js";
5
4
  import { Pending } from "@optimystic/db-core";
6
5
  import type { PeerId } from "@libp2p/interface";
7
6
  import { createLogger, verbose } from '../logger.js'
@@ -13,6 +12,26 @@ import type { ITransactionStateStore } from "../cluster/i-transaction-state-stor
13
12
 
14
13
  const log = createLogger('cluster')
15
14
 
15
+ /**
16
+ * Consensus refused a transaction: enough members voted reject that super-majority became
17
+ * impossible. A typed error (rather than a bare `Error`) so the repo layer above can distinguish
18
+ * "the cluster voted this down" from transport/availability failures WITHOUT string-matching the
19
+ * rejection reasons — those are free-form text that is part of each member's signed vote payload
20
+ * (see cluster-repo's `computeSigningPayload`), so their wording must never become control flow.
21
+ * `CoordinatorRepo.pend` uses this to decide whether a rejection is a retryable stale-revision
22
+ * loss (confirmed against local storage) or a genuine validation fault.
23
+ */
24
+ export class ValidatorRejectionError extends Error {
25
+ constructor(
26
+ message: string,
27
+ /** Per-peer reject reasons, verbatim from the vote signatures (free-form, wire-visible). */
28
+ readonly rejectReasons: Record<string, string>
29
+ ) {
30
+ super(message);
31
+ this.name = 'ValidatorRejectionError';
32
+ }
33
+ }
34
+
16
35
  /** Cancel handle for an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
17
36
  export type TimerCancel = () => void;
18
37
 
@@ -76,7 +95,8 @@ export class ClusterCoordinator {
76
95
 
77
96
  constructor(
78
97
  private readonly keyNetwork: IKeyNetwork,
79
- private readonly createClusterClient: (peerId: PeerId) => ClusterClient,
98
+ /** Factory for a per-peer cluster RPC handle; only `update` is ever called, hence `ICluster`. */
99
+ private readonly createClusterClient: (peerId: PeerId) => ICluster,
80
100
  private readonly cfg: ClusterConsensusConfig & { clusterSize: number },
81
101
  private readonly localCluster?: {
82
102
  update: (record: ClusterRecord) => Promise<ClusterRecord>;
@@ -322,9 +342,11 @@ export class ClusterCoordinator {
322
342
  // If more than (peerCount - superMajority) nodes reject, we can never reach super-majority
323
343
  const maxAllowedRejections = peerCount - superMajority;
324
344
  if (rejectionCount > maxAllowedRejections) {
325
- const rejectReasons = Object.entries(promises)
345
+ const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
326
346
  .filter(([_, sig]) => sig.type === 'reject')
327
- .map(([peerId, sig]) => `${peerId}: ${sig.rejectReason ?? 'unknown'}`)
347
+ .map(([peerId, sig]) => [peerId, sig.rejectReason ?? 'unknown']));
348
+ const rejectReasons = Object.entries(rejectReasonsByPeer)
349
+ .map(([peerId, reason]) => `${peerId}: ${reason}`)
328
350
  .join('; ');
329
351
  log('cluster-tx:rejected-by-validators', {
330
352
  messageHash: record.messageHash,
@@ -334,7 +356,9 @@ export class ClusterCoordinator {
334
356
  reasons: rejectReasons
335
357
  });
336
358
  this.updateTransactionRecord(promised.record, 'rejected-by-validators');
337
- throw new Error(`Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`);
359
+ throw new ValidatorRejectionError(
360
+ `Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`,
361
+ rejectReasonsByPeer);
338
362
  }
339
363
 
340
364
  if (peerCount > 1 && approvalCount < superMajority) {
@@ -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,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');
@@ -28,8 +28,8 @@ const log = createLogger('coordinator-repo');
28
28
  export type AcquireBlockCallback = ReconcileBlockCallback;
29
29
 
30
30
  /** 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);
31
+ function isAdvanceOver(rev: number | undefined, baselineRev: number | undefined): boolean {
32
+ return typeof rev === 'number' && (baselineRev === undefined || rev > baselineRev);
33
33
  }
34
34
 
35
35
  /**
@@ -46,6 +46,22 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
46
46
  });
47
47
  }
48
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
+
49
65
  /**
50
66
  * What one round of polling the cohort learned about a block: the revision the OTHER
51
67
  * cohort members corroborated, and what this node itself already holds. The two are kept
@@ -92,10 +108,22 @@ interface CoordinatorRepoComponents {
92
108
  acquireBlockFromCohort?: AcquireBlockCallback;
93
109
  }
94
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
+
95
123
  export function coordinatorRepo(
96
124
  keyNetwork: IKeyNetwork,
97
- createClusterClient: (peerId: PeerId) => ClusterClient,
98
- cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
125
+ createClusterClient: (peerId: PeerId) => ICluster,
126
+ cfg?: CoordinatorRepoConfig,
99
127
  fretService?: FretService,
100
128
  reputation?: IPeerReputation,
101
129
  stateStore?: ITransactionStateStore
@@ -128,8 +156,13 @@ export class CoordinatorRepo implements IRepo {
128
156
  private readonly readRepairSampleRate: number;
129
157
  /** Simple-majority threshold from the consensus policy; drives the read-repair corroboration quorum. */
130
158
  private readonly simpleMajorityThreshold: number;
131
- /** Configured full cluster size; the operator's declaration of how many corroborators should exist. */
132
- private readonly clusterSize: 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;
133
166
  /** Resolved super-majority threshold the coordinator commits on (mirrors the value handed to ClusterCoordinator). */
134
167
  private readonly superMajorityThreshold: number;
135
168
  private readonly reputation?: IPeerReputation;
@@ -140,9 +173,9 @@ export class CoordinatorRepo implements IRepo {
140
173
 
141
174
  constructor(
142
175
  readonly keyNetwork: IKeyNetwork,
143
- readonly createClusterClient: (peerId: PeerId) => ClusterClient,
176
+ readonly createClusterClient: (peerId: PeerId) => ICluster,
144
177
  private readonly storageRepo: IRepo,
145
- cfg?: Partial<ClusterConsensusConfig> & { clusterSize?: number },
178
+ cfg?: CoordinatorRepoConfig,
146
179
  localCluster?: LocalClusterWithExecutionTracking,
147
180
  localPeerId?: PeerId,
148
181
  fretService?: FretService,
@@ -154,6 +187,7 @@ export class CoordinatorRepo implements IRepo {
154
187
  this.localPeerId = localPeerId;
155
188
  const policy: ClusterConsensusConfig & { clusterSize: number } = {
156
189
  clusterSize: cfg?.clusterSize ?? 10,
190
+ assumedClusterSize: cfg?.assumedClusterSize,
157
191
  superMajorityThreshold: cfg?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
158
192
  simpleMajorityThreshold: cfg?.simpleMajorityThreshold ?? 0.51,
159
193
  minAbsoluteClusterSize: cfg?.minAbsoluteClusterSize ?? 3,
@@ -178,7 +212,15 @@ export class CoordinatorRepo implements IRepo {
178
212
  this.readRepairSampleRate = policy.readRepairSampleRate!;
179
213
  this.simpleMajorityThreshold = policy.simpleMajorityThreshold;
180
214
  this.superMajorityThreshold = policy.superMajorityThreshold;
181
- this.clusterSize = policy.clusterSize;
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;
182
224
  this.reputation = reputation;
183
225
  const localClusterRef = localCluster && localPeerId ? {
184
226
  update: localCluster.update.bind(localCluster),
@@ -251,6 +293,13 @@ export class CoordinatorRepo implements IRepo {
251
293
 
252
294
  async get(blockGets: BlockGets, options?: MessageOptions): Promise<GetBlockResults> {
253
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.
254
303
  for (const blockId of blockGets.blockIds) {
255
304
  if (!await this.isResponsibleForBlock(blockId)) {
256
305
  log('proximity:get-warning', { blockId, msg: 'serving read for non-responsible block' });
@@ -264,14 +313,21 @@ export class CoordinatorRepo implements IRepo {
264
313
  // (a) Missing — block isn't present locally at all (legacy behavior).
265
314
  // (b) Stale-by-policy — block is present but read-repair policy says verify.
266
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.)
267
320
  const skipClusterFetch = (options as any)?.skipClusterFetch;
268
321
  // NOTE: NetworkTransactor.get treats an authoritative "absent" ({ state: {} })
269
322
  // 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.
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.
275
331
  if (this.clusterLatestCallback && !skipClusterFetch) {
276
332
  for (const blockId of blockGets.blockIds) {
277
333
  const localEntry = localResult[blockId];
@@ -290,7 +346,7 @@ export class CoordinatorRepo implements IRepo {
290
346
  }
291
347
 
292
348
  try {
293
- await this.fetchBlockFromCluster(blockId, blockGets.context);
349
+ await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
294
350
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
295
351
  const newRev = refreshed[blockId]?.state?.latest?.rev;
296
352
  if (refreshed[blockId]) {
@@ -305,6 +361,23 @@ export class CoordinatorRepo implements IRepo {
305
361
  }
306
362
  } catch (err) {
307
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
+ }
308
381
  }
309
382
  }
310
383
  }
@@ -350,7 +423,12 @@ export class CoordinatorRepo implements IRepo {
350
423
  this.lastSeenCommitMs.set(blockId, ts);
351
424
  }
352
425
 
353
- 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> {
354
432
  if (!this.clusterLatestCallback) return;
355
433
 
356
434
  const blockIdBytes = new TextEncoder().encode(blockId);
@@ -376,18 +454,32 @@ export class CoordinatorRepo implements IRepo {
376
454
  // block seen here would suppress the next attempt for the whole read-repair window.
377
455
  if (!corroborated) return;
378
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
+
379
465
  // Never restore backwards. With this node's own claim excluded from the quorum, a
380
466
  // cohort that lags behind the reader corroborates an OLDER revision; adopting it
381
467
  // would be a regression, and logging it as a sync would be a lie. The cohort did
382
468
  // 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 });
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 });
385
477
  this.markBlocksSeen([blockId]);
386
478
  return;
387
479
  }
388
480
 
389
481
  // Corroborated revision is ahead of ours — converge onto it.
390
- const rev = await this.restoreCorroborated(blockId, corroborated, local, peerIds);
482
+ const rev = await this.restoreCorroborated(blockId, corroborated, baselineRev, peerIds);
391
483
 
392
484
  // Log the OUTCOME, not the attempt. Logging `synced` unconditionally reported hundreds of
393
485
  // phantom convergences per run and made a real replication defect invisible for two debugging
@@ -395,15 +487,15 @@ export class CoordinatorRepo implements IRepo {
395
487
  if (rev !== undefined) {
396
488
  log('cluster-fetch:synced', { blockId, rev });
397
489
  } else {
398
- log('cluster-fetch:not-restored', { blockId, localRev: local?.rev, clusterRev: corroborated.rev });
490
+ log('cluster-fetch:not-restored', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
399
491
  }
400
492
  // The block is marked seen either way — the cohort DID answer, so its freshness was checked,
401
493
  // which is what the read-repair window tracks. A failed convergence therefore waits out the
402
494
  // window before retrying.
403
495
  // NOTE: that damping covers only a block this node holds at an OLDER revision. A block entirely
404
496
  // 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
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
407
499
  // archive on every read of that block. Correct, and self-limiting once the cohort can agree; if
408
500
  // it ever shows as read amplification, gate the acquisition step (not the latest-query) on the
409
501
  // same window rather than widening `isMissing`.
@@ -412,7 +504,7 @@ export class CoordinatorRepo implements IRepo {
412
504
 
413
505
  /**
414
506
  * 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`.
507
+ * afterwards when that is an advance over `baselineRev`, else `undefined`.
416
508
  *
417
509
  * Two mechanisms, cheapest first:
418
510
  * 1. **Promote a local pending** — free, no network, and the only mechanism that existed before
@@ -435,11 +527,11 @@ export class CoordinatorRepo implements IRepo {
435
527
  private async restoreCorroborated(
436
528
  blockId: BlockId,
437
529
  corroborated: ActionRev,
438
- local: ActionRev | undefined,
530
+ baselineRev: number | undefined,
439
531
  cohortPeerIds: string[]
440
532
  ): Promise<number | undefined> {
441
533
  const promoted = await this.promoteCorroborated(blockId, corroborated);
442
- if (isAdvanceOver(promoted, local)) {
534
+ if (isAdvanceOver(promoted, baselineRev)) {
443
535
  return promoted;
444
536
  }
445
537
 
@@ -451,6 +543,12 @@ export class CoordinatorRepo implements IRepo {
451
543
  // inside the callback via `saveReplicatedBlock`, which takes the per-block commit latch —
452
544
  // safe to call from here because the read path holds no latch of its own (`StorageRepo.get`
453
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.
454
552
  await withDeadline(
455
553
  this.acquireBlockFromCohort(blockId, corroborated, cohortPeerIds),
456
554
  RECONCILE_TIMEOUT_MS,
@@ -462,7 +560,7 @@ export class CoordinatorRepo implements IRepo {
462
560
  return undefined;
463
561
  }
464
562
  const acquired = await this.readLocalRev(blockId);
465
- return isAdvanceOver(acquired, local) ? acquired : undefined;
563
+ return isAdvanceOver(acquired, baselineRev) ? acquired : undefined;
466
564
  }
467
565
 
468
566
  /**
@@ -470,41 +568,36 @@ export class CoordinatorRepo implements IRepo {
470
568
  * the repair. Returns the local revision afterwards.
471
569
  *
472
570
  * 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.
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.
476
576
  */
477
577
  private async promoteCorroborated(blockId: BlockId, corroborated: ActionRev): Promise<number | undefined> {
478
578
  try {
479
- return await this.readLocalRev(blockId, { committed: [corroborated], rev: corroborated.rev });
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;
480
585
  } catch (err) {
481
586
  log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: (err as Error).message });
482
587
  return undefined;
483
588
  }
484
589
  }
485
590
 
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> {
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) {
488
594
  const result = await this.storageRepo.get({ blockIds: [blockId], context });
489
- return result[blockId]?.state?.latest?.rev;
595
+ return result[blockId];
490
596
  }
491
597
 
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);
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;
508
601
  }
509
602
 
510
603
  /**
@@ -528,13 +621,6 @@ export class CoordinatorRepo implements IRepo {
528
621
  * `debt-read-repair-commit-cert-verification`.
529
622
  */
530
623
  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
624
  // Query peers in parallel for their latest revision (with 1s timeout per peer),
539
625
  // tagging each response with the peer that made it so votes stay distinct.
540
626
  const latestResults = await Promise.allSettled(
@@ -545,6 +631,11 @@ export class CoordinatorRepo implements IRepo {
545
631
  })
546
632
  );
547
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.
548
639
  const selfId = this.localPeerId?.toString();
549
640
  let local: ActionRev | undefined;
550
641
  const claims: RevClaim[] = [];
@@ -558,7 +649,7 @@ export class CoordinatorRepo implements IRepo {
558
649
  claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
559
650
  }
560
651
 
561
- const capacity = this.corroboratorCapacity(peerIds);
652
+ const capacity = corroboratorCapacity(peerIds.filter(id => id !== selfId).length, this.repairCorroborationClusterSize);
562
653
  const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
563
654
  if (!selected) {
564
655
  log('cluster-fetch:no-quorum', {
@@ -644,10 +735,61 @@ export class CoordinatorRepo implements IRepo {
644
735
  };
645
736
  } catch (error) {
646
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;
647
740
  throw error;
648
741
  }
649
742
  }
650
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
+
651
793
  async cancel(actionRef: ActionBlocks, options?: MessageOptions): Promise<void> {
652
794
  const blockIds = actionRef.blockIds;
653
795
  await this.verifyResponsibility(blockIds);
@@ -700,23 +842,32 @@ export class CoordinatorRepo implements IRepo {
700
842
  this.markBlocksSeen(blockIds);
701
843
  return { success: true };
702
844
  }
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.
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.
708
858
  try {
709
859
  const result = await this.storageRepo.commit(request, options);
710
- 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
+ }
711
867
  return result;
712
868
  } catch (err) {
713
869
  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 };
870
+ return this.tolerateLocalCommitDivergence(request, blockIds, (err as Error).message);
720
871
  }
721
872
  throw err;
722
873
  }
@@ -725,6 +876,17 @@ export class CoordinatorRepo implements IRepo {
725
876
  throw error;
726
877
  }
727
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
+ }
728
890
  }
729
891
 
730
892
  /** True if a simple majority of cluster peers signed an approving commit. */