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