@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.
- package/dist/src/cluster/block-transfer-service.d.ts +10 -0
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +2 -1
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/cluster-policy.d.ts +112 -0
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -0
- package/dist/src/cluster/cluster-policy.js +88 -0
- package/dist/src/cluster/cluster-policy.js.map +1 -0
- package/dist/src/cluster/cluster-repo.d.ts +35 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +88 -19
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/quorum-restore.d.ts +25 -3
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +27 -3
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts +10 -2
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +38 -18
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +8 -0
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/inbound-authorization.d.ts +6 -0
- package/dist/src/inbound-authorization.d.ts.map +1 -1
- package/dist/src/inbound-authorization.js +6 -0
- package/dist/src/inbound-authorization.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +14 -0
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +54 -4
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +22 -23
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +22 -19
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +21 -3
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +27 -5
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +60 -26
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +218 -65
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +9 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +56 -4
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +10 -0
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +14 -1
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/package.json +2 -2
- package/readme.md +20 -0
- package/src/cluster/block-transfer-service.ts +9 -1
- package/src/cluster/cluster-policy.ts +152 -0
- package/src/cluster/cluster-repo.ts +93 -22
- package/src/cluster/quorum-restore.ts +28 -3
- package/src/cluster/reconcile-block.ts +52 -19
- package/src/cluster/spread-on-churn.ts +8 -0
- package/src/inbound-authorization.ts +6 -0
- package/src/libp2p-key-network.ts +54 -4
- package/src/libp2p-node-base.ts +42 -43
- package/src/repo/cluster-coordinator.ts +30 -6
- package/src/repo/coordinator-repo.ts +235 -73
- package/src/storage/storage-repo.ts +58 -6
- 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
|
-
|
|
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
|
|
345
|
+
const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
|
|
326
346
|
.filter(([_, sig]) => sig.type === 'reject')
|
|
327
|
-
.map(([peerId, sig]) =>
|
|
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
|
|
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,
|
|
32
|
-
return typeof rev === 'number' && (
|
|
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) =>
|
|
98
|
-
cfg?:
|
|
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
|
-
/**
|
|
132
|
-
|
|
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) =>
|
|
176
|
+
readonly createClusterClient: (peerId: PeerId) => ICluster,
|
|
144
177
|
private readonly storageRepo: IRepo,
|
|
145
|
-
cfg?:
|
|
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
|
-
|
|
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.
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
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,
|
|
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:
|
|
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
|
|
406
|
-
//
|
|
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 `
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
474
|
-
*
|
|
475
|
-
*
|
|
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
|
-
|
|
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
|
|
487
|
-
|
|
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]
|
|
595
|
+
return result[blockId];
|
|
490
596
|
}
|
|
491
597
|
|
|
492
|
-
/**
|
|
493
|
-
|
|
494
|
-
|
|
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 =
|
|
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
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
707
|
-
//
|
|
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)
|
|
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
|
-
|
|
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. */
|