@optimystic/db-p2p 0.24.2 → 0.25.1
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/{readme.md → README.md} +688 -668
- package/dist/src/cluster/block-transfer-service.d.ts +133 -10
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +155 -9
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +67 -7
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +127 -24
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/certified-claims.d.ts +170 -0
- package/dist/src/cluster/certified-claims.d.ts.map +1 -0
- package/dist/src/cluster/certified-claims.js +200 -0
- package/dist/src/cluster/certified-claims.js.map +1 -0
- package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
- package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
- package/dist/src/cluster/client-signature-verifier.js +33 -0
- package/dist/src/cluster/client-signature-verifier.js.map +1 -0
- package/dist/src/cluster/cluster-policy.d.ts +9 -3
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js +111 -27
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +140 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +485 -29
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.d.ts +134 -0
- package/dist/src/cluster/commit-proof.d.ts.map +1 -0
- package/dist/src/cluster/commit-proof.js +263 -0
- package/dist/src/cluster/commit-proof.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +95 -8
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +113 -14
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +207 -5
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts +27 -7
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +179 -38
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/service.d.ts +13 -4
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +17 -18
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +8 -2
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +7 -7
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +8 -7
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +7 -6
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +14 -41
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/cascade.d.ts +7 -8
- package/dist/src/dispute/cascade.d.ts.map +1 -1
- package/dist/src/dispute/cascade.js +24 -7
- package/dist/src/dispute/cascade.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +5 -0
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +13 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +33 -15
- package/dist/src/dispute/invalidation.d.ts.map +1 -1
- package/dist/src/dispute/invalidation.js +127 -34
- package/dist/src/dispute/invalidation.js.map +1 -1
- package/dist/src/dispute/service.d.ts.map +1 -1
- package/dist/src/dispute/service.js +2 -1
- package/dist/src/dispute/service.js.map +1 -1
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +14 -10
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +28 -58
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +38 -7
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +148 -63
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/network/open-protocol-stream.d.ts +56 -0
- package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
- package/dist/src/network/open-protocol-stream.js +60 -0
- package/dist/src/network/open-protocol-stream.js.map +1 -0
- package/dist/src/network/register-protocol-handler.d.ts +66 -0
- package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
- package/dist/src/network/register-protocol-handler.js +51 -0
- package/dist/src/network/register-protocol-handler.js.map +1 -0
- package/dist/src/peer-address-book.d.ts +60 -2
- package/dist/src/peer-address-book.d.ts.map +1 -1
- package/dist/src/peer-address-book.js +89 -1
- package/dist/src/peer-address-book.js.map +1 -1
- package/dist/src/pend-validation.d.ts +65 -0
- package/dist/src/pend-validation.d.ts.map +1 -0
- package/dist/src/pend-validation.js +69 -0
- package/dist/src/pend-validation.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
- package/dist/src/reactivity/notify-transport.js +2 -1
- package/dist/src/reactivity/notify-transport.js.map +1 -1
- package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
- package/dist/src/reactivity/push-state-gossip.js +2 -1
- package/dist/src/reactivity/push-state-gossip.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +32 -6
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +44 -10
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +181 -25
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +650 -62
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/served-repo-proxy.d.ts +30 -0
- package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
- package/dist/src/repo/served-repo-proxy.js +50 -0
- package/dist/src/repo/served-repo-proxy.js.map +1 -0
- package/dist/src/repo/service.d.ts +16 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +18 -16
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/types.d.ts +3 -1
- package/dist/src/reputation/types.d.ts.map +1 -1
- package/dist/src/reputation/types.js +3 -1
- package/dist/src/reputation/types.js.map +1 -1
- package/dist/src/rn.d.ts +24 -2
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +33 -4
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-archive.d.ts +153 -0
- package/dist/src/storage/block-archive.d.ts.map +1 -0
- package/dist/src/storage/block-archive.js +194 -0
- package/dist/src/storage/block-archive.js.map +1 -0
- package/dist/src/storage/block-latch.d.ts +92 -0
- package/dist/src/storage/block-latch.d.ts.map +1 -0
- package/dist/src/storage/block-latch.js +132 -0
- package/dist/src/storage/block-latch.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +169 -12
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +415 -152
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +5 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +16 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +14 -4
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +39 -5
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +70 -19
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +21 -1
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +38 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +14 -8
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +29 -9
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/memory-store-driver.d.ts +4 -1
- package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
- package/dist/src/storage/memory-store-driver.js +22 -2
- package/dist/src/storage/memory-store-driver.js.map +1 -1
- package/dist/src/storage/raw-store-codec.js +2 -2
- package/dist/src/storage/raw-store-driver.d.ts +44 -3
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +60 -3
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +68 -4
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +204 -26
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +422 -119
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/store-identity.d.ts +21 -0
- package/dist/src/storage/store-identity.d.ts.map +1 -0
- package/dist/src/storage/store-identity.js +33 -0
- package/dist/src/storage/store-identity.js.map +1 -0
- package/dist/src/storage/struct.d.ts +15 -0
- package/dist/src/storage/struct.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +92 -0
- package/dist/src/storage/with-read-cache.d.ts.map +1 -0
- package/dist/src/storage/with-read-cache.js +132 -0
- package/dist/src/storage/with-read-cache.js.map +1 -0
- package/dist/src/sync/service.d.ts +13 -3
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +7 -30
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +75 -2
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +193 -81
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +97 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +5 -6
- package/src/cluster/block-transfer-service.ts +528 -312
- package/src/cluster/block-transfer.ts +547 -424
- package/src/cluster/certified-claims.ts +319 -0
- package/src/cluster/client-signature-verifier.ts +34 -0
- package/src/cluster/cluster-policy.ts +294 -203
- package/src/cluster/cluster-repo.ts +2501 -2034
- package/src/cluster/commit-proof.ts +344 -0
- package/src/cluster/quorum-restore.ts +348 -223
- package/src/cluster/rebalance-monitor.ts +539 -254
- package/src/cluster/reconcile-block.ts +404 -203
- package/src/cluster/service.ts +23 -20
- package/src/cluster/spread-on-churn.ts +352 -346
- package/src/cohort-topic/host.ts +8 -7
- package/src/cohort-topic/stream-util.ts +14 -43
- package/src/dispute/cascade.ts +31 -15
- package/src/dispute/dispute-service.ts +706 -696
- package/src/dispute/invalidation.ts +162 -52
- package/src/dispute/service.ts +126 -125
- package/src/index.ts +7 -0
- package/src/libp2p-key-network.ts +1204 -1235
- package/src/libp2p-node-base.ts +185 -70
- package/src/network/open-protocol-stream.ts +87 -0
- package/src/network/register-protocol-handler.ts +85 -0
- package/src/peer-address-book.ts +96 -2
- package/src/pend-validation.ts +89 -0
- package/src/reactivity/notify-transport.ts +2 -1
- package/src/reactivity/push-state-gossip.ts +2 -1
- package/src/repo/cluster-coordinator.ts +65 -11
- package/src/repo/coordinator-repo.ts +729 -71
- package/src/repo/served-repo-proxy.ts +55 -0
- package/src/repo/service.ts +25 -16
- package/src/reputation/types.ts +3 -1
- package/src/rn.ts +34 -4
- package/src/storage/block-archive.ts +242 -0
- package/src/storage/block-latch.ts +144 -0
- package/src/storage/block-storage.ts +787 -498
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +898 -859
- package/src/storage/i-block-storage.ts +172 -116
- package/src/storage/i-raw-storage.ts +40 -0
- package/src/storage/kv-raw-storage.ts +34 -9
- package/src/storage/memory-store-driver.ts +26 -2
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +45 -3
- package/src/storage/shared-cache-pool.ts +516 -452
- package/src/storage/storage-repo.ts +1421 -1014
- package/src/storage/store-identity.ts +41 -0
- package/src/storage/struct.ts +52 -34
- package/src/storage/with-read-cache.ts +182 -0
- package/src/sync/service.ts +178 -198
- package/src/testing/mesh-harness.ts +524 -344
- package/src/testing/raw-storage-conformance.ts +114 -2
|
@@ -1,34 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { transformForBlockId, applyTransform, groupBy, concatTransform, emptyTransforms, blockIdsForTransforms, transformsFromTransform, highestStaleAt, isOwnRevision, canonicalBlockHash } from "@optimystic/db-core";
|
|
2
2
|
import { asyncIteratorToArray } from "../it-utility.js";
|
|
3
|
+
import { proofDeclaredDigest } from "../cluster/commit-proof.js";
|
|
4
|
+
import { RevisionNotCoveredError } from "./i-block-storage.js";
|
|
5
|
+
import { acquireBlockWriteLatches, withBlockWriteLatch } from "./block-latch.js";
|
|
3
6
|
import { createLogger } from "../logger.js";
|
|
7
|
+
import { checkPendValidation } from "../pend-validation.js";
|
|
4
8
|
const log = createLogger('storage-repo');
|
|
5
|
-
/**
|
|
6
|
-
* Single source of truth for the per-block commit latch key. Held by {@link StorageRepo.commit} and
|
|
7
|
-
* {@link StorageRepo.saveReplicatedBlock}, and — through an injected runner ({@link withBlockCommitLatch})
|
|
8
|
-
* — by the invalidation-apply path. Every out-of-band writer of a block's `meta.latest` must serialize
|
|
9
|
-
* on this key against a concurrent local commit on the same block; keeping all call sites on this helper
|
|
10
|
-
* is what prevents the key from drifting between them.
|
|
11
|
-
*/
|
|
12
|
-
export const commitLatchKey = (blockId) => `StorageRepo.commit:${blockId}`;
|
|
13
|
-
/**
|
|
14
|
-
* Runs `fn` while holding the per-block commit latch {@link commitLatchKey}. This is the capability the
|
|
15
|
-
* dispute module's `applyInvalidation` is handed (through its context) so its compensating
|
|
16
|
-
* `saveReplica`/`saveDeletion` read-modify-write of `meta.latest` is mutually exclusive with a concurrent
|
|
17
|
-
* {@link StorageRepo.commit} on the same block — otherwise an invalidation advancing `latest` outside
|
|
18
|
-
* that latch is invisible to commit's staleness guard and can be clobbered (a non-monotonic regression).
|
|
19
|
-
*
|
|
20
|
-
* Acquire/release is per call, so a caller holds at most one block latch at any instant and cannot
|
|
21
|
-
* deadlock against commit's sorted, up-front multi-latch acquisition.
|
|
22
|
-
*/
|
|
23
|
-
export async function withBlockCommitLatch(blockId, fn) {
|
|
24
|
-
const release = await Latches.acquire(commitLatchKey(blockId));
|
|
25
|
-
try {
|
|
26
|
-
return await fn();
|
|
27
|
-
}
|
|
28
|
-
finally {
|
|
29
|
-
release();
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
9
|
/**
|
|
33
10
|
* Stable, greppable prefix on the failure reason a commit carries when this node cannot materialize
|
|
34
11
|
* the revision it was asked to record. It is a STRING marker rather than only an error class because
|
|
@@ -36,6 +13,13 @@ export async function withBlockCommitLatch(blockId, fn) {
|
|
|
36
13
|
* also crosses the wire), so the class identity is lost by the time a caller inspects the result.
|
|
37
14
|
*/
|
|
38
15
|
export const MISSING_BASE_REVISION_REASON = 'missing-base-revision';
|
|
16
|
+
/**
|
|
17
|
+
* The two stable reject-reason prefixes a validating receiver emits, re-exported here (and from
|
|
18
|
+
* `cluster/cluster-repo.ts`) next to their siblings so a caller inspecting a `PendResult` reason
|
|
19
|
+
* need not know which module defines them. Both tiers refuse with the same prefixes because both
|
|
20
|
+
* run the same {@link checkPendValidation}.
|
|
21
|
+
*/
|
|
22
|
+
export { PEND_NOT_VALIDATABLE, VALIDATOR_FAULT } from "../pend-validation.js";
|
|
39
23
|
/**
|
|
40
24
|
* This node was asked to commit revision N of a block it holds no materializable base for, so
|
|
41
25
|
* applying the transform would materialize nothing while `latest` advanced to N — a block that is
|
|
@@ -64,6 +48,7 @@ export function isMissingBaseRevisionFailure(result) {
|
|
|
64
48
|
export class StorageRepo {
|
|
65
49
|
createBlockStorage;
|
|
66
50
|
validatePend;
|
|
51
|
+
unvalidatablePendPolicy;
|
|
67
52
|
/** Per-collection change listeners; empty sets are pruned on unsubscribe. */
|
|
68
53
|
changeListeners = new Map();
|
|
69
54
|
/** Catch-all change listeners — fire for EVERY collection's commit on this node. */
|
|
@@ -71,6 +56,7 @@ export class StorageRepo {
|
|
|
71
56
|
constructor(createBlockStorage, options) {
|
|
72
57
|
this.createBlockStorage = createBlockStorage;
|
|
73
58
|
this.validatePend = options?.validatePend;
|
|
59
|
+
this.unvalidatablePendPolicy = options?.unvalidatablePendPolicy ?? 'accept';
|
|
74
60
|
}
|
|
75
61
|
/**
|
|
76
62
|
* Subscribe to commits that mutate `collectionId`'s blocks on this node.
|
|
@@ -169,9 +155,9 @@ export class StorageRepo {
|
|
|
169
155
|
// posing as an authoritative "never existed" — see BlockUnavailableReason.
|
|
170
156
|
let unavailable;
|
|
171
157
|
// Ensure that all outstanding transactions in the context are committed.
|
|
172
|
-
// This promotes a landed-elsewhere pending via internalCommit, which
|
|
173
|
-
//
|
|
174
|
-
// with the per-block
|
|
158
|
+
// This promotes a landed-elsewhere pending via internalCommit, which writes the
|
|
159
|
+
// block's metadata — the same read-modify-write commit()/saveReplicatedBlock guard
|
|
160
|
+
// with the per-block write latch. It MUST hold that latch too, or a promotion
|
|
175
161
|
// racing a concurrent commit on the block regresses latest non-monotonically /
|
|
176
162
|
// cross-writes a revision. Cheap unlatched pre-scan first so the common
|
|
177
163
|
// contextless read and no-pending read never pay for latch acquisition; the
|
|
@@ -182,7 +168,7 @@ export class StorageRepo {
|
|
|
182
168
|
? context.committed.filter(c => c.rev > preLatest.rev)
|
|
183
169
|
: context.committed;
|
|
184
170
|
if (preMissing.length > 0) {
|
|
185
|
-
await
|
|
171
|
+
await withBlockWriteLatch(blockId, async (latch) => {
|
|
186
172
|
// Re-read authoritative state under the latch: a concurrent commit may have
|
|
187
173
|
// promoted or superseded a pending between the unlatched pre-scan and here.
|
|
188
174
|
// Recompute which committed entries are still ahead of `latest` (drops the
|
|
@@ -201,7 +187,7 @@ export class StorageRepo {
|
|
|
201
187
|
for (const { actionId, rev } of [...missing].sort((a, b) => a.rev - b.rev)) {
|
|
202
188
|
const pending = await blockStorage.getPendingTransaction(actionId);
|
|
203
189
|
if (pending) {
|
|
204
|
-
const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage);
|
|
190
|
+
const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage, latch);
|
|
205
191
|
if (collectionId !== undefined) {
|
|
206
192
|
promotions.push({ collectionId, blockId, actionId, rev });
|
|
207
193
|
}
|
|
@@ -231,17 +217,17 @@ export class StorageRepo {
|
|
|
231
217
|
// context-driven get skips promotion (pending gone) and a default getBlock() sees the
|
|
232
218
|
// stale latest. It is soft-wedged (stale), not hard-wedged: the next commit-retry for
|
|
233
219
|
// (actionId, rev) self-heals it via storage.recover() in commit(). Not repaired lazily on
|
|
234
|
-
// the read path because
|
|
235
|
-
// ever become a problem, add a latched lazy recover() here.
|
|
220
|
+
// the read path because the plain read below holds no write latch; if stale reads on
|
|
221
|
+
// unwritten blocks ever become a problem, add a latched lazy recover() here.
|
|
236
222
|
//
|
|
237
|
-
//
|
|
238
|
-
// history: "Failed to find materialized block", or a failed restore). Caught
|
|
239
|
-
// one broken block cannot fail the whole batch's Promise.all and take healthy
|
|
240
|
-
// down with it. The read still fails for THIS block — TransactorSource throws
|
|
223
|
+
// readBlockHealing() THROWS when this node holds a `latest` it cannot materialize
|
|
224
|
+
// (truncated history: "Failed to find materialized block", or a failed restore). Caught
|
|
225
|
+
// PER BLOCK so one broken block cannot fail the whole batch's Promise.all and take healthy
|
|
226
|
+
// siblings down with it. The read still fails for THIS block — TransactorSource throws
|
|
241
227
|
// BlockUnavailableError on the flagged entry — so nothing is swallowed.
|
|
242
228
|
let blockRev;
|
|
243
229
|
try {
|
|
244
|
-
blockRev = await
|
|
230
|
+
blockRev = await this.readBlockHealing(blockId, blockStorage, context?.rev);
|
|
245
231
|
}
|
|
246
232
|
catch (err) {
|
|
247
233
|
// NOTE: the entry drops `state.latest`, which this node does know (getLatest() does not
|
|
@@ -291,7 +277,7 @@ export class StorageRepo {
|
|
|
291
277
|
// of its own, so the honest answer is the base it was applied to. Absent when there
|
|
292
278
|
// was no base at all — a pending-only insert served over an absent committed base,
|
|
293
279
|
// where fabricating a revision would claim content this node never committed.
|
|
294
|
-
...(blockRev ? {
|
|
280
|
+
...(blockRev ? { materialized: blockRev.actionRev } : {}),
|
|
295
281
|
// A pending applied to a missing base can materialize nothing (applyTransform drops
|
|
296
282
|
// updates with no block to apply them to) — that absence is a guess, and is flagged.
|
|
297
283
|
// A materialized block is a real answer regardless of the earlier refusal. TWO ways
|
|
@@ -322,7 +308,7 @@ export class StorageRepo {
|
|
|
322
308
|
// content. `state.latest` deliberately stays the node's newest revision for the block
|
|
323
309
|
// (StorageRepo.get's own promotion pre-scan and CoordinatorRepo's read-repair compare
|
|
324
310
|
// against it), so the two disagree exactly when a pinned read is serving older content.
|
|
325
|
-
|
|
311
|
+
materialized: blockRev.actionRev,
|
|
326
312
|
state: {
|
|
327
313
|
latest: await blockStorage.getLatest(),
|
|
328
314
|
pendings
|
|
@@ -335,6 +321,48 @@ export class StorageRepo {
|
|
|
335
321
|
this.emitPromotions(promotions);
|
|
336
322
|
return Object.fromEntries(results);
|
|
337
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* The one place a local coverage gap is healed from a peer. `getBlock` is local-only; when it
|
|
326
|
+
* reports the target revision as not covered ({@link RevisionNotCoveredError}) this fetches it
|
|
327
|
+
* through `restoreRevision` under the block's write latch — the restore writes revision records
|
|
328
|
+
* and merges coverage into the metadata blob, so it must serialize against every other writer of
|
|
329
|
+
* the block — and re-reads. Only the restore is latched; the reads on either side are not, and
|
|
330
|
+
* the latch is never held across the two.
|
|
331
|
+
*
|
|
332
|
+
* A restore that fails on a **pending-only** block (metadata seeded by a pend, no committed
|
|
333
|
+
* revision) reads as ABSENT, not as a fault: the named revision was a guess about content this
|
|
334
|
+
* node never held, and the caller's insert-probe / pending-overlay logic already treats an absent
|
|
335
|
+
* base as "nothing committed here". A failed restore on a block that DOES hold a `latest` is a
|
|
336
|
+
* real fault (a `latest` this node cannot serve) and propagates, so the caller reports the block
|
|
337
|
+
* as unavailable. Any throw from the second read (records restored but nothing materializable
|
|
338
|
+
* under them) propagates the same way.
|
|
339
|
+
*/
|
|
340
|
+
async readBlockHealing(blockId, storage, rev) {
|
|
341
|
+
try {
|
|
342
|
+
return await storage.getBlock(rev);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
if (!(err instanceof RevisionNotCoveredError)) {
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
// NOTE: the peer fetch inside restoreRevision runs UNDER the block's write latch, so a
|
|
350
|
+
// slow restore queues every commit/pend/replica on this block behind one network
|
|
351
|
+
// round-trip. Fine at today's restore rates (a gap is healed once, then served
|
|
352
|
+
// locally); if restore latency ever shows up delaying commits, fetch + vet OUTSIDE the
|
|
353
|
+
// latch and take it only to write, re-checking coverage inside.
|
|
354
|
+
await withBlockWriteLatch(blockId, latch => storage.restoreRevision(err.rev, latch));
|
|
355
|
+
}
|
|
356
|
+
catch (restoreErr) {
|
|
357
|
+
if (await storage.getLatest() === undefined) {
|
|
358
|
+
log('get:restore-failed-pending-only blockId=%s rev=%d error=%s', blockId, err.rev, restoreErr instanceof Error ? restoreErr.message : String(restoreErr));
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
throw restoreErr;
|
|
362
|
+
}
|
|
363
|
+
return await storage.getBlock(rev);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
338
366
|
/**
|
|
339
367
|
* Emit a {@link CollectionChangeEvent} for each read-driven promotion that landed
|
|
340
368
|
* during a {@link get}. A single get() can promote multiple distinct actions, each
|
|
@@ -362,17 +390,20 @@ export class StorageRepo {
|
|
|
362
390
|
}
|
|
363
391
|
}
|
|
364
392
|
async pend(request, _options) {
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
393
|
+
// Re-check the transaction when a validation hook is configured — the unvalidatable-pend
|
|
394
|
+
// policy and the throwing-hook catch both live in the shared `checkPendValidation`, so this
|
|
395
|
+
// tier and the cluster tier cannot drift apart on what they refuse.
|
|
396
|
+
const hook = this.validatePend;
|
|
397
|
+
const validation = await checkPendValidation(request, hook && (({ transaction, operationsHash }) => hook(transaction, operationsHash)), this.unvalidatablePendPolicy, event => event.kind === 'unvalidatable'
|
|
398
|
+
? log('pend-unvalidatable actionId=%s policy=%s', request.actionId, event.policy)
|
|
399
|
+
: log('pend validator-fault actionId=%s error=%s', request.actionId, event.error));
|
|
400
|
+
if (!validation.valid) {
|
|
401
|
+
// Hard rejection: no `conflict` flag, because re-driving the same request fails the same
|
|
402
|
+
// way and would only burn the writer's retry budget.
|
|
403
|
+
return {
|
|
404
|
+
success: false,
|
|
405
|
+
reason: validation.reason ?? 'Transaction validation failed'
|
|
406
|
+
};
|
|
376
407
|
}
|
|
377
408
|
const blockIds = blockIdsForTransforms(request.transforms);
|
|
378
409
|
log('pend actionId=%s blockIds=%d rev=%s', request.actionId, blockIds.length, request.rev);
|
|
@@ -382,6 +413,11 @@ export class StorageRepo {
|
|
|
382
413
|
// requested one — reported as StaleFailure.staleAt so a losing writer learns the number
|
|
383
414
|
// instead of parsing prose. Confirmed-local only: we read it from our own storage below.
|
|
384
415
|
let staleAt;
|
|
416
|
+
// Blocks this action ALREADY committed at exactly the requested revision — the durable half
|
|
417
|
+
// of a torn action whose retry reuses the same actionId. Sibling of the `alreadyDone`
|
|
418
|
+
// partition in `commit` below: satisfied, not merely non-stale, so no pending is recorded
|
|
419
|
+
// for them (see the fan-out at the end of this method).
|
|
420
|
+
const satisfied = new Set();
|
|
385
421
|
// Potential race condition: A concurrent commit operation could complete
|
|
386
422
|
// between the conflict checks (latest.rev, listPendingTransactions) and the
|
|
387
423
|
// savePendingTransaction call below. This pend operation might succeed based on
|
|
@@ -392,12 +428,22 @@ export class StorageRepo {
|
|
|
392
428
|
for (const blockId of blockIds) {
|
|
393
429
|
const blockStorage = this.createBlockStorage(blockId);
|
|
394
430
|
const transforms = transformForBlockId(request.transforms, blockId);
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
// Handle any conflicting revisions
|
|
431
|
+
// Handle any conflicting revisions FIRST: a block this same action already committed at
|
|
432
|
+
// exactly the requested revision is satisfied, and skips both this check and the
|
|
433
|
+
// pending-action listing below.
|
|
399
434
|
if (request.rev !== undefined || transforms.insert) {
|
|
400
435
|
const latest = await blockStorage.getLatest();
|
|
436
|
+
// Our own already-durable work, met again by a retry (see {@link isOwnRevision}):
|
|
437
|
+
// treating it as a stale rival would refuse the writer with its own commit.
|
|
438
|
+
// NOTE: a rev-less pend (`request.rev === undefined`, an insert-only claim) can
|
|
439
|
+
// never match, so a torn action retried WITHOUT a revision is still refused by its
|
|
440
|
+
// own insert. No production caller sends one — `TransactorSource.transact` and the
|
|
441
|
+
// multi-collection coordinator both require a rev — so this is unreachable today;
|
|
442
|
+
// if a rev-less write path ever appears, match on `latest.actionId` alone here.
|
|
443
|
+
if (isOwnRevision(latest, request.rev, request.actionId)) {
|
|
444
|
+
satisfied.add(blockId);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
401
447
|
if (latest && latest.rev >= (request.rev ?? 0)) {
|
|
402
448
|
// Only a real revision race yields a meaningful `staleAt`. When `request.rev` is
|
|
403
449
|
// undefined this same branch fires for an insert collision (the comparison degrades
|
|
@@ -420,6 +466,9 @@ export class StorageRepo {
|
|
|
420
466
|
}
|
|
421
467
|
}
|
|
422
468
|
}
|
|
469
|
+
// Then handle any pending actions
|
|
470
|
+
const pending = await asyncIteratorToArray(blockStorage.listPendingTransactions());
|
|
471
|
+
pendings.push(...pending.map(actionId => ({ blockId, actionId })));
|
|
423
472
|
}
|
|
424
473
|
if (missing.length) {
|
|
425
474
|
log('pend:stale actionId=%s missing=%d', request.actionId, missing.length);
|
|
@@ -454,10 +503,24 @@ export class StorageRepo {
|
|
|
454
503
|
// Note: that this is not atomic, after we checked for conflicts and pending actions
|
|
455
504
|
// new pending or committed actions may have been added. This is okay, because
|
|
456
505
|
// this check during pend is conservative.
|
|
457
|
-
|
|
506
|
+
//
|
|
507
|
+
// Each block's pending write runs under THAT block's write latch, one latch per branch and
|
|
508
|
+
// never nested: savePendingTransaction seeds the block's metadata blob when it has none, and
|
|
509
|
+
// an unlatched seed racing a concurrent commit/replica on a fresh block erases the `latest`
|
|
510
|
+
// the other writer just landed. Never more than one block latch is held by a branch, so this
|
|
511
|
+
// cannot deadlock against commit's sorted multi-latch acquisition.
|
|
512
|
+
//
|
|
513
|
+
// `satisfied` blocks are skipped: `commit`'s `alreadyDone` arm skips `internalCommit`, the
|
|
514
|
+
// only thing that promotes (and thereby removes) a pending record, so a pending saved here
|
|
515
|
+
// would never clear — a permanent durable reservation that the rival-pending checks (this
|
|
516
|
+
// method's listPendingTransactions scan, and `ClusterMember.validatePendOperations`) refuse
|
|
517
|
+
// every future writer against, a worse wedge than the one this carve-out fixes. They still
|
|
518
|
+
// ride in the returned `blockIds` so `cancel` covers them (deleting an absent pending is a
|
|
519
|
+
// no-op that writes no metadata).
|
|
520
|
+
await Promise.all(blockIds.filter(blockId => !satisfied.has(blockId)).map(blockId => {
|
|
458
521
|
const blockStorage = this.createBlockStorage(blockId);
|
|
459
522
|
const blockTransform = transformForBlockId(request.transforms, blockId);
|
|
460
|
-
return blockStorage.savePendingTransaction(request.actionId, blockTransform);
|
|
523
|
+
return withBlockWriteLatch(blockId, latch => blockStorage.savePendingTransaction(request.actionId, blockTransform, latch));
|
|
461
524
|
}));
|
|
462
525
|
return {
|
|
463
526
|
success: true,
|
|
@@ -469,11 +532,11 @@ export class StorageRepo {
|
|
|
469
532
|
log('cancel actionId=%s blockIds=%d', actionRef.actionId, actionRef.blockIds.length);
|
|
470
533
|
await Promise.all(actionRef.blockIds.map(blockId => {
|
|
471
534
|
const blockStorage = this.createBlockStorage(blockId);
|
|
472
|
-
return blockStorage.deletePendingTransaction(actionRef.actionId);
|
|
535
|
+
return withBlockWriteLatch(blockId, latch => blockStorage.deletePendingTransaction(actionRef.actionId, latch));
|
|
473
536
|
}));
|
|
474
537
|
}
|
|
475
538
|
/**
|
|
476
|
-
* Commit a previously-pended action across its blocks, under the
|
|
539
|
+
* Commit a previously-pended action across its blocks, under the block write latches.
|
|
477
540
|
*
|
|
478
541
|
* **Divergence vs genuine fault.** When the batch cannot be completed, the reason decides what
|
|
479
542
|
* happens to the pending records the pend left behind. `ClusterMember.applyConsensusOperation`
|
|
@@ -500,10 +563,12 @@ export class StorageRepo {
|
|
|
500
563
|
* its `cancel` therefore still strands the record; that is pre-existing and orthogonal to the
|
|
501
564
|
* divergence split above.
|
|
502
565
|
*/
|
|
503
|
-
async commit(request, _options) {
|
|
566
|
+
async commit(request, _options, proof) {
|
|
504
567
|
log('commit actionId=%s rev=%d blockIds=%d', request.actionId, request.rev, request.blockIds.length);
|
|
505
|
-
|
|
506
|
-
|
|
568
|
+
// Deduped ONCE, in request order — the order blocks are committed and reported in. The latches
|
|
569
|
+
// are acquired in sorted order by `acquireBlockWriteLatches` over this same set, so every
|
|
570
|
+
// `latches.get(blockId)!` below resolves.
|
|
571
|
+
const blockIds = Array.from(new Set(request.blockIds));
|
|
507
572
|
// Collects the blocks newly committed in this call, grouped by collection,
|
|
508
573
|
// so we can emit change events once locks are released. Blocks that land before
|
|
509
574
|
// a mid-loop failure stay here and are still emitted (Option A — emit eagerly):
|
|
@@ -512,17 +577,16 @@ export class StorageRepo {
|
|
|
512
577
|
// Captured when internalCommit throws mid-loop; we break (rather than return)
|
|
513
578
|
// so locks release and accumulated landings still emit before we report failure.
|
|
514
579
|
let failure;
|
|
580
|
+
// Every block's token is kept so each write below can prove it runs inside that block's latch.
|
|
581
|
+
const { latches, release } = await acquireBlockWriteLatches(blockIds);
|
|
515
582
|
try {
|
|
516
|
-
// Acquire locks sequentially based on sorted IDs to prevent deadlocks
|
|
517
|
-
for (const id of uniqueBlockIds) {
|
|
518
|
-
const lockId = commitLatchKey(id);
|
|
519
|
-
const release = await Latches.acquire(lockId);
|
|
520
|
-
releases.push(release);
|
|
521
|
-
}
|
|
522
583
|
// --- Start of Critical Section ---
|
|
523
|
-
|
|
584
|
+
// Request order, deduped (NOT the sorted acquisition order): the order here is the order
|
|
585
|
+
// blocks are committed and reported in change events, which callers may observe.
|
|
586
|
+
const blockStorages = blockIds.map(blockId => ({
|
|
524
587
|
blockId,
|
|
525
|
-
storage: this.createBlockStorage(blockId)
|
|
588
|
+
storage: this.createBlockStorage(blockId),
|
|
589
|
+
latch: latches.get(blockId)
|
|
526
590
|
}));
|
|
527
591
|
// Partition blocks into:
|
|
528
592
|
// - alreadyDone: latest.rev === request.rev && latest.actionId === request.actionId
|
|
@@ -538,11 +602,15 @@ export class StorageRepo {
|
|
|
538
602
|
// so it never seeds this.
|
|
539
603
|
let staleAt;
|
|
540
604
|
for (const entry of blockStorages) {
|
|
541
|
-
const { blockId, storage } = entry;
|
|
605
|
+
const { blockId, storage, latch } = entry;
|
|
542
606
|
const latest = await storage.getLatest();
|
|
543
607
|
if (latest && latest.rev >= request.rev) {
|
|
544
|
-
if (latest
|
|
608
|
+
if (isOwnRevision(latest, request.rev, request.actionId)) {
|
|
545
609
|
// Idempotent no-op for this block — already committed with this exact (actionId, rev).
|
|
610
|
+
// A retry can carry a proof the original commit lacked (or crashed before writing):
|
|
611
|
+
// back-fill it, strictly additively, under the same digest-match retention rule the
|
|
612
|
+
// original commit applies. Runs inside the latched critical section.
|
|
613
|
+
await this.backFillProof(blockId, storage, request.rev, request.actionId, proof, latch);
|
|
546
614
|
continue;
|
|
547
615
|
}
|
|
548
616
|
staleAt = highestStaleAt([staleAt, { blockId, rev: latest.rev }]);
|
|
@@ -581,7 +649,7 @@ export class StorageRepo {
|
|
|
581
649
|
// is gone. getTransaction(actionId) returns the promoted transform. Self-heal here
|
|
582
650
|
// via storage.recover() (redoes the lost setLatest, advancing latest to the highest
|
|
583
651
|
// contiguous promoted rev, >= request.rev). recover() is idempotent + monotonic, so
|
|
584
|
-
// calling it under the already-held
|
|
652
|
+
// calling it under the already-held block write latch is safe. Recovered blocks are then
|
|
585
653
|
// excluded from the internalCommit loop below — their pending is gone, so
|
|
586
654
|
// internalCommit would throw.
|
|
587
655
|
// - Genuine missing pend: the action was never promoted (getTransaction → undefined),
|
|
@@ -589,7 +657,7 @@ export class StorageRepo {
|
|
|
589
657
|
// Crash-D2 never reaches this branch: its pending record is still present.
|
|
590
658
|
const missingPends = [];
|
|
591
659
|
const recovered = new Set();
|
|
592
|
-
for (const { blockId, storage } of toCommit) {
|
|
660
|
+
for (const { blockId, storage, latch } of toCommit) {
|
|
593
661
|
const pendingAction = await storage.getPendingTransaction(request.actionId);
|
|
594
662
|
if (pendingAction) {
|
|
595
663
|
continue;
|
|
@@ -600,7 +668,7 @@ export class StorageRepo {
|
|
|
600
668
|
continue;
|
|
601
669
|
}
|
|
602
670
|
// Crash-D3 signature (pending absent + action durably promoted). Redo the lost setLatest.
|
|
603
|
-
const result = await storage.recover();
|
|
671
|
+
const result = await storage.recover(latch);
|
|
604
672
|
if (result.latest !== undefined && result.latest.rev >= request.rev) {
|
|
605
673
|
recovered.add(blockId);
|
|
606
674
|
}
|
|
@@ -635,7 +703,7 @@ export class StorageRepo {
|
|
|
635
703
|
// internalCommit does — otherwise a recovered delete would silently fail to wake watchers.
|
|
636
704
|
// Only when neither resolves (a delete-only block with no prior materialization) is the
|
|
637
705
|
// emit skipped, the same terminal fallback internalCommit uses.
|
|
638
|
-
for (const { blockId, storage } of toCommit) {
|
|
706
|
+
for (const { blockId, storage, latch } of toCommit) {
|
|
639
707
|
if (!recovered.has(blockId)) {
|
|
640
708
|
continue;
|
|
641
709
|
}
|
|
@@ -646,6 +714,10 @@ export class StorageRepo {
|
|
|
646
714
|
list.push(blockId);
|
|
647
715
|
collectionBlocks.set(collectionId, list);
|
|
648
716
|
}
|
|
717
|
+
// The recovered block IS committed at request.rev, but it is excluded from the
|
|
718
|
+
// internalCommit loop below — so without this it would be the one landing path that
|
|
719
|
+
// never retains the cohort's proof, even though this very call is carrying it.
|
|
720
|
+
await this.backFillProof(blockId, storage, request.rev, request.actionId, proof, latch);
|
|
649
721
|
}
|
|
650
722
|
// Commit the action for each block that still needs it.
|
|
651
723
|
// This loop will execute atomically for all blocks due to the acquired locks.
|
|
@@ -655,13 +727,13 @@ export class StorageRepo {
|
|
|
655
727
|
// Set when the mid-loop failure was a divergence rather than a genuine fault — the split
|
|
656
728
|
// documented on commit() above, which decides the fate of the batch's pending records.
|
|
657
729
|
let divergentFailure = false;
|
|
658
|
-
for (const { blockId, storage } of toCommit) {
|
|
730
|
+
for (const { blockId, storage, latch } of toCommit) {
|
|
659
731
|
if (recovered.has(blockId)) {
|
|
660
732
|
continue;
|
|
661
733
|
}
|
|
662
734
|
try {
|
|
663
735
|
// internalCommit will throw if it encounters an issue
|
|
664
|
-
const collectionId = await this.internalCommit(blockId, request.actionId, request.rev, storage);
|
|
736
|
+
const collectionId = await this.internalCommit(blockId, request.actionId, request.rev, storage, latch, proof);
|
|
665
737
|
if (collectionId !== undefined) {
|
|
666
738
|
const list = collectionBlocks.get(collectionId) ?? [];
|
|
667
739
|
list.push(blockId);
|
|
@@ -689,8 +761,8 @@ export class StorageRepo {
|
|
|
689
761
|
}
|
|
690
762
|
}
|
|
691
763
|
finally {
|
|
692
|
-
//
|
|
693
|
-
|
|
764
|
+
// Releases every block latch, in reverse acquisition order.
|
|
765
|
+
release();
|
|
694
766
|
}
|
|
695
767
|
// Notify after the critical section, for every block newly committed here —
|
|
696
768
|
// including those that landed before a mid-loop failure (alreadyDone / stale
|
|
@@ -723,9 +795,9 @@ export class StorageRepo {
|
|
|
723
795
|
return;
|
|
724
796
|
}
|
|
725
797
|
log('commit:drop-unpromotable-pendings actionId=%s blockIds=%d', actionId, blocks.length);
|
|
726
|
-
await Promise.all(blocks.map(async ({ blockId, storage }) => {
|
|
798
|
+
await Promise.all(blocks.map(async ({ blockId, storage, latch }) => {
|
|
727
799
|
try {
|
|
728
|
-
await storage.deletePendingTransaction(actionId);
|
|
800
|
+
await storage.deletePendingTransaction(actionId, latch);
|
|
729
801
|
}
|
|
730
802
|
catch (err) {
|
|
731
803
|
log('commit:drop-unpromotable-pending-failed blockId=%s actionId=%s error=%s', blockId, actionId, err instanceof Error ? err.message : String(err));
|
|
@@ -742,13 +814,13 @@ export class StorageRepo {
|
|
|
742
814
|
async recoverBlock(blockId) {
|
|
743
815
|
log('recoverBlock blockId=%s', blockId);
|
|
744
816
|
const storage = this.createBlockStorage(blockId);
|
|
745
|
-
// Hold the
|
|
746
|
-
// blindly writes back the
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
await
|
|
817
|
+
// Hold the block write latch: recover() is a read-modify-write of the metadata blob that
|
|
818
|
+
// blindly writes back the object it read, so its "advance only" guard is TOCTOU — racing a
|
|
819
|
+
// concurrent commit()/saveReplicatedBlock that advanced latest in between would clobber it
|
|
820
|
+
// (a non-monotonic regression). Same latching invariant as every other metadata writer.
|
|
821
|
+
// commit() calls storage.recover(latch) directly under its own held latch, so it never
|
|
822
|
+
// routes through here — no double-acquire / deadlock.
|
|
823
|
+
await withBlockWriteLatch(blockId, latch => storage.recover(latch));
|
|
752
824
|
}
|
|
753
825
|
/**
|
|
754
826
|
* Persist a replica of a block received out-of-band (churn re-replication) into
|
|
@@ -756,20 +828,34 @@ export class StorageRepo {
|
|
|
756
828
|
* already materialized from a departing owner, not as a pend/commit. See
|
|
757
829
|
* {@link IBlockStorage.saveReplica} for the durability/monotonicity contract.
|
|
758
830
|
*
|
|
759
|
-
* Held under the same
|
|
760
|
-
*
|
|
831
|
+
* Held under the same block write latch as {@link commit} so the replica's
|
|
832
|
+
* read-modify-write of the metadata blob is mutually exclusive with a concurrent
|
|
761
833
|
* local commit on the same block — otherwise `saveReplica`'s monotonic guard could
|
|
762
834
|
* read a stale `latest` and clobber a commit that advanced it in between.
|
|
835
|
+
*
|
|
836
|
+
* `verifiedProof` is retained when supplied: both the reconcile path
|
|
837
|
+
* (`cluster/reconcile-block.ts`) and the certified push path (`BlockTransferService.handlePush`)
|
|
838
|
+
* pass the {@link BlockCommitProof} they verified against these exact bytes (`certifyContent`'s
|
|
839
|
+
* digest check), so a repaired replica serves the proof onward and certification no longer decays
|
|
840
|
+
* across repair hops.
|
|
841
|
+
*
|
|
842
|
+
* When the push does NOT advance `latest` (this node already holds that revision), `saveReplica`
|
|
843
|
+
* is a no-op and persists nothing — so the proof is back-filled here instead, through
|
|
844
|
+
* {@link backFillProof}'s digest-match rule. It is deliberately NOT persisted inside
|
|
845
|
+
* `saveReplica`: the proof was verified against the PUSHED bytes, while a back-fill attaches it to
|
|
846
|
+
* this node's HELD materialization, and a diverged holder's bytes at the same `(rev, actionId)`
|
|
847
|
+
* may differ. Storing a proof whose declared digest contradicts local content would make this node
|
|
848
|
+
* serve content that fails its own proof — `digest-mismatch` is an ATTRIBUTABLE fault in
|
|
849
|
+
* `certified-claims.ts`, so every receiver would penalize it.
|
|
763
850
|
*/
|
|
764
|
-
async saveReplicatedBlock(blockId, block, source) {
|
|
851
|
+
async saveReplicatedBlock(blockId, block, source, verifiedProof) {
|
|
765
852
|
log('saveReplicatedBlock blockId=%s rev=%s', blockId, source?.rev);
|
|
766
853
|
const storage = this.createBlockStorage(blockId);
|
|
767
|
-
const release = await Latches.acquire(commitLatchKey(blockId));
|
|
768
854
|
// Captured under the latch; emitted after release to match commit's ordering.
|
|
769
855
|
let landed;
|
|
770
|
-
|
|
856
|
+
await withBlockWriteLatch(blockId, async (latch) => {
|
|
771
857
|
const priorLatest = await storage.getLatest();
|
|
772
|
-
const effective = await storage.saveReplica(block, source);
|
|
858
|
+
const effective = await storage.saveReplica(block, source, verifiedProof, latch);
|
|
773
859
|
// Advanced iff there was no prior revision or the effective rev moved past it. On the
|
|
774
860
|
// monotonic no-op, saveReplica returns the held latest unchanged → effective.rev === priorLatest.rev.
|
|
775
861
|
const advanced = priorLatest === undefined || effective.rev > priorLatest.rev;
|
|
@@ -777,10 +863,31 @@ export class StorageRepo {
|
|
|
777
863
|
if (advanced && collectionId !== undefined) {
|
|
778
864
|
landed = { collectionId, actionId: effective.actionId, rev: effective.rev };
|
|
779
865
|
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
866
|
+
if (!advanced && verifiedProof !== undefined && source !== undefined
|
|
867
|
+
&& effective.rev === source.rev && effective.actionId === source.actionId) {
|
|
868
|
+
// The push named exactly the revision this node already holds, and carried a verified
|
|
869
|
+
// proof for it. Back-fill so a proof-lessly-landed revision stops being corroboration-only
|
|
870
|
+
// the moment valid evidence for it arrives. Requires agreement on BOTH rev and actionId:
|
|
871
|
+
// same rev under a different action is a divergence, not the same revision.
|
|
872
|
+
//
|
|
873
|
+
// A held revision NEWER than the pushed one is deliberately not back-filled: `servableProof`
|
|
874
|
+
// only ever serves the proof for `latest.rev`, so the proof would be keyed to a revision
|
|
875
|
+
// this node will never serve, for content it may not even materialize.
|
|
876
|
+
//
|
|
877
|
+
// Runs under the block write latch already held here — the same latch the commit-path
|
|
878
|
+
// back-fill sites hold, so no new latch interaction. `backFillProof` never throws: the
|
|
879
|
+
// revision is already durable, and a proof-persist fault must not turn a no-op into a
|
|
880
|
+
// failure.
|
|
881
|
+
//
|
|
882
|
+
// NOTE: once a proof IS retained this costs one key lookup per duplicate push
|
|
883
|
+
// (`backFillProof` returns before materializing). A holder whose bytes diverge from the
|
|
884
|
+
// cohort's never retains one, so it re-materializes and re-hashes the block on EVERY
|
|
885
|
+
// certified push of that revision. Bounded by push frequency and fine at spread-on-churn
|
|
886
|
+
// rates; if a diverged holder under repeated push ever shows up in a profile, remember the
|
|
887
|
+
// withheld `(rev, actionId)` and skip the re-check.
|
|
888
|
+
await this.backFillProof(blockId, storage, effective.rev, effective.actionId, verifiedProof, latch);
|
|
889
|
+
}
|
|
890
|
+
});
|
|
784
891
|
// Replica-persist has no CommitRequest, hence no tailId — like a read-driven promotion,
|
|
785
892
|
// this wakes local onCollectionChange watchers but is cert-gated out of cohort-topic
|
|
786
893
|
// re-origination downstream (change-bridge selfIsCohortMember treats a tail-less event as
|
|
@@ -789,12 +896,104 @@ export class StorageRepo {
|
|
|
789
896
|
this.emitCollectionChanges(new Map([[landed.collectionId, [blockId]]]), landed.actionId, landed.rev);
|
|
790
897
|
}
|
|
791
898
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
899
|
+
/**
|
|
900
|
+
* The digest the block WOULD materialize to if `actionId`'s pending transform committed at `rev`,
|
|
901
|
+
* plus the base revision it was computed from. Read-only: touches no durable state and takes no
|
|
902
|
+
* block write latch.
|
|
903
|
+
*
|
|
904
|
+
* Mirrors {@link internalCommit}'s reads (pending transform → latest → base → applyTransform) so
|
|
905
|
+
* the prediction and the eventual commit cannot drift. Consumed by the cluster member's
|
|
906
|
+
* promise-round content-digest check (`ClusterMember.validateCommitOperations`), which compares it
|
|
907
|
+
* against the digest the transaction author declared on the commit request.
|
|
908
|
+
*
|
|
909
|
+
* Deliberately does NOT take the block write latch: this runs on the vote path, ahead of the
|
|
910
|
+
* commit that will take it, so taking it here would serialize voting behind commits and risks
|
|
911
|
+
* deadlocking against commit's sorted up-front multi-block latch acquisition. The price is that a
|
|
912
|
+
* concurrent commit can move `latest` mid-preview; the caller's checkable rule (base-independent,
|
|
913
|
+
* or `baseRev` agreement) makes a torn read at worst an abstain, never a false reject of honest
|
|
914
|
+
* content.
|
|
915
|
+
*
|
|
916
|
+
* `rev` is accepted for parity/logging with the commit that would follow; materialization does not
|
|
917
|
+
* depend on it (internalCommit only records it).
|
|
918
|
+
*
|
|
919
|
+
* Returns `undefined` when this node holds no pending transform for the action (it never saw the
|
|
920
|
+
* pend) — distinct from a defined preview with `digest: undefined` (see {@link CommitDigestPreview}).
|
|
921
|
+
*/
|
|
922
|
+
async previewCommitDigest(blockId, actionId, rev) {
|
|
923
|
+
const storage = this.createBlockStorage(blockId);
|
|
924
|
+
const transform = await storage.getPendingTransaction(actionId);
|
|
925
|
+
if (!transform) {
|
|
926
|
+
return undefined;
|
|
927
|
+
}
|
|
928
|
+
// An insert replaces the block wholesale before updates apply, so the result is the same on
|
|
929
|
+
// every member no matter what base it holds — do not read a base at all (the block may even be
|
|
930
|
+
// locally wedged/unmaterializable, which must not degrade a base-independent preview).
|
|
931
|
+
const baseIndependent = transform.insert !== undefined;
|
|
932
|
+
let base;
|
|
933
|
+
let baseRev;
|
|
934
|
+
if (!baseIndependent) {
|
|
935
|
+
const latest = await storage.getLatest();
|
|
936
|
+
if (latest) {
|
|
937
|
+
baseRev = latest.rev;
|
|
938
|
+
try {
|
|
939
|
+
base = (await storage.getBlock(latest.rev))?.block;
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
// This node holds a `latest` it cannot materialize (see readCommitBase). That is a
|
|
943
|
+
// local deficiency, not a content mismatch — report "cannot check" so the caller
|
|
944
|
+
// abstains. Unlike the commit path's refuseMissingBase, this must NOT delete the
|
|
945
|
+
// pending record or throw: preview is read-only and runs before any commit exists.
|
|
946
|
+
log('previewCommitDigest:unmaterializable-base blockId=%s baseRev=%d rev=%d error=%s', blockId, latest.rev, rev, err instanceof Error ? err.message : String(err));
|
|
947
|
+
return { baseIndependent: false, baseRev, digest: undefined };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
// Clone both: applyTransform assigns `transform.insert` into the result by reference and
|
|
952
|
+
// applyOperations mutates the block in place, so materializing on live storage/pending objects
|
|
953
|
+
// would corrupt them for the real commit that follows.
|
|
954
|
+
const newBlock = applyTransform(structuredClone(base), structuredClone(transform));
|
|
955
|
+
// `undefined` covers the tombstone (delete transform) and updates-with-no-base (applyTransform
|
|
956
|
+
// drops updates when there is no block to apply them to) — both materialize nothing.
|
|
957
|
+
const digest = newBlock ? await canonicalBlockHash(newBlock) : undefined;
|
|
958
|
+
return { digest, baseRev, baseIndependent };
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* See {@link IRevisionActionReader}. Reads the block's revision index directly
|
|
962
|
+
* (`listRevisions(rev, rev)` — both bounds inclusive per the `IBlockStorage` contract); an empty
|
|
963
|
+
* range means this node holds no record for that revision.
|
|
964
|
+
*/
|
|
965
|
+
async getRevisionAction(blockId, rev) {
|
|
966
|
+
const storage = this.createBlockStorage(blockId);
|
|
967
|
+
for await (const actionRev of storage.listRevisions(rev, rev)) {
|
|
968
|
+
return actionRev.actionId;
|
|
969
|
+
}
|
|
970
|
+
return undefined;
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* The {@link BlockCommitProof} this node retained for `blockId` at `rev`, or `undefined` when it
|
|
974
|
+
* kept none — a revision committed before proofs were persisted, a member whose materialization
|
|
975
|
+
* diverged from the declared digest (see {@link persistProofIfContentMatches}), or simply a
|
|
976
|
+
* revision this node never landed.
|
|
977
|
+
*
|
|
978
|
+
* Public because a peer answering a block-repair fetch serves the proof alongside the revision
|
|
979
|
+
* (`serveBlockArchive`), which is the only way a requester can check a lone holder's claim
|
|
980
|
+
* without a second holder to corroborate it. Read-only and unlatched: a proof is written once
|
|
981
|
+
* and never mutated, so a concurrent commit can only make this return a proof for a revision
|
|
982
|
+
* that just became stale — which the caller pairs with the revision it actually read.
|
|
983
|
+
*/
|
|
984
|
+
async getBlockProof(blockId, rev) {
|
|
985
|
+
return await this.createBlockStorage(blockId).getBlockProof(rev);
|
|
986
|
+
}
|
|
987
|
+
async internalCommit(blockId, actionId, rev, storage, latch, proof) {
|
|
988
|
+
// Note: This method is called under the block write latch — by commit() (within its locked
|
|
989
|
+
// critical section) and by the read-driven promotion in get() (which takes the same latch);
|
|
990
|
+
// `latch` is the proof of that. So, operations like getPendingTransaction, getLatest,
|
|
991
|
+
// getBlock, saveMaterializedBlock, saveRevision, promotePendingTransaction, setLatest are
|
|
992
|
+
// protected against concurrent writers for the *same blockId*.
|
|
993
|
+
//
|
|
994
|
+
// `getBlock` here (via readCommitBase) is LOCAL-ONLY: the commit path never fetches from a
|
|
995
|
+
// peer while holding N block latches. A coverage gap reads as a missing base, which the
|
|
996
|
+
// healing path repairs by replication instead.
|
|
798
997
|
const transform = await storage.getPendingTransaction(actionId);
|
|
799
998
|
// No need to check if !transform here, as the caller (commit) already verified this.
|
|
800
999
|
// If it's null here, it indicates a logic error or race condition bypassed the lock (unlikely).
|
|
@@ -803,7 +1002,7 @@ export class StorageRepo {
|
|
|
803
1002
|
}
|
|
804
1003
|
// Get prior materialized block if it exists
|
|
805
1004
|
const latest = await storage.getLatest();
|
|
806
|
-
const priorBlock = await this.readCommitBase(blockId, actionId, rev, storage, latest);
|
|
1005
|
+
const priorBlock = await this.readCommitBase(blockId, actionId, rev, storage, latest, latch);
|
|
807
1006
|
// Apply transform and save materialized block
|
|
808
1007
|
// applyTransform handles undefined priorBlock correctly for inserts
|
|
809
1008
|
const newBlock = applyTransform(priorBlock, transform);
|
|
@@ -815,24 +1014,32 @@ export class StorageRepo {
|
|
|
815
1014
|
// target, and with no prior revision there is none. With a prior `latest` an absent newBlock is
|
|
816
1015
|
// a legitimate tombstone (the walk resolves to an earlier materialization), so it stays allowed.
|
|
817
1016
|
if (!newBlock && latest === undefined) {
|
|
818
|
-
return await this.refuseMissingBase(blockId, actionId, rev, storage, 'no committed revision to apply the transform to');
|
|
1017
|
+
return await this.refuseMissingBase(blockId, actionId, rev, storage, latch, 'no committed revision to apply the transform to');
|
|
819
1018
|
}
|
|
820
1019
|
if (newBlock) {
|
|
821
|
-
await storage.saveMaterializedBlock(actionId, newBlock);
|
|
1020
|
+
await storage.saveMaterializedBlock(actionId, newBlock, latch);
|
|
822
1021
|
}
|
|
823
1022
|
// Save revision and promote action *before* updating latest
|
|
824
1023
|
// This ensures that if the process crashes between these steps,
|
|
825
1024
|
// the 'latest' pointer doesn't point to a revision that hasn't been fully recorded.
|
|
826
|
-
await storage.saveRevision(rev, actionId);
|
|
827
|
-
await storage.promotePendingTransaction(actionId);
|
|
1025
|
+
await storage.saveRevision(rev, actionId, latch);
|
|
1026
|
+
await storage.promotePendingTransaction(actionId, latch);
|
|
828
1027
|
// Update latest revision *last*
|
|
829
|
-
await storage.setLatest({ actionId, rev });
|
|
1028
|
+
await storage.setLatest({ actionId, rev }, latch);
|
|
1029
|
+
// Persist the cohort's commit proof AFTER the commit is durably latest — the proof is
|
|
1030
|
+
// evidence about a landed revision, never a precondition of landing it. The retention rule
|
|
1031
|
+
// (persist only when the LOCAL materialization matches the digest the commit op declared)
|
|
1032
|
+
// and its failure logging live in the shared helper; a proof-persist fault must not fail a
|
|
1033
|
+
// commit that already landed, so the helper never throws.
|
|
1034
|
+
if (proof !== undefined) {
|
|
1035
|
+
await this.persistProofIfContentMatches(blockId, actionId, rev, storage, proof, newBlock, latch);
|
|
1036
|
+
}
|
|
830
1037
|
// Prune the now-superseded prior materialization (checkpoint retention). Runs LAST — after the
|
|
831
1038
|
// new rev's materialization + revision + transform + setLatest are all durable — so no crash
|
|
832
1039
|
// point can leave a rev unrecoverable: a crash BEFORE this leaves a redundant (harmless)
|
|
833
1040
|
// materialization the next commit's prune reclaims; a crash AFTER is fully consistent. The prune
|
|
834
1041
|
// only ever deletes a materialization reconstructible from the retained floor + transforms. Runs
|
|
835
|
-
// under the
|
|
1042
|
+
// under the block write latch already held here, so it serializes against concurrent commits.
|
|
836
1043
|
// NOTE: prune targets ONLY the immediate prior. A crash between setLatest and this call leaves that
|
|
837
1044
|
// one prior materialization un-pruned; since a later commit prunes ITS OWN prior (never the earlier
|
|
838
1045
|
// leaked rev), that copy is NOT auto-reclaimed — a bounded (≤1 block-copy per crash), harmless leak
|
|
@@ -840,7 +1047,7 @@ export class StorageRepo {
|
|
|
840
1047
|
// add a bounded look-back (prune non-retained mats in [rev-checkpointInterval, rev)) here, or a
|
|
841
1048
|
// periodic reconciliation sweep — do NOT reintroduce a per-read re-cache.
|
|
842
1049
|
if (latest !== undefined) {
|
|
843
|
-
await storage.pruneSupersededMaterialization(latest);
|
|
1050
|
+
await storage.pruneSupersededMaterialization(latest, latch);
|
|
844
1051
|
}
|
|
845
1052
|
// Report the affected collection for change-event routing. For a delete the
|
|
846
1053
|
// materialized block is undefined, so fall back to the prior block's header.
|
|
@@ -848,6 +1055,81 @@ export class StorageRepo {
|
|
|
848
1055
|
// undefined so the caller skips it rather than emitting a bogus event.
|
|
849
1056
|
return newBlock?.header.collectionId ?? priorBlock?.header.collectionId;
|
|
850
1057
|
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Retain `proof` for a block this call found ALREADY committed at `(rev, actionId)` — the paths
|
|
1060
|
+
* that land (or find already landed) a revision without running {@link internalCommit}, and would
|
|
1061
|
+
* otherwise never retain a proof: the idempotent re-commit partition, the Crash-D3 `recover()`
|
|
1062
|
+
* partition, and {@link saveReplicatedBlock}'s monotonic no-op on a certified push. Strictly
|
|
1063
|
+
* additive: an existing proof is left alone, and the same digest-match rule as the fresh-commit
|
|
1064
|
+
* site decides retention.
|
|
1065
|
+
*
|
|
1066
|
+
* `rev`/`actionId` are passed separately rather than as a `CommitRequest` because the replica
|
|
1067
|
+
* caller has no commit request — it has the `(rev, actionId)` the push and the held revision
|
|
1068
|
+
* agree on.
|
|
1069
|
+
*
|
|
1070
|
+
* Callers must hold the block's write latch (`latch`). `getBlock` is local-only and can throw on
|
|
1071
|
+
* an unmaterializable or uncovered base — treated as "no local content", i.e. the proof is withheld.
|
|
1072
|
+
*/
|
|
1073
|
+
async backFillProof(blockId, storage, rev, actionId, proof, latch) {
|
|
1074
|
+
if (proof === undefined || await storage.getBlockProof(rev) !== undefined) {
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
let committedBlock;
|
|
1078
|
+
try {
|
|
1079
|
+
committedBlock = (await storage.getBlock(rev))?.block;
|
|
1080
|
+
}
|
|
1081
|
+
catch {
|
|
1082
|
+
committedBlock = undefined;
|
|
1083
|
+
}
|
|
1084
|
+
await this.persistProofIfContentMatches(blockId, actionId, rev, storage, proof, committedBlock, latch);
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* The single retention rule for {@link BlockCommitProof}s, shared by the fresh-commit site
|
|
1088
|
+
* ({@link internalCommit}, after `setLatest`) and the already-landed back-fill
|
|
1089
|
+
* ({@link backFillProof}):
|
|
1090
|
+
*
|
|
1091
|
+
* > **A member persists the proof only when its own materialization matches the digest the
|
|
1092
|
+
* > commit operation declared for this block.**
|
|
1093
|
+
*
|
|
1094
|
+
* One rule covers every awkward case without a second flag: a DIVERGED member (committed onto a
|
|
1095
|
+
* lagging base) computes a different hash, stores no proof, and falls back to corroboration
|
|
1096
|
+
* exactly as today — the `commit:proof-digest-mismatch` log line is also the first signal this
|
|
1097
|
+
* system has ever had that a member diverged. A member that abstained at vote time still checks
|
|
1098
|
+
* here (by commit time it HAS materialized) and legitimately keeps the proof on agreement. A
|
|
1099
|
+
* tombstone (no `block`) and a commit with no `blockDigests` (pre-upgrade client) declare no
|
|
1100
|
+
* digest and store no proof (`commit:proof-undeclared`).
|
|
1101
|
+
*
|
|
1102
|
+
* Never throws: the commit this proof describes already durably landed, so a proof-persist
|
|
1103
|
+
* fault must not turn `commit()` into `success:false` for a landed commit — it is logged and
|
|
1104
|
+
* the proof simply is not retained (repair falls back to corroboration).
|
|
1105
|
+
*
|
|
1106
|
+
* NOTE: one commit of N blocks stores the SAME proof under each block's `(blockId, rev)` proofs-store key, and
|
|
1107
|
+
* the proof itself carries the commit op's N `blockIds`/`blockDigests` — so bytes retained per
|
|
1108
|
+
* commit grow with N². Measured base cost is ~4.6 KB for a 10-peer 2-block commit
|
|
1109
|
+
* (`test/commit-proof.spec.ts` "size"), and nothing today bounds `CommitRequest.blockIds`. Fine
|
|
1110
|
+
* at the handful-of-blocks batches the transactor produces now; if per-coordinator batches ever
|
|
1111
|
+
* grow large, store the proof once under its `messageHash` and key each revision to a pointer.
|
|
1112
|
+
*/
|
|
1113
|
+
async persistProofIfContentMatches(blockId, actionId, rev, storage, proof, block, latch) {
|
|
1114
|
+
try {
|
|
1115
|
+
const declaredDigest = proofDeclaredDigest(proof, { blockId, rev, actionId });
|
|
1116
|
+
if (declaredDigest === undefined) {
|
|
1117
|
+
log('commit:proof-undeclared blockId=%s rev=%d actionId=%s', blockId, rev, actionId);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
// A digest was declared but this node materialized nothing (tombstone / unmaterializable
|
|
1121
|
+
// read on the back-fill path): the local content provably is not the declared content.
|
|
1122
|
+
const localDigest = block === undefined ? undefined : await canonicalBlockHash(block);
|
|
1123
|
+
if (localDigest !== declaredDigest) {
|
|
1124
|
+
log('commit:proof-digest-mismatch blockId=%s rev=%d actionId=%s declared=%s local=%s', blockId, rev, actionId, declaredDigest, localDigest);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
await storage.saveBlockProof(rev, proof, latch);
|
|
1128
|
+
}
|
|
1129
|
+
catch (err) {
|
|
1130
|
+
log('commit:proof-persist-failed blockId=%s rev=%d actionId=%s error=%s', blockId, rev, actionId, err instanceof Error ? err.message : String(err));
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
851
1133
|
/**
|
|
852
1134
|
* The materialization this commit builds on: the block at `latest`, or `undefined` when the block
|
|
853
1135
|
* holds no committed revision yet (the normal insert case).
|
|
@@ -871,16 +1153,37 @@ export class StorageRepo {
|
|
|
871
1153
|
* Narrowing this would require typed faults out of BlockStorage; until then, prefer the tolerant
|
|
872
1154
|
* reading.
|
|
873
1155
|
*/
|
|
874
|
-
async readCommitBase(blockId, actionId, rev, storage, latest) {
|
|
1156
|
+
async readCommitBase(blockId, actionId, rev, storage, latest, latch) {
|
|
875
1157
|
if (!latest) {
|
|
876
1158
|
return undefined;
|
|
877
1159
|
}
|
|
1160
|
+
// NOTE: this read is deliberately LOCAL-ONLY and does not heal. `getBlock` no longer restores
|
|
1161
|
+
// from a peer (that moved to the explicit `restoreRevision`, which `StorageRepo.get` calls), so a
|
|
1162
|
+
// base this node cannot materialize locally raises {@link MissingBaseRevisionError} here instead
|
|
1163
|
+
// of being fetched in line. The reason is the calling context, not the cost of a fetch: `commit`
|
|
1164
|
+
// holds the write latch of EVERY block in the batch across this call, and network I/O inside that
|
|
1165
|
+
// critical section makes one unreachable peer stall every writer of every block in the batch for
|
|
1166
|
+
// the length of a round trip. Healing is out-of-band instead — cohort reconcile supplies the
|
|
1167
|
+
// revision (`ClusterMember` → `saveReplicatedBlock`) and the action is retried, by which point
|
|
1168
|
+
// this read succeeds locally. Pinned by `test/storage-repo.spec.ts` "commit reads its base
|
|
1169
|
+
// locally", which wires a restore callback that would have answered and asserts it is never
|
|
1170
|
+
// called. Do not reintroduce a restore on this path; if a commit ever genuinely needs one, fetch
|
|
1171
|
+
// BEFORE taking the latches, not underneath them.
|
|
1172
|
+
//
|
|
1173
|
+
// NOTE: `latest.rev` is always inside `meta.ranges` today — every writer of `latest`
|
|
1174
|
+
// (`setLatest`, `saveForwardRevision`, `recover`) merges an open-ended range anchored at or
|
|
1175
|
+
// below the new latest in the same `saveMetadata` — so the RevisionNotCoveredError arm below
|
|
1176
|
+
// is unreachable from here and only truncated-history corruption lands in the catch. If a
|
|
1177
|
+
// future change can leave `latest` uncovered, the ordering in `get` becomes load-bearing: the
|
|
1178
|
+
// read-driven promotion runs BEFORE `readBlockHealing`, so a coverage gap under `latest` would
|
|
1179
|
+
// make `refuseMissingBase` delete the pending record moments before the healing read would
|
|
1180
|
+
// have restored it. Heal before refusing if that day comes.
|
|
878
1181
|
try {
|
|
879
1182
|
return (await storage.getBlock(latest.rev))?.block;
|
|
880
1183
|
}
|
|
881
1184
|
catch (err) {
|
|
882
1185
|
log('commit:unmaterializable-base blockId=%s baseRev=%d error=%s', blockId, latest.rev, err instanceof Error ? err.message : String(err));
|
|
883
|
-
return await this.refuseMissingBase(blockId, actionId, rev, storage, `local rev ${latest.rev} is not materializable here`);
|
|
1186
|
+
return await this.refuseMissingBase(blockId, actionId, rev, storage, latch, `local rev ${latest.rev} is not materializable here`);
|
|
884
1187
|
}
|
|
885
1188
|
}
|
|
886
1189
|
/**
|
|
@@ -893,8 +1196,8 @@ export class StorageRepo {
|
|
|
893
1196
|
* so a commit retry partitions the block as already-done/stale and never revisits the pending.
|
|
894
1197
|
* Leaving it would also report a phantom conflicting action from {@link pend} for every later write.
|
|
895
1198
|
*/
|
|
896
|
-
async refuseMissingBase(blockId, actionId, rev, storage, detail) {
|
|
897
|
-
await storage.deletePendingTransaction(actionId);
|
|
1199
|
+
async refuseMissingBase(blockId, actionId, rev, storage, latch, detail) {
|
|
1200
|
+
await storage.deletePendingTransaction(actionId, latch);
|
|
898
1201
|
log('commit:missing-base blockId=%s rev=%d actionId=%s detail=%s', blockId, rev, actionId, detail);
|
|
899
1202
|
throw new MissingBaseRevisionError(blockId, rev, detail);
|
|
900
1203
|
}
|