@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,14 +1,17 @@
1
- import { LruMap, blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
- import { ClusterCoordinator } from "./cluster-coordinator.js";
1
+ import { LruMap, blockIdsForTransforms, highestStaleAt, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
2
+ import { ClusterCoordinator, ValidatorRejectionError } from "./cluster-coordinator.js";
3
3
  import { peerIdFromString } from "@libp2p/peer-id";
4
4
  import { createLogger } from '../logger.js';
5
5
  import { PenaltyReason } from "../reputation/types.js";
6
- import { quorumSize, selectQuorumRev } from "../cluster/quorum-restore.js";
6
+ import { quorumSize, corroboratorCapacity, selectQuorumRev } from "../cluster/quorum-restore.js";
7
7
  import { RECONCILE_TIMEOUT_MS } from "../cluster/reconcile-block.js";
8
+ import { isMissingBaseRevisionFailure, MISSING_BASE_REVISION_REASON } from "../storage/storage-repo.js";
8
9
  const log = createLogger('coordinator-repo');
10
+ /** How long one cohort peer gets to answer the latest-revision consult before it counts as silent. */
11
+ const LATEST_QUERY_TIMEOUT_MS = 1000;
9
12
  /** True when a freshly-read local revision is strictly ahead of the baseline the repair started from. */
10
- function isAdvanceOver(rev, baseline) {
11
- return typeof rev === 'number' && (baseline === undefined || rev > baseline.rev);
13
+ function isAdvanceOver(rev, baselineRev) {
14
+ return typeof rev === 'number' && (baselineRev === undefined || rev > baselineRev);
12
15
  }
13
16
  /**
14
17
  * Reject if `promise` has not settled within `ms`. The timer is cleared on either outcome, so no
@@ -45,8 +48,13 @@ export class CoordinatorRepo {
45
48
  readRepairSampleRate;
46
49
  /** Simple-majority threshold from the consensus policy; drives the read-repair corroboration quorum. */
47
50
  simpleMajorityThreshold;
48
- /** Configured full cluster size; the operator's declaration of how many corroborators should exist. */
49
- clusterSize;
51
+ /**
52
+ * Yardstick the read-repair corroboration floor is measured against; the floor for
53
+ * {@link corroboratorCapacity}. Resolved by `resolveClusterPolicy` for a real node; falls back to
54
+ * `assumedClusterSize` and then `clusterSize` for direct constructors (see the constructor), so a
55
+ * caller that has adopted neither field keeps today's behavior exactly.
56
+ */
57
+ repairCorroborationClusterSize;
50
58
  /** Resolved super-majority threshold the coordinator commits on (mirrors the value handed to ClusterCoordinator). */
51
59
  superMajorityThreshold;
52
60
  reputation;
@@ -63,6 +71,7 @@ export class CoordinatorRepo {
63
71
  this.localPeerId = localPeerId;
64
72
  const policy = {
65
73
  clusterSize: cfg?.clusterSize ?? 10,
74
+ assumedClusterSize: cfg?.assumedClusterSize,
66
75
  superMajorityThreshold: cfg?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
67
76
  simpleMajorityThreshold: cfg?.simpleMajorityThreshold ?? 0.51,
68
77
  minAbsoluteClusterSize: cfg?.minAbsoluteClusterSize ?? 3,
@@ -87,7 +96,15 @@ export class CoordinatorRepo {
87
96
  this.readRepairSampleRate = policy.readRepairSampleRate;
88
97
  this.simpleMajorityThreshold = policy.simpleMajorityThreshold;
89
98
  this.superMajorityThreshold = policy.superMajorityThreshold;
90
- this.clusterSize = policy.clusterSize;
99
+ // Unlike the membership admission gate (which treats an absent assumedClusterSize as "unknown"
100
+ // and admits — refusing writes outright is unacceptable), this falls back to the replication
101
+ // factor and stays strict: the failure mode of getting this wrong is a block that goes
102
+ // unrepaired, degraded rather than dead, so there is no reason to relax it for a caller that
103
+ // has not adopted the new field. A real node is handed an explicit
104
+ // `repairCorroborationClusterSize` by `resolveClusterPolicy`; the `assumedClusterSize` middle
105
+ // term keeps direct constructors (embedders, existing tests) behaving as before.
106
+ this.repairCorroborationClusterSize =
107
+ cfg?.repairCorroborationClusterSize ?? policy.assumedClusterSize ?? policy.clusterSize;
91
108
  this.reputation = reputation;
92
109
  const localClusterRef = localCluster && localPeerId ? {
93
110
  update: localCluster.update.bind(localCluster),
@@ -154,6 +171,13 @@ export class CoordinatorRepo {
154
171
  }
155
172
  async get(blockGets, options) {
156
173
  // Soft proximity check — warn but still serve reads for graceful degradation
174
+ // NOTE: a soft-served read now also *acquires* the block durably (see restoreCorroborated), where
175
+ // before it could at most promote a pending this node already held. So a soft serve leaves behind
176
+ // a replica of a block this node is not responsible for, and nothing sweeps those: ring-shift
177
+ // sheds a keyspace RANGE, not "blocks outside my cohort". Fine while soft serves are what they
178
+ // are meant to be — a rare degradation during routing churn — since routing already placed this
179
+ // node near the block. If they ever become routine, gate acquisition (not the serve itself) on
180
+ // isResponsibleForBlock.
157
181
  for (const blockId of blockGets.blockIds) {
158
182
  if (!await this.isResponsibleForBlock(blockId)) {
159
183
  log('proximity:get-warning', { blockId, msg: 'serving read for non-responsible block' });
@@ -165,14 +189,22 @@ export class CoordinatorRepo {
165
189
  // (a) Missing — block isn't present locally at all (legacy behavior).
166
190
  // (b) Stale-by-policy — block is present but read-repair policy says verify.
167
191
  // Skip cluster fetch if this is already a sync request (to prevent recursive queries).
192
+ // A sync read is also never marked `unavailable` here — the consult it skips is the
193
+ // one whose failure the flag reports, and flagging would feed the recursion this
194
+ // bypass exists to prevent. (Storage-level 'unmaterializable' flags still pass
195
+ // through untouched; they report local state, not the consult.)
168
196
  const skipClusterFetch = options?.skipClusterFetch;
169
197
  // NOTE: NetworkTransactor.get treats an authoritative "absent" ({ state: {} })
170
198
  // as final and no longer retries it (ticket txn-perf-authoritative-notfound),
171
- // relying on this cluster reconciliation to have already run. If a coordinator
172
- // is ever configured WITHOUT clusterLatestCallback, a missing block is answered
173
- // from local state alone with no transactor-level retry to compensate. That is
174
- // fine today (such a coordinator has no cluster to reconcile against), but keep
175
- // this coupling in mind if a partial-cluster read path is added.
199
+ // relying on this cluster reconciliation to have already run. When the consult
200
+ // FAILS outright or runs while part of the cohort stays SILENT and the block
201
+ // stays missing the entry is flagged `unavailable: 'peers-unreachable'` below,
202
+ // which re-enables the transactor-level retry against a different peer. If a
203
+ // coordinator is configured WITHOUT clusterLatestCallback, there is no cohort to
204
+ // consult and the local answer IS the whole truth — it stays authoritative, with
205
+ // no flag and no transactor-level retry to compensate. That is fine (such a
206
+ // coordinator has no cluster to reconcile against), but keep this coupling in
207
+ // mind if a partial-cluster read path is added.
176
208
  if (this.clusterLatestCallback && !skipClusterFetch) {
177
209
  for (const blockId of blockGets.blockIds) {
178
210
  const localEntry = localResult[blockId];
@@ -190,7 +222,7 @@ export class CoordinatorRepo {
190
222
  });
191
223
  }
192
224
  try {
193
- await this.fetchBlockFromCluster(blockId, blockGets.context);
225
+ const { inconclusive } = await this.fetchBlockFromCluster(blockId, blockGets.context, localRev);
194
226
  const refreshed = await this.storageRepo.get({ blockIds: [blockId], context: blockGets.context }, options);
195
227
  const newRev = refreshed[blockId]?.state?.latest?.rev;
196
228
  if (refreshed[blockId]) {
@@ -204,14 +236,43 @@ export class CoordinatorRepo {
204
236
  log('cluster-tx:read-repair-noop', { blockId });
205
237
  }
206
238
  }
239
+ // The consult ran but came back INCONCLUSIVE (a silent cohort peer, or a
240
+ // corroborated revision this node could not acquire — see
241
+ // fetchBlockFromCluster). Either way the reader cannot rule the block out,
242
+ // so a still-missing block must not pose as an authoritative absent. When
243
+ // the whole cohort answers "holds nothing" the absent stays authoritative —
244
+ // the new-collection probe against a healthy cohort stays one round-trip.
245
+ if (isMissing && inconclusive) {
246
+ this.flagUnconfirmedAbsence(localResult, blockId);
247
+ }
207
248
  }
208
249
  catch (err) {
209
250
  log('cluster-fetch:error', { blockId, error: err.message });
251
+ // The consult that was supposed to make this answer trustworthy did not run.
252
+ if (isMissing) {
253
+ this.flagUnconfirmedAbsence(localResult, blockId);
254
+ }
210
255
  }
211
256
  }
212
257
  }
213
258
  return localResult;
214
259
  }
260
+ /**
261
+ * Downgrade an absence the coordinator could not confirm to `unavailable: 'peers-unreachable'` —
262
+ * the flag `NetworkTransactor.get` retries against another peer instead of taking as final.
263
+ *
264
+ * No-op once the entry carries a real answer (the consult restored the block) or a sharper flag
265
+ * (storage's `'unmaterializable'`), so callers only need to establish that the answer is a guess.
266
+ */
267
+ flagUnconfirmedAbsence(results, blockId) {
268
+ const entry = results[blockId];
269
+ if (!entry) {
270
+ results[blockId] = { state: {}, unavailable: 'peers-unreachable' };
271
+ }
272
+ else if (!entry.state?.latest && entry.unavailable === undefined) {
273
+ entry.unavailable = 'peers-unreachable';
274
+ }
275
+ }
215
276
  /** Decide whether the read-repair policy wants us to consult the cluster for a present-but-possibly-stale block. */
216
277
  shouldReadRepair(blockId) {
217
278
  switch (this.readRepairMode) {
@@ -249,14 +310,27 @@ export class CoordinatorRepo {
249
310
  setLastSeenForTest(blockId, ts) {
250
311
  this.lastSeenCommitMs.set(blockId, ts);
251
312
  }
252
- async fetchBlockFromCluster(blockId, context) {
313
+ /**
314
+ * One repair pass for a block: ask the cohort what it holds, and converge onto that if it is
315
+ * ahead of `localRev` — the revision the caller's read already loaded, and the baseline every
316
+ * decision below is measured against.
317
+ *
318
+ * Returns the one thing `get` needs beyond the storage side effects: whether the pass was
319
+ * INCONCLUSIVE — it neither confirmed the cohort holds nothing nor left this node holding the
320
+ * block. Two ways that happens: a cohort peer other than this node stayed SILENT (rejected
321
+ * callback or per-peer deadline), or a revision WAS corroborated and the convergence onto it
322
+ * failed. In both, `get` has learned that its local absence may be wrong, so it must not report
323
+ * a still-missing block as an authoritative absent. Paths that consult nobody (no cohort,
324
+ * solo-self) are conclusive: there, the local answer genuinely is the whole truth.
325
+ */
326
+ async fetchBlockFromCluster(blockId, context, localRev) {
253
327
  if (!this.clusterLatestCallback)
254
- return;
328
+ return { inconclusive: false };
255
329
  const blockIdBytes = new TextEncoder().encode(blockId);
256
330
  const peers = await this.keyNetwork.findCluster(blockIdBytes);
257
331
  const peerIds = peers ? Object.keys(peers) : [];
258
332
  if (peerIds.length === 0)
259
- return;
333
+ return { inconclusive: false };
260
334
  // Solo-cluster short-circuit: the only responsible peer is us. There is no
261
335
  // remote to sync from, so skip the callback entirely. Querying ourselves
262
336
  // would dial self via SyncClient — pointless at best, and on nodes without
@@ -265,24 +339,41 @@ export class CoordinatorRepo {
265
339
  && this.localPeerId
266
340
  && peerIds[0] === this.localPeerId.toString()) {
267
341
  log('cluster-fetch:solo-self-skip', { blockId });
268
- return;
342
+ return { inconclusive: false };
269
343
  }
270
- const { corroborated, local } = await this.queryClusterForLatest(peerIds, blockId, context);
344
+ const { corroborated, local, silent } = await this.queryClusterForLatest(peerIds, blockId, context);
345
+ // Any silence flags the WHOLE consult, not a fraction of it (fail-closed): one silent
346
+ // peer could be the sole holder, and the cost — an extra transactor-level retry against
347
+ // another coordinator — is paid only while a peer is actually unreachable.
348
+ const cohortSilent = silent.length > 0;
271
349
  // Nothing corroborated: keep local data AND stay eligible for repair — marking the
272
350
  // block seen here would suppress the next attempt for the whole read-repair window.
273
351
  if (!corroborated)
274
- return;
352
+ return { inconclusive: cohortSilent };
353
+ // The self answer is the sharper baseline (same storage, same context, read alongside the
354
+ // cohort's), but it exists only when `findCluster` returned this node. A soft serve for a
355
+ // block this node is no longer responsible for is absent from its own cohort view, so fall
356
+ // back to the revision the caller's read already loaded. Without the fallback both decisions
357
+ // below degrade to "any local revision is an advance", which restores backwards and reports
358
+ // a sync at the revision the pass started from.
359
+ const baselineRev = local?.rev ?? localRev;
275
360
  // Never restore backwards. With this node's own claim excluded from the quorum, a
276
361
  // cohort that lags behind the reader corroborates an OLDER revision; adopting it
277
362
  // would be a regression, and logging it as a sync would be a lie. The cohort did
278
363
  // answer, so the block is verified fresh — mark it seen.
279
- if (local && corroborated.rev <= local.rev) {
280
- log('cluster-fetch:local-current', { blockId, localRev: local.rev, clusterRev: corroborated.rev });
364
+ // NOTE: in a cohort of two, that sole peer is the only corroborator, so a lying one can park
365
+ // the reader here corroborating the revision it already holds — and re-arm the lazy window
366
+ // on every pass, hiding a real divergence. Bounded by `readRepairWindowMs` (10s default) and
367
+ // no worse than the peer simply staying silent. If two-member cohorts become a supported
368
+ // production topology rather than a dev convenience, stop re-arming the window on a
369
+ // corroboration that came from a single voter.
370
+ if (baselineRev !== undefined && corroborated.rev <= baselineRev) {
371
+ log('cluster-fetch:local-current', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
281
372
  this.markBlocksSeen([blockId]);
282
- return;
373
+ return { inconclusive: cohortSilent };
283
374
  }
284
375
  // Corroborated revision is ahead of ours — converge onto it.
285
- const rev = await this.restoreCorroborated(blockId, corroborated, local, peerIds);
376
+ const rev = await this.restoreCorroborated(blockId, corroborated, baselineRev, peerIds);
286
377
  // Log the OUTCOME, not the attempt. Logging `synced` unconditionally reported hundreds of
287
378
  // phantom convergences per run and made a real replication defect invisible for two debugging
288
379
  // sessions.
@@ -290,23 +381,28 @@ export class CoordinatorRepo {
290
381
  log('cluster-fetch:synced', { blockId, rev });
291
382
  }
292
383
  else {
293
- log('cluster-fetch:not-restored', { blockId, localRev: local?.rev, clusterRev: corroborated.rev });
384
+ log('cluster-fetch:not-restored', { blockId, localRev: baselineRev, clusterRev: corroborated.rev });
294
385
  }
386
+ // A corroborated revision this node failed to converge onto is inconclusive in its own right,
387
+ // even with the whole cohort answering: the reader has just been TOLD the block exists, so
388
+ // reporting it absent would be a lie of the same kind a silent peer causes (see `get`).
389
+ const inconclusive = cohortSilent || rev === undefined;
295
390
  // The block is marked seen either way — the cohort DID answer, so its freshness was checked,
296
391
  // which is what the read-repair window tracks. A failed convergence therefore waits out the
297
392
  // window before retrying.
298
393
  // NOTE: that damping covers only a block this node holds at an OLDER revision. A block entirely
299
394
  // missing locally never consults the window (`get` triggers on `isMissing` before
300
- // `shouldReadRepair`), so a persistently failing acquisition — e.g. a two-node deployment left
301
- // at the default `clusterSize: 10`, where the content quorum can never be met — re-fetches an
395
+ // `shouldReadRepair`), so a persistently failing acquisition — e.g. a two-node deployment that
396
+ // never set `assumedClusterSize`, where the content quorum can never be met — re-fetches an
302
397
  // archive on every read of that block. Correct, and self-limiting once the cohort can agree; if
303
398
  // it ever shows as read amplification, gate the acquisition step (not the latest-query) on the
304
399
  // same window rather than widening `isMissing`.
305
400
  this.markBlocksSeen([blockId]);
401
+ return { inconclusive };
306
402
  }
307
403
  /**
308
404
  * Bring this node up to the cohort-corroborated `corroborated`, returning the revision it holds
309
- * afterwards when that is an advance over `local`, else `undefined`.
405
+ * afterwards when that is an advance over `baselineRev`, else `undefined`.
310
406
  *
311
407
  * Two mechanisms, cheapest first:
312
408
  * 1. **Promote a local pending** — free, no network, and the only mechanism that existed before
@@ -326,9 +422,9 @@ export class CoordinatorRepo {
326
422
  * Cohort peer ids are passed straight through: the callback filters self out and caps its own
327
423
  * corroboration quorum by how many peers could answer at all.
328
424
  */
329
- async restoreCorroborated(blockId, corroborated, local, cohortPeerIds) {
425
+ async restoreCorroborated(blockId, corroborated, baselineRev, cohortPeerIds) {
330
426
  const promoted = await this.promoteCorroborated(blockId, corroborated);
331
- if (isAdvanceOver(promoted, local)) {
427
+ if (isAdvanceOver(promoted, baselineRev)) {
332
428
  return promoted;
333
429
  }
334
430
  if (!this.acquireBlockFromCohort) {
@@ -339,6 +435,12 @@ export class CoordinatorRepo {
339
435
  // inside the callback via `saveReplicatedBlock`, which takes the per-block commit latch —
340
436
  // safe to call from here because the read path holds no latch of its own (`StorageRepo.get`
341
437
  // acquires and releases it around the promotion above, and nothing wraps this method).
438
+ // NOTE: `get` walks its block ids sequentially, so the bound is per block, not per call — a
439
+ // multi-block read that is missing N blocks against a wholly stalled cohort waits N × this.
440
+ // Acceptable today (the underlying per-peer archive fetch is itself 1s-bounded and runs the
441
+ // cohort in parallel, so the 5s is a stall ceiling, not a typical cost). If a cold reader
442
+ // batching a wide read ever times out above this layer, repair the block ids concurrently
443
+ // rather than shortening the bound.
342
444
  await withDeadline(this.acquireBlockFromCohort(blockId, corroborated, cohortPeerIds), RECONCILE_TIMEOUT_MS, `block acquisition for ${blockId}`);
343
445
  }
344
446
  catch (err) {
@@ -347,47 +449,42 @@ export class CoordinatorRepo {
347
449
  return undefined;
348
450
  }
349
451
  const acquired = await this.readLocalRev(blockId);
350
- return isAdvanceOver(acquired, local) ? acquired : undefined;
452
+ return isAdvanceOver(acquired, baselineRev) ? acquired : undefined;
351
453
  }
352
454
  /**
353
455
  * Promote a corroborated action this node already holds as a local pending — the no-network half of
354
456
  * the repair. Returns the local revision afterwards.
355
457
  *
356
458
  * A pending-only block (metadata seeded by `savePendingTransaction`, no committed revision) asked
357
- * for a forward revision throws out of `BlockStorage.ensureRevision` when no restore can supply it.
358
- * On THIS path that is an absence, not a read failure — acquisition is precisely the mechanism that
359
- * can supply it so the throw is logged and swallowed rather than short-circuiting the caller.
459
+ * for a forward revision no promotion can reach used to throw out of `BlockStorage.ensureRevision`;
460
+ * `StorageRepo.get` now reports it as an entry flagged `unavailable` instead (ticket
461
+ * repo-reports-unavailable-vs-absent). On THIS path either shape is an absence, not a read failure
462
+ * acquisition is precisely the mechanism that can supply the revision — so both are logged as
463
+ * `promote-unavailable` and stepped over rather than short-circuiting the caller.
360
464
  */
361
465
  async promoteCorroborated(blockId, corroborated) {
362
466
  try {
363
- return await this.readLocalRev(blockId, { committed: [corroborated], rev: corroborated.rev });
467
+ const entry = await this.readLocalEntry(blockId, { committed: [corroborated], rev: corroborated.rev });
468
+ if (entry?.unavailable !== undefined) {
469
+ log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: entry.unavailable });
470
+ return undefined;
471
+ }
472
+ return entry?.state?.latest?.rev;
364
473
  }
365
474
  catch (err) {
366
475
  log('cluster-fetch:promote-unavailable', { blockId, rev: corroborated.rev, error: err.message });
367
476
  return undefined;
368
477
  }
369
478
  }
370
- /** This node's own `latest.rev` for a block, optionally driving a promotion context through the read. */
371
- async readLocalRev(blockId, context) {
479
+ /** This node's own answer for a block, optionally driving a promotion context through the read.
480
+ * Callers that care whether the answer is authoritative inspect `entry.unavailable`. */
481
+ async readLocalEntry(blockId, context) {
372
482
  const result = await this.storageRepo.get({ blockIds: [blockId], context });
373
- return result[blockId]?.state?.latest?.rev;
483
+ return result[blockId];
374
484
  }
375
- /**
376
- * How many peers other than this node could corroborate a claim about a block, given a
377
- * cohort view of `peerIds`. Deliberately the MAX of what we observe and what the
378
- * configured cluster size implies: the corroboration floor may only be relaxed for a
379
- * cohort that is genuinely small, never for one that merely *looks* small. `findCluster`
380
- * results are unauthenticated, so a partition — or an attacker with routing influence —
381
- * can shrink this node's view to itself plus one peer; measuring against the configured
382
- * size keeps that shrunken view from talking the requirement down to a single voter.
383
- * The escape hatch for a real two-node deployment is therefore to configure
384
- * `clusterSize: 2`, an explicit operator declaration, mirroring how
385
- * `allowUnvalidatedSmallCluster` gates the membership admission floor.
386
- */
387
- corroboratorCapacity(peerIds) {
388
- const selfId = this.localPeerId?.toString();
389
- const observed = peerIds.filter(id => id !== selfId).length;
390
- return Math.max(observed, this.clusterSize - 1);
485
+ /** This node's own `latest.rev` for a block, optionally driving a promotion context through the read. */
486
+ async readLocalRev(blockId, context) {
487
+ return (await this.readLocalEntry(blockId, context))?.state?.latest?.rev;
391
488
  }
392
489
  /**
393
490
  * Query cluster peers for their latest revision and return the highest revision
@@ -410,32 +507,54 @@ export class CoordinatorRepo {
410
507
  * `debt-read-repair-commit-cert-verification`.
411
508
  */
412
509
  async queryClusterForLatest(peerIds, blockId, context) {
413
- // Add timeout wrapper to prevent hanging on unresponsive peers
414
- const withTimeout = (promise, timeoutMs) => Promise.race([
415
- promise,
416
- new Promise(resolve => setTimeout(() => resolve(undefined), timeoutMs))
417
- ]);
418
- // Query peers in parallel for their latest revision (with 1s timeout per peer),
419
- // tagging each response with the peer that made it so votes stay distinct.
510
+ // Query peers in parallel for their latest revision. Each query is DEADLINED (rejects), not
511
+ // raced-to-undefined: a peer that blows the deadline lands in the silent set below exactly
512
+ // like a dial failure, because a slow peer and a peer claiming "I hold nothing" must produce
513
+ // different answers (ticket cluster-read-consult-cannot-report-unreachable).
514
+ // NOTE: LATEST_QUERY_TIMEOUT_MS is a LAN-shaped budget. A cohort whose round trip honestly
515
+ // exceeds it now reads as permanently silent, which is safe (the read is flagged, not
516
+ // mis-reported) but makes every miss cost a transactor-level retry. If a WAN deployment shows
517
+ // steady `cluster-fetch:peers-silent` against healthy peers, raise this rather than softening
518
+ // the deadline back into an absent claim.
420
519
  const latestResults = await Promise.allSettled(peerIds.map(async (peerIdStr) => {
421
520
  const peerId = peerIdFromString(peerIdStr);
422
- const value = await withTimeout(this.clusterLatestCallback(peerId, blockId, context), 1000);
423
- return { peerIdStr, value };
521
+ return await withDeadline(this.clusterLatestCallback(peerId, blockId, context), LATEST_QUERY_TIMEOUT_MS, `latest query to ${peerIdStr}`);
424
522
  }));
523
+ // NOTE: self-exclusion is keyed on `localPeerId`, which is optional for the single-node/test
524
+ // construction this class has always tolerated. Left unset, this node's own answer is counted
525
+ // as a peer claim again. Harmless today — the self answer can only ever corroborate the
526
+ // revision already held, so the pass declines as `local-current` — but if a future caller can
527
+ // make self report something the reader does not hold, make `localPeerId` required instead.
425
528
  const selfId = this.localPeerId?.toString();
426
529
  let local;
427
530
  const claims = [];
428
- for (const result of latestResults) {
429
- if (result.status !== 'fulfilled' || !result.value.value)
531
+ const silent = [];
532
+ // `allSettled` preserves input order, so results correlate to `peerIds` by index — a
533
+ // rejected entry carries no payload of its own, and its peer id is what `silent` records.
534
+ for (let i = 0; i < latestResults.length; i++) {
535
+ const result = latestResults[i];
536
+ const peerIdStr = peerIds[i];
537
+ if (result.status !== 'fulfilled') {
538
+ // Silence: the callback rejected or the deadline expired. Never a claim. Self is
539
+ // excluded — its short-circuit reads local storage, and a local read error is not a
540
+ // cohort peer being unreachable.
541
+ if (peerIdStr !== selfId)
542
+ silent.push(peerIdStr);
430
543
  continue;
431
- const { peerIdStr, value } = result.value;
544
+ }
545
+ const value = result.value;
432
546
  if (peerIdStr === selfId) {
433
547
  local = value;
434
548
  continue;
435
549
  }
550
+ if (!value)
551
+ continue; // responded, holds nothing — an absent claim, not silence
436
552
  claims.push({ peerId: peerIdStr, rev: value.rev, actionId: value.actionId });
437
553
  }
438
- const capacity = this.corroboratorCapacity(peerIds);
554
+ if (silent.length > 0) {
555
+ log('cluster-fetch:peers-silent', { blockId, silent: silent.length, consulted: peerIds.length });
556
+ }
557
+ const capacity = corroboratorCapacity(peerIds.filter(id => id !== selfId).length, this.repairCorroborationClusterSize);
439
558
  const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
440
559
  if (!selected) {
441
560
  log('cluster-fetch:no-quorum', {
@@ -443,13 +562,13 @@ export class CoordinatorRepo {
443
562
  responders: claims.length,
444
563
  required: quorumSize(claims.length, this.simpleMajorityThreshold, capacity)
445
564
  });
446
- return { local };
565
+ return { local, silent };
447
566
  }
448
567
  // Best-effort: penalize peers whose claim contradicts the corroborated pair
449
568
  // (an inflated rev the quorum outvoted, or conflicting content at the agreed
450
569
  // rev). A lower rev is just lag, never penalized. Never let this throw.
451
570
  this.penalizeContradictingRevClaims(claims, selected, blockId);
452
- return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local };
571
+ return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local, silent };
453
572
  }
454
573
  /**
455
574
  * Report peers whose reported latest contradicts the quorum-corroborated pair. Best-effort.
@@ -517,9 +636,78 @@ export class CoordinatorRepo {
517
636
  }
518
637
  catch (error) {
519
638
  log('coordinator-repo:pend-error', { actionId: request.actionId, error: error.message });
639
+ const stale = await this.classifyStaleRejection(error, request, allBlockIds);
640
+ if (stale)
641
+ return stale;
520
642
  throw error;
521
643
  }
522
644
  }
645
+ /**
646
+ * Decide whether a cluster validator rejection was an optimistic-concurrency loss — the block
647
+ * already advanced past the requested revision — rather than a genuine validation fault.
648
+ * A confirmed loss returns a {@link StaleFailure} carrying `conflict: true` so the caller
649
+ * receives a non-success *response* that says plainly it is a lost race: network-transactor's
650
+ * pend then takes its stale branch and both writers (`Collection.sync`, and the coordinator's
651
+ * multi-collection pendPhase via `isConflictFailure`) retry, instead of a thrown error escaping
652
+ * mid-batch (which splits multi-tree commits — see PartialCommitError).
653
+ *
654
+ * The failure carries no `missing` list: confirmation is a local re-read that reveals the
655
+ * revision is taken but not which actions took it, and no consumer rebases from `missing`
656
+ * anyway (it is only counted or logged). `conflict` conveys retryability directly instead.
657
+ *
658
+ * Confirmation is purely local: re-read the affected blocks from our own storage and require
659
+ * `latest.rev >= request.rev`. The signed reject-reason text is never consulted — it is
660
+ * free-form wire-visible prose and must not become control flow. Anything unconfirmed
661
+ * (including read errors during confirmation) stays a throw, preserving fail-fast for
662
+ * genuine validation faults.
663
+ */
664
+ async classifyStaleRejection(error, request, blockIds) {
665
+ const requestedRev = request.rev;
666
+ if (!(error instanceof ValidatorRejectionError) || requestedRev === undefined)
667
+ return undefined;
668
+ let results;
669
+ try {
670
+ results = await this.storageRepo.get({ blockIds });
671
+ }
672
+ catch (readError) {
673
+ log('coordinator-repo:pend-stale-classify-read-error', {
674
+ actionId: request.actionId,
675
+ error: readError.message
676
+ });
677
+ return undefined;
678
+ }
679
+ // Scan EVERY block rather than stopping at the first confirmation: several of the request's
680
+ // blocks can be past the requested revision at different revisions, and it is the highest
681
+ // that the loser's next request has to clear (see `highestStaleAt`). Both the reported
682
+ // number and the reason prose name that block, so they never disagree.
683
+ const staleAt = highestStaleAt(blockIds.map(blockId => {
684
+ const latest = results[blockId]?.state.latest;
685
+ return latest && latest.rev >= requestedRev ? { blockId, rev: latest.rev } : undefined;
686
+ }));
687
+ if (staleAt) {
688
+ log('coordinator-repo:pend-stale-classified', {
689
+ actionId: request.actionId,
690
+ blockId: staleAt.blockId,
691
+ latestRev: staleAt.rev,
692
+ requestedRev
693
+ });
694
+ return {
695
+ success: false,
696
+ conflict: true,
697
+ reason: `stale revision: block ${staleAt.blockId} at rev ${staleAt.rev}, requested rev ${requestedRev}`,
698
+ // The same fact as the reason prose, but as data. This is the ONLY place a losing
699
+ // writer can learn the revision it lost to, since this failure deliberately carries
700
+ // no `missing`. Confirmed-local: read out of our own storage just above.
701
+ staleAt
702
+ };
703
+ }
704
+ // NOTE: conservative — when only remote members saw the newer revision (local storage still
705
+ // behind), staleness can't be confirmed locally and the rejection stays a throw. If that
706
+ // shows up in practice, extend confirmation with a quorum read; never trust the reject text.
707
+ // `staleAt` is absent on this path for the same reason, and deliberately so — there is no
708
+ // confirmed number to report, and the field's contract forbids inferring one from that text.
709
+ return undefined;
710
+ }
523
711
  async cancel(actionRef, options) {
524
712
  const blockIds = actionRef.blockIds;
525
713
  await this.verifyResponsibility(blockIds);
@@ -564,25 +752,33 @@ export class CoordinatorRepo {
564
752
  this.markBlocksSeen(blockIds);
565
753
  return { success: true };
566
754
  }
567
- // Local cluster didn't execute during consensus. Attempt a local commit,
568
- // but tolerate failure (e.g., "pending action not found") when the cluster
569
- // already reached consensus this coordinator was likely picked for commit
570
- // after missing the pend phase (unreachable during pend, fresh join, etc.).
571
- // The cluster's majority is authoritative; this peer will catch up via sync.
755
+ // Local cluster didn't execute during consensus. Attempt a local commit, but tolerate
756
+ // local divergence when the cluster already reached consensus this coordinator was
757
+ // likely picked for commit after missing the pend phase (unreachable during pend, fresh
758
+ // join, etc.). The cluster's majority is authoritative; this peer catches up via sync.
759
+ //
760
+ // Divergence reaches us in BOTH shapes and both must be tolerated identically:
761
+ // - a THROW ("Pending action … not found"), when we never saw the pend;
762
+ // - a RETURNED `success:false` carrying `missing-base-revision`, when we saw the pend
763
+ // but not the revision that created the block (see StorageRepo.internalCommit).
764
+ // Only the throw was tolerated before the refusal existed. Reporting the refusal to the
765
+ // caller instead would surface a committed transaction as a stale loss: db-core's
766
+ // commitPhase treats any returned `success:false` as a permanent stale failure, so the
767
+ // client would retry an action the cluster already landed until it exhausted its budget.
572
768
  try {
573
769
  const result = await this.storageRepo.commit(request, options);
574
- if (result.success)
770
+ if (result.success) {
575
771
  this.markBlocksSeen(blockIds);
772
+ return result;
773
+ }
774
+ if (isMissingBaseRevisionFailure(result) && clusterReachedCommitConsensus(record)) {
775
+ return this.tolerateLocalCommitDivergence(request, blockIds, result.reason ?? MISSING_BASE_REVISION_REASON);
776
+ }
576
777
  return result;
577
778
  }
578
779
  catch (err) {
579
780
  if (clusterReachedCommitConsensus(record)) {
580
- log('coordinator-repo:commit-local-failed-cluster-succeeded', {
581
- actionId: request.actionId,
582
- error: err.message
583
- });
584
- this.markBlocksSeen(blockIds);
585
- return { success: true };
781
+ return this.tolerateLocalCommitDivergence(request, blockIds, err.message);
586
782
  }
587
783
  throw err;
588
784
  }
@@ -592,6 +788,16 @@ export class CoordinatorRepo {
592
788
  throw error;
593
789
  }
594
790
  }
791
+ /**
792
+ * Report success for a commit the cluster carried but this peer could not apply locally. The
793
+ * blocks are marked seen so the read path treats them as freshness-checked; convergence comes
794
+ * from replication (cohort reconcile, or read-driven acquisition), not from replay here.
795
+ */
796
+ tolerateLocalCommitDivergence(request, blockIds, detail) {
797
+ log('coordinator-repo:commit-local-failed-cluster-succeeded', { actionId: request.actionId, error: detail });
798
+ this.markBlocksSeen(blockIds);
799
+ return { success: true };
800
+ }
595
801
  }
596
802
  /** True if a simple majority of cluster peers signed an approving commit. */
597
803
  function clusterReachedCommitConsensus(record) {