@optimystic/db-p2p 0.24.1 → 0.25.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/{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 +21 -4
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +28 -17
- 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 +78 -6
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +136 -57
- 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 +150 -62
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +2 -2
- 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 +128 -1
- package/dist/src/peer-address-book.d.ts.map +1 -1
- package/dist/src/peer-address-book.js +198 -15
- 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 +22 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +25 -13
- 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/routing/libp2p-known-peers.d.ts.map +1 -1
- package/dist/src/routing/libp2p-known-peers.js +5 -0
- package/dist/src/routing/libp2p-known-peers.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 +308 -293
- 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 +142 -58
- package/src/libp2p-node-base.ts +1793 -1675
- package/src/logger.ts +27 -27
- package/src/network/open-protocol-stream.ts +87 -0
- package/src/network/register-protocol-handler.ts +85 -0
- package/src/peer-address-book.ts +360 -149
- package/src/pend-validation.ts +89 -0
- package/src/reactivity/notify-transport.ts +145 -144
- package/src/reactivity/push-state-gossip.ts +292 -291
- 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 +332 -313
- package/src/reputation/types.ts +3 -1
- package/src/rn.ts +34 -4
- package/src/routing/libp2p-known-peers.ts +31 -26
- 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/cohort-topic-mesh-harness.ts +673 -673
- package/src/testing/mesh-harness.ts +524 -344
- package/src/testing/raw-storage-conformance.ts +114 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyTransform, canonicalJson, hashString } from "@optimystic/db-core";
|
|
2
2
|
import { mergeRanges } from "./helpers.js";
|
|
3
|
+
import { RevisionNotCoveredError } from "./i-block-storage.js";
|
|
3
4
|
import { createLogger } from "../logger.js";
|
|
4
5
|
const log = createLogger('block-storage');
|
|
5
6
|
/**
|
|
@@ -29,13 +30,34 @@ export class BlockStorage {
|
|
|
29
30
|
const meta = await this.storage.getMetadata(this.blockId);
|
|
30
31
|
return meta?.latest;
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Guard every write: the token must have been minted for THIS block and must still be live. The
|
|
35
|
+
* type already proves the caller went through `acquireBlockWriteLatch`; this catches the two
|
|
36
|
+
* things the type cannot — a token for block A presented to block B's storage, and a token
|
|
37
|
+
* stashed by a callback and used after its scope released the latch.
|
|
38
|
+
*/
|
|
39
|
+
assertLatch(latch) {
|
|
40
|
+
if (latch.blockId !== this.blockId) {
|
|
41
|
+
throw new Error(`Block ${this.blockId}: write latch was acquired for block ${latch.blockId}`);
|
|
42
|
+
}
|
|
43
|
+
if (!latch.live) {
|
|
44
|
+
throw new Error(`Block ${this.blockId}: write latch has already been released`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* LOCAL-ONLY read — never fetches from a peer. A revision outside `meta.ranges` throws
|
|
49
|
+
* {@link RevisionNotCoveredError}; the one caller allowed to heal that (`StorageRepo.get`) does so
|
|
50
|
+
* with {@link restoreRevision} under the block's write latch and re-reads. Keeping the fetch out of
|
|
51
|
+
* here is what lets the commit path hold N block latches with no network I/O inside them.
|
|
52
|
+
*/
|
|
32
53
|
async getBlock(rev) {
|
|
33
54
|
const meta = await this.storage.getMetadata(this.blockId);
|
|
34
55
|
if (!meta) {
|
|
35
56
|
// No metadata at all ⇒ this node has never seen the block, and reads report it absent
|
|
36
57
|
// WITHOUT consulting `restoreCallback`. That is deliberate, not an oversight: `restoreCallback`
|
|
37
|
-
// is reachable only from
|
|
38
|
-
//
|
|
58
|
+
// is reachable only from restoreRevision, which a caller invokes only after THIS method has
|
|
59
|
+
// reported a coverage gap on a block that has metadata — so a never-seen block is never fetched.
|
|
60
|
+
// Attempting a fetch for it would turn every read of a genuinely non-existent block —
|
|
39
61
|
// the common case for an insert probing for a collision — into a network round trip, because
|
|
40
62
|
// storage cannot tell "nobody has this" from "I don't have this".
|
|
41
63
|
//
|
|
@@ -45,56 +67,71 @@ export class BlockStorage {
|
|
|
45
67
|
// nothing beyond the latest-query it already performed. Keep this early return as-is.
|
|
46
68
|
return undefined;
|
|
47
69
|
}
|
|
48
|
-
// Pending-only state
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (meta.latest === undefined) {
|
|
57
|
-
if (rev === undefined) {
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
// A named rev still ATTEMPTS the restore: `restoreCallback` may be able to supply that
|
|
61
|
-
// revision even though nothing is committed locally, and a successful restore serves real
|
|
62
|
-
// content with `latest` still undefined. That capability is pinned by the 'getBlock for an
|
|
63
|
-
// absent revision fires restoreCallback (restore not short-circuited)' test in
|
|
64
|
-
// test/block-storage.spec.ts — do not short-circuit it away.
|
|
65
|
-
//
|
|
66
|
-
// Only ensureRevision's FAILURE is swallowed (no callback wired, or restore could not
|
|
67
|
-
// supply the rev): that is precisely the "no committed base here" absence. materializeBlock
|
|
68
|
-
// below is deliberately OUTSIDE the try — a throw from there means revision records exist
|
|
69
|
-
// with no materialization anywhere under them, which is genuine corruption and must keep
|
|
70
|
-
// reading as `unmaterializable`.
|
|
71
|
-
//
|
|
72
|
-
// NOTE: a contextful read of a pending-only block still attempts a network restore before
|
|
73
|
-
// falling back to absent (same cost as the pre-fix throw path); if pending-only read-backs
|
|
74
|
-
// ever show as hot, short-circuit when ranges are empty.
|
|
75
|
-
try {
|
|
76
|
-
await this.ensureRevision(meta, rev);
|
|
77
|
-
}
|
|
78
|
-
catch (err) {
|
|
79
|
-
log('getBlock:no-committed-base blockId=%s rev=%d error=%s', this.blockId, rev, err instanceof Error ? err.message : String(err));
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
return await this.materializeBlock(meta, rev);
|
|
70
|
+
// Pending-only state (metadata seeded by savePendingTransaction, nothing committed) with no
|
|
71
|
+
// revision named: "no committed base here" is an ABSENCE, not a fault — answer `undefined`.
|
|
72
|
+
// StorageRepo.get then applies any pending overlay over that absent base. A NAMED rev on a
|
|
73
|
+
// pending-only block falls through: it is covered only if an earlier restore brought it in
|
|
74
|
+
// (a restore can serve real content with `latest` still undefined), otherwise it reports the
|
|
75
|
+
// gap below and StorageRepo.get decides whether the failed restore reads as absent.
|
|
76
|
+
if (meta.latest === undefined && rev === undefined) {
|
|
77
|
+
return undefined;
|
|
83
78
|
}
|
|
84
79
|
const targetRev = rev ?? meta.latest.rev;
|
|
85
|
-
|
|
80
|
+
if (!this.inRanges(targetRev, meta.ranges)) {
|
|
81
|
+
throw new RevisionNotCoveredError(this.blockId, targetRev);
|
|
82
|
+
}
|
|
83
|
+
// A throw from here means revision records exist with no materialization anywhere under them —
|
|
84
|
+
// genuine corruption, which StorageRepo.get reports as `unmaterializable`.
|
|
86
85
|
return await this.materializeBlock(meta, targetRev);
|
|
87
86
|
}
|
|
87
|
+
async restoreRevision(rev, latch) {
|
|
88
|
+
this.assertLatch(latch);
|
|
89
|
+
// One metadata read, under the held latch: the caller's earlier `getBlock` observed a gap, but
|
|
90
|
+
// a queued-ahead restore or replica may have filled it before this latch was granted.
|
|
91
|
+
const meta = await this.storage.getMetadata(this.blockId);
|
|
92
|
+
if (!meta) {
|
|
93
|
+
// Same reasoning as getBlock's early return: a never-seen block is not restored here.
|
|
94
|
+
throw new Error(`Block ${this.blockId} has no metadata; a never-seen block is not restored here.`);
|
|
95
|
+
}
|
|
96
|
+
if (this.inRanges(rev, meta.ranges)) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const restored = await this.restoreBlock(rev);
|
|
100
|
+
// An archive off this wire is a peer's UNVERIFIED answer (see {@link saveRestored}), so it
|
|
101
|
+
// is vetted before a byte of it reaches storage. A rejected archive is indistinguishable to
|
|
102
|
+
// the caller from an absent one — same throw — because both mean the same thing: this node
|
|
103
|
+
// still cannot serve `rev`. The specific reason is logged rather than thrown so that
|
|
104
|
+
// `StorageRepo.get`'s healing helper keeps one rule for every restore failure.
|
|
105
|
+
const coverage = restored ? await this.vetRestoredArchive(restored, rev) : undefined;
|
|
106
|
+
if (!restored || !coverage) {
|
|
107
|
+
throw new Error(`Block ${this.blockId} revision ${rev} not found during restore attempt.`);
|
|
108
|
+
}
|
|
109
|
+
await this.saveRestored(restored);
|
|
110
|
+
// The vetted coverage, NOT `restored.range`. The declared range is checked for internal
|
|
111
|
+
// consistency above but is not what gets recorded — see {@link vetRestoredArchive} for why
|
|
112
|
+
// the pin has to be folded in, or the same restore repeats on every read forever.
|
|
113
|
+
meta.ranges.unshift(coverage);
|
|
114
|
+
meta.ranges = mergeRanges(meta.ranges);
|
|
115
|
+
await this.storage.saveMetadata(this.blockId, meta);
|
|
116
|
+
}
|
|
88
117
|
async getTransaction(actionId) {
|
|
89
118
|
return await this.storage.getTransaction(this.blockId, actionId);
|
|
90
119
|
}
|
|
120
|
+
async getBlockProof(rev) {
|
|
121
|
+
return await this.storage.getBlockProof(this.blockId, rev);
|
|
122
|
+
}
|
|
123
|
+
async saveBlockProof(rev, proof, latch) {
|
|
124
|
+
this.assertLatch(latch);
|
|
125
|
+
await this.storage.saveBlockProof(this.blockId, rev, proof);
|
|
126
|
+
}
|
|
91
127
|
async getPendingTransaction(actionId) {
|
|
92
128
|
return await this.storage.getPendingTransaction(this.blockId, actionId);
|
|
93
129
|
}
|
|
94
130
|
async *listPendingTransactions() {
|
|
95
131
|
yield* this.storage.listPendingTransactions(this.blockId);
|
|
96
132
|
}
|
|
97
|
-
async savePendingTransaction(actionId, transform) {
|
|
133
|
+
async savePendingTransaction(actionId, transform, latch) {
|
|
134
|
+
this.assertLatch(latch);
|
|
98
135
|
log('pend blockId=%s actionId=%s', this.blockId, actionId);
|
|
99
136
|
let meta = await this.storage.getMetadata(this.blockId);
|
|
100
137
|
if (!meta) {
|
|
@@ -102,23 +139,29 @@ export class BlockStorage {
|
|
|
102
139
|
// nothing yet: seed empty ranges. The first commit anchors an OPEN-ENDED span at
|
|
103
140
|
// the earliest held rev E ([E, +inf)); later commits/recover merge into it via
|
|
104
141
|
// setLatest/recover. Seeding open-ended `[[0]]` would falsely claim coverage of the
|
|
105
|
-
// un-held revs below E and disable
|
|
142
|
+
// un-held revs below E and disable restoreRevision's restore path.
|
|
143
|
+
//
|
|
144
|
+
// This read-then-seed is exactly the window a concurrent replica used to land in (the
|
|
145
|
+
// seed then erased its `latest`); the latch the caller holds is what closes it.
|
|
106
146
|
meta = { latest: undefined, ranges: [] };
|
|
107
147
|
await this.storage.saveMetadata(this.blockId, meta);
|
|
108
148
|
}
|
|
109
149
|
await this.storage.savePendingTransaction(this.blockId, actionId, transform);
|
|
110
150
|
}
|
|
111
|
-
async deletePendingTransaction(actionId) {
|
|
151
|
+
async deletePendingTransaction(actionId, latch) {
|
|
152
|
+
this.assertLatch(latch);
|
|
112
153
|
log('cancel blockId=%s actionId=%s', this.blockId, actionId);
|
|
113
154
|
await this.storage.deletePendingTransaction(this.blockId, actionId);
|
|
114
155
|
}
|
|
115
156
|
async *listRevisions(startRev, endRev) {
|
|
116
157
|
yield* this.storage.listRevisions(this.blockId, startRev, endRev);
|
|
117
158
|
}
|
|
118
|
-
async saveMaterializedBlock(actionId, block) {
|
|
159
|
+
async saveMaterializedBlock(actionId, block, latch) {
|
|
160
|
+
this.assertLatch(latch);
|
|
119
161
|
await this.storage.saveMaterializedBlock(this.blockId, actionId, block);
|
|
120
162
|
}
|
|
121
|
-
async pruneSupersededMaterialization(prior) {
|
|
163
|
+
async pruneSupersededMaterialization(prior, latch) {
|
|
164
|
+
this.assertLatch(latch);
|
|
122
165
|
const meta = await this.storage.getMetadata(this.blockId);
|
|
123
166
|
// No metadata / no committed tip yet ⇒ nothing has superseded `prior`; leave it.
|
|
124
167
|
if (!meta || meta.latest === undefined) {
|
|
@@ -136,14 +179,17 @@ export class BlockStorage {
|
|
|
136
179
|
await this.storage.saveMaterializedBlock(this.blockId, prior.actionId, undefined);
|
|
137
180
|
log('prune blockId=%s rev=%d actionId=%s', this.blockId, prior.rev, prior.actionId);
|
|
138
181
|
}
|
|
139
|
-
async saveRevision(rev, actionId) {
|
|
182
|
+
async saveRevision(rev, actionId, latch) {
|
|
183
|
+
this.assertLatch(latch);
|
|
140
184
|
await this.storage.saveRevision(this.blockId, rev, actionId);
|
|
141
185
|
}
|
|
142
|
-
async promotePendingTransaction(actionId) {
|
|
186
|
+
async promotePendingTransaction(actionId, latch) {
|
|
187
|
+
this.assertLatch(latch);
|
|
143
188
|
log('commit blockId=%s actionId=%s', this.blockId, actionId);
|
|
144
189
|
await this.storage.promotePendingTransaction(this.blockId, actionId);
|
|
145
190
|
}
|
|
146
|
-
async setLatest(latest) {
|
|
191
|
+
async setLatest(latest, latch) {
|
|
192
|
+
this.assertLatch(latch);
|
|
147
193
|
const meta = await this.storage.getMetadata(this.blockId);
|
|
148
194
|
if (!meta) {
|
|
149
195
|
throw new Error(`Block ${this.blockId} not found`);
|
|
@@ -162,13 +208,14 @@ export class BlockStorage {
|
|
|
162
208
|
// the un-held revs below E). Claim open-ended from the prior latest (>= E via merge); the first
|
|
163
209
|
// commit (prevRev undefined) anchors the span at E = L. mergeRanges folds it into the existing
|
|
164
210
|
// [E, +inf). Only revs BELOW E miss inRanges, which is exactly the genuine-gap/restore case.
|
|
165
|
-
// Range + latest advance in one saveMetadata write (atomic under the
|
|
211
|
+
// Range + latest advance in one saveMetadata write (atomic under the block write latch), so a crash
|
|
166
212
|
// before this call advances neither.
|
|
167
213
|
meta.ranges.unshift([prevRev ?? latest.rev]);
|
|
168
214
|
meta.ranges = mergeRanges(meta.ranges);
|
|
169
215
|
await this.storage.saveMetadata(this.blockId, meta);
|
|
170
216
|
}
|
|
171
|
-
async recover() {
|
|
217
|
+
async recover(latch) {
|
|
218
|
+
this.assertLatch(latch);
|
|
172
219
|
const meta = await this.storage.getMetadata(this.blockId);
|
|
173
220
|
if (!meta) {
|
|
174
221
|
return { reconciled: false };
|
|
@@ -204,7 +251,8 @@ export class BlockStorage {
|
|
|
204
251
|
}
|
|
205
252
|
return { reconciled: false, latest: meta.latest };
|
|
206
253
|
}
|
|
207
|
-
async saveReplica(block, source) {
|
|
254
|
+
async saveReplica(block, source, proof, latch) {
|
|
255
|
+
this.assertLatch(latch);
|
|
208
256
|
const rev = source?.rev ?? 1;
|
|
209
257
|
// Deterministic fallback id when the sender carried no revision metadata, so a
|
|
210
258
|
// re-push of the same block resolves to the same (rev, actionId) and stays
|
|
@@ -213,9 +261,10 @@ export class BlockStorage {
|
|
|
213
261
|
// Replica revision carries the materialized block. `{ insert: block }` satisfies saveRestored's
|
|
214
262
|
// write invariants; on the serving path materializeBlock returns the materialized block directly
|
|
215
263
|
// (single rev), so this transform is never applied — see ticket notes.
|
|
216
|
-
return await this.saveForwardRevision(rev, actionId, { action: { actionId, rev, transform: { insert: block } }, block }, 'replica');
|
|
264
|
+
return await this.saveForwardRevision(rev, actionId, { action: { actionId, rev, transform: { insert: block } }, block }, 'replica', proof);
|
|
217
265
|
}
|
|
218
|
-
async saveDeletion(source) {
|
|
266
|
+
async saveDeletion(source, latch) {
|
|
267
|
+
this.assertLatch(latch);
|
|
219
268
|
const { rev, actionId } = source;
|
|
220
269
|
// Forward tombstone: a `{ delete: true }` transform and NO materialized block. saveRestored
|
|
221
270
|
// skips materialization when `block` is absent, so the reverse-apply in materializeBlock
|
|
@@ -231,102 +280,84 @@ export class BlockStorage {
|
|
|
231
280
|
* plus the materialized `block`; a deletion carries `{ delete: true }` and no block. `rev` and
|
|
232
281
|
* `actionId` are passed alongside `body` because the guard and the `latest` advance need them
|
|
233
282
|
* independently of the archive body.
|
|
283
|
+
*
|
|
284
|
+
* `verifiedProof` travels OUTSIDE `body` on purpose — see {@link saveRestored}: it is the one
|
|
285
|
+
* channel that persists a proof, and only {@link saveReplica} (whose caller verified the proof
|
|
286
|
+
* against `body.block`) supplies it.
|
|
234
287
|
*/
|
|
235
|
-
async saveForwardRevision(rev, actionId, body, logLabel) {
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
range: [rev, rev + 1]
|
|
259
|
-
};
|
|
260
|
-
await this.saveRestored(archive);
|
|
261
|
-
// INVARIANT P: a block never holds a pending record AND a committed record for the same
|
|
262
|
-
// action id. On the commit path `promotePendingTransaction` maintains it by MOVING the
|
|
263
|
-
// record atomically; this forward path writes the committed transform directly (via
|
|
264
|
-
// saveRestored above), so it owes the deletion itself. Without it, a node that pended the
|
|
265
|
-
// action but diverged before committing keeps a record nothing can ever promote — reported
|
|
266
|
-
// as a phantom conflicting action by every later `pend` on the block, which under
|
|
267
|
-
// `policy: 'f'` refuses that node's participation in the block's writes permanently.
|
|
268
|
-
//
|
|
269
|
-
// Deliberately on the WRITE path only: the monotonic guard above returns before here, and
|
|
270
|
-
// that early return must stay a true no-op (the earlier call that wrote the revision is the
|
|
271
|
-
// one that owed the deletion). Deliberately here rather than in `saveRestored`, which is
|
|
272
|
-
// also reached from ensureRevision's historical restore under a different latch, where a
|
|
273
|
-
// deletion could race a concurrent promotePendingTransaction; this path holds
|
|
274
|
-
// `BlockStorage.saveReplica:<id>` and (via StorageRepo.saveReplicatedBlock) the per-block
|
|
275
|
-
// commit latch, so it is already mutually exclusive with a live commit.
|
|
276
|
-
//
|
|
277
|
-
// NOTE: deletes only this revision's actionId, not every pending whose action is already
|
|
278
|
-
// committed. A broader sweep would repair records orphaned by routes that do not carry the
|
|
279
|
-
// committing actionId; if orphaned pendings ever show up in the field on blocks whose
|
|
280
|
-
// committing action id differs, widen to a sweep over listPendingTransactions filtered by
|
|
281
|
-
// getTransaction.
|
|
282
|
-
await this.storage.deletePendingTransaction(this.blockId, actionId);
|
|
283
|
-
// Seed metadata when absent, advance latest, and merge the covered range.
|
|
284
|
-
const prevRev = meta?.latest?.rev;
|
|
285
|
-
if (!meta) {
|
|
286
|
-
meta = { latest: undefined, ranges: [] };
|
|
287
|
-
}
|
|
288
|
-
meta.latest = { rev, actionId };
|
|
289
|
-
// Open-ended coverage from the earliest held rev (see setLatest): the descending walk serves
|
|
290
|
-
// any rev >= the anchor. A prior latest at prevRev (< rev per the monotonic guard) is a
|
|
291
|
-
// materialized point, so anchor at prevRev; the first write (prevRev undefined) anchors at
|
|
292
|
-
// rev. Freshness of a stale replica is a separate (replication-lag) concern from what this
|
|
293
|
-
// node can locally reconstruct, which is exactly what ranges records.
|
|
294
|
-
meta.ranges.unshift([prevRev ?? rev]);
|
|
295
|
-
meta.ranges = mergeRanges(meta.ranges);
|
|
296
|
-
await this.storage.saveMetadata(this.blockId, meta);
|
|
297
|
-
log('%s:save blockId=%s rev=%d actionId=%s', logLabel, this.blockId, rev, actionId);
|
|
288
|
+
async saveForwardRevision(rev, actionId, body, logLabel, verifiedProof) {
|
|
289
|
+
// The read-modify-write of this block's metadata below is serialized by the write latch the
|
|
290
|
+
// caller already holds (asserted in saveReplica / saveDeletion) — the same latch every other
|
|
291
|
+
// writer of this block holds, so a concurrent replica, deletion, restore, commit, or pend
|
|
292
|
+
// cannot land inside the window between the read and the saveMetadata.
|
|
293
|
+
let meta = await this.storage.getMetadata(this.blockId);
|
|
294
|
+
// Monotonic guard: an equal-or-newer revision is already held. The block (or tombstone) is
|
|
295
|
+
// durably present; do not downgrade `latest` or rewrite the metadata.
|
|
296
|
+
//
|
|
297
|
+
// This skip returns before persisting anything, INCLUDING `verifiedProof` — deliberately.
|
|
298
|
+
// The proof was verified against the PUSHED bytes; persisting it here would attach it to
|
|
299
|
+
// this node's HELD materialization, whose bytes at the same `(rev, actionId)` may differ if
|
|
300
|
+
// this holder diverged. A stored proof whose declared digest contradicts local content makes
|
|
301
|
+
// this node serve content that fails its own proof, and `digest-mismatch` is ATTRIBUTABLE in
|
|
302
|
+
// `cluster/certified-claims.ts` — every receiver would penalize it.
|
|
303
|
+
//
|
|
304
|
+
// Back-filling a proof onto an already-held revision therefore happens one layer up, in
|
|
305
|
+
// `StorageRepo.saveReplicatedBlock`'s non-advancing branch, which routes it through
|
|
306
|
+
// `backFillProof` → `persistProofIfContentMatches` — the rule that persists only when the
|
|
307
|
+
// LOCAL materialization matches the digest the commit op declared. Keep this guard a true
|
|
308
|
+
// no-op; the digest check is what makes the back-fill safe, and it does not belong here.
|
|
309
|
+
if (meta?.latest && meta.latest.rev >= rev) {
|
|
310
|
+
log('%s:skip blockId=%s rev=%d held=%d', logLabel, this.blockId, rev, meta.latest.rev);
|
|
298
311
|
return meta.latest;
|
|
299
312
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
313
|
+
// One-revision archive. A replica's body carries the materialized block; a deletion's body
|
|
314
|
+
// omits it (forward tombstone). saveRestored skips materialization when `block` is absent,
|
|
315
|
+
// so a tombstone reverse-applies to an absent block (read back as undefined).
|
|
316
|
+
const archive = {
|
|
317
|
+
blockId: this.blockId,
|
|
318
|
+
revisions: {
|
|
319
|
+
[rev]: body
|
|
320
|
+
},
|
|
321
|
+
range: [rev, rev + 1]
|
|
322
|
+
};
|
|
323
|
+
await this.saveRestored(archive, verifiedProof ? { rev, proof: verifiedProof } : undefined);
|
|
324
|
+
// INVARIANT P: a block never holds a pending record AND a committed record for the same
|
|
325
|
+
// action id. On the commit path `promotePendingTransaction` maintains it by MOVING the
|
|
326
|
+
// record atomically; this forward path writes the committed transform directly (via
|
|
327
|
+
// saveRestored above), so it owes the deletion itself. Without it, a node that pended the
|
|
328
|
+
// action but diverged before committing keeps a record nothing can ever promote — reported
|
|
329
|
+
// as a phantom conflicting action by every later `pend` on the block, which under
|
|
330
|
+
// `policy: 'f'` refuses that node's participation in the block's writes permanently.
|
|
331
|
+
//
|
|
332
|
+
// Deliberately on the WRITE path only: the monotonic guard above returns before here, and
|
|
333
|
+
// that early return must stay a true no-op (the earlier call that wrote the revision is the
|
|
334
|
+
// one that owed the deletion). Deliberately here rather than in `saveRestored`, which is
|
|
335
|
+
// also reached from restoreRevision's historical restore, where a held revision's pending
|
|
336
|
+
// record is not this writer's to delete. Both paths run under the block's write latch, so
|
|
337
|
+
// this deletion is already mutually exclusive with a live commit.
|
|
338
|
+
//
|
|
339
|
+
// NOTE: deletes only this revision's actionId, not every pending whose action is already
|
|
340
|
+
// committed. A broader sweep would repair records orphaned by routes that do not carry the
|
|
341
|
+
// committing actionId; if orphaned pendings ever show up in the field on blocks whose
|
|
342
|
+
// committing action id differs, widen to a sweep over listPendingTransactions filtered by
|
|
343
|
+
// getTransaction.
|
|
344
|
+
await this.storage.deletePendingTransaction(this.blockId, actionId);
|
|
345
|
+
// Seed metadata when absent, advance latest, and merge the covered range.
|
|
346
|
+
const prevRev = meta?.latest?.rev;
|
|
347
|
+
if (!meta) {
|
|
348
|
+
meta = { latest: undefined, ranges: [] };
|
|
329
349
|
}
|
|
350
|
+
meta.latest = { rev, actionId };
|
|
351
|
+
// Open-ended coverage from the earliest held rev (see setLatest): the descending walk serves
|
|
352
|
+
// any rev >= the anchor. A prior latest at prevRev (< rev per the monotonic guard) is a
|
|
353
|
+
// materialized point, so anchor at prevRev; the first write (prevRev undefined) anchors at
|
|
354
|
+
// rev. Freshness of a stale replica is a separate (replication-lag) concern from what this
|
|
355
|
+
// node can locally reconstruct, which is exactly what ranges records.
|
|
356
|
+
meta.ranges.unshift([prevRev ?? rev]);
|
|
357
|
+
meta.ranges = mergeRanges(meta.ranges);
|
|
358
|
+
await this.storage.saveMetadata(this.blockId, meta);
|
|
359
|
+
log('%s:save blockId=%s rev=%d actionId=%s', logLabel, this.blockId, rev, actionId);
|
|
360
|
+
return meta.latest;
|
|
330
361
|
}
|
|
331
362
|
async materializeBlock(meta, targetRev) {
|
|
332
363
|
let block;
|
|
@@ -373,11 +404,30 @@ export class BlockStorage {
|
|
|
373
404
|
// NOTE: cold non-checkpoint historical reads re-replay every time (up to `checkpointInterval`
|
|
374
405
|
// forward transforms). Acceptable — historical reads are rare and replay is depth-bounded. If
|
|
375
406
|
// they ever show as hot, cache at the nearest checkpoint below the target instead of skipping.
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
407
|
+
// NOTE: this `saveMaterializedBlock` is the ONE named exclusion from the storage invariant that
|
|
408
|
+
// every write to a block holds `blockWriteLatchKey(blockId)` (see block-latch.ts). It runs on the
|
|
409
|
+
// READ path, unlatched, and that is safe for CONTENT because it is not a read-modify-write of
|
|
410
|
+
// anything: the key is `(blockId, actionId)` and the value is a deterministic replay of
|
|
411
|
+
// transforms this node has already retained, so a concurrent SAVE of the same key writes the
|
|
412
|
+
// same bytes. It touches neither the metadata blob nor any revision record, so it cannot
|
|
413
|
+
// clobber `latest`. Taking the latch here would put a lock acquisition on every cold historical
|
|
414
|
+
// read and would deadlock the callers that already hold it. Dropping the re-cache entirely is
|
|
415
|
+
// the other way to close the exclusion, and is out of scope until someone measures the cold
|
|
416
|
+
// historical-read cost of doing without it.
|
|
417
|
+
//
|
|
418
|
+
// NOTE: the one racer that does NOT write the same bytes at this key is
|
|
419
|
+
// `pruneSupersededMaterialization`, which DELETES it (saveMaterializedBlock(..., undefined)).
|
|
420
|
+
// Losing that race resurrects a materialization the sweep just removed — a bounded storage
|
|
421
|
+
// leak, never wrong content, since the resurrected bytes are a correct materialization of that
|
|
422
|
+
// rev. The fresh `retentionMeta` read below narrows the window but cannot close it: a commit
|
|
423
|
+
// can land and prune between that read and this save. If materialization storage is ever seen
|
|
424
|
+
// to grow under read load, close it by having the prune win — e.g. re-check retention inside
|
|
425
|
+
// the raw driver's save, or have the sweep re-run after the read.
|
|
426
|
+
//
|
|
427
|
+
// Read metadata FRESH for the retention decision: because this runs outside the block's write
|
|
428
|
+
// latch, a concurrent restore or commit may have moved `ranges` since `meta` was captured. A stale
|
|
429
|
+
// `meta.ranges` would send rangeFloorOf into its fallback (treats the target as its own floor
|
|
430
|
+
// ⇒ wrongly "retained"), re-caching a rev the sweep means to prune — regrowing storage via reads.
|
|
381
431
|
const retentionMeta = (await this.storage.getMetadata(this.blockId)) ?? meta;
|
|
382
432
|
const cacheRev = actions[0].rev;
|
|
383
433
|
const latestRev = retentionMeta.latest?.rev ?? cacheRev;
|
|
@@ -394,15 +444,228 @@ export class BlockStorage {
|
|
|
394
444
|
return undefined;
|
|
395
445
|
return await this.restoreCallback(this.blockId, rev);
|
|
396
446
|
}
|
|
397
|
-
|
|
447
|
+
/**
|
|
448
|
+
* Vet an archive fetched for a PINNED restore of `rev`, returning the revision coverage to record
|
|
449
|
+
* for it — or `undefined` when the archive must be refused, in which case nothing is written at
|
|
450
|
+
* all and the reason is logged.
|
|
451
|
+
*
|
|
452
|
+
* This is the whole trust boundary for the restore wire. `restoreBlock`'s
|
|
453
|
+
* `RestorationCoordinator` verifies nothing about a response (`queryPeer` returns
|
|
454
|
+
* `response.archive` straight through), so every field below is a remote peer's assertion, and
|
|
455
|
+
* `saveRestored` writes keyed by REVISION and by ACTION ID — meaning an archive naming a
|
|
456
|
+
* revision or action id this node already holds would otherwise overwrite content that was never
|
|
457
|
+
* in question. The checks, in order:
|
|
458
|
+
*
|
|
459
|
+
* - **The archive is about this block.** `saveRestored` writes under `this.blockId` and ignores
|
|
460
|
+
* `archive.blockId`, so an answer about a different block would land as this block's history.
|
|
461
|
+
* - **Every revision key is a real revision.** Keys arrive as JSON strings; a non-numeric key
|
|
462
|
+
* coerces to `NaN` and would be stored as a garbage revision number. Min/max are folded rather
|
|
463
|
+
* than spread through `Math.min`/`Math.max`, which throws `RangeError` past ~125k arguments —
|
|
464
|
+
* reachable inside the 8 MiB sync-response cap (see `maxArchiveRevision`, same hazard).
|
|
465
|
+
* - **Each entry's own `rev`, when it declares one, agrees with the key it is filed under.**
|
|
466
|
+
* That disagreement IS the mislabel this ticket's family of bugs is about, in miniature.
|
|
467
|
+
* - **The archive answers the pin.** NOT "carries revision `rev`" — `ActionContext.rev` is a
|
|
468
|
+
* COLLECTION-wide revision, so it routinely sits above the revision at which this particular
|
|
469
|
+
* block last changed. A peer answering a pin at 9 for a block whose last commit was rev 2
|
|
470
|
+
* correctly serves rev 2, labelled as rev 2 (pinned in `test/block-archive-proof.spec.ts`).
|
|
471
|
+
* So the rule is that the archive's LOWEST revision is at or below the pin: `materializeBlock`
|
|
472
|
+
* descends from `rev`, so an archive entirely above the pin answers a different question and
|
|
473
|
+
* is exactly the "old bytes under a newer label" shape that overwrites good local data.
|
|
474
|
+
* - **The declared `range` agrees with the revisions actually carried** — it starts at the
|
|
475
|
+
* lowest (the floor must be present, or the descending walk has nothing to land on) and ends
|
|
476
|
+
* past the highest. An OPEN-ENDED range is refused outright: it would claim infinite coverage
|
|
477
|
+
* and permanently disable restore for this block on one unverified peer's say-so.
|
|
478
|
+
* (`RestoreCallback` allows open-ended for the UNPINNED call; `restoreRevision` never makes one.)
|
|
479
|
+
* - **Nothing already held is overwritten with different content** — see
|
|
480
|
+
* {@link noDivergentRewrite}.
|
|
481
|
+
*
|
|
482
|
+
* ## What gets recorded, and the one thing taken on trust
|
|
483
|
+
*
|
|
484
|
+
* The coverage returned is `[lowest, rev + 1)` — the archive's floor, up to the PIN and no
|
|
485
|
+
* further. Both halves of that are deliberate.
|
|
486
|
+
*
|
|
487
|
+
* Extending UP to the pin is an INFERENCE, and the only one here: a peer
|
|
488
|
+
* answering a pinned fetch with revision M ≤ N means "M is my highest committed revision of this
|
|
489
|
+
* block at or below N", i.e. nothing changed in (M, N]. This node cannot verify that locally.
|
|
490
|
+
*
|
|
491
|
+
* It is recorded anyway because the alternative is worse. `meta.ranges` is what
|
|
492
|
+
* {@link restoreRevision} consults to decide whether to fetch at all, so recording only the
|
|
493
|
+
* archive's literal `[M, M+1)` leaves `inRanges(N)` false and re-runs the ENTIRE restore — network
|
|
494
|
+
* round trip plus a full `saveRestored` write — on every later read at that pin, forever, never
|
|
495
|
+
* converging. The inference is also unavoidable rather than merely convenient: having the peer
|
|
496
|
+
* state the claim on the wire instead would not make it verifiable, only explicit, while breaking
|
|
497
|
+
* repair against every peer running an older build.
|
|
498
|
+
*
|
|
499
|
+
* Stopping AT the pin, on the other hand, discards coverage for any revision the archive
|
|
500
|
+
* volunteered ABOVE it. Those entries are still WRITTEN — an honest peer serves a contiguous
|
|
501
|
+
* span, so an archive of `{2, 3, 4}` answering a pin at 3 is normal, not hostile — they are
|
|
502
|
+
* merely not CLAIMED. `rev` is the one number in the exchange this node chose, so it is as far as
|
|
503
|
+
* its trust in the answer should reach; recording `highest + 1` instead would let the peer set
|
|
504
|
+
* the width of its own credibility by padding the archive with fabricated high revisions, and
|
|
505
|
+
* reads across that padded span would then be served from local content without ever re-asking.
|
|
506
|
+
* The cost is one redundant fetch the first time a revision above the pin is read; that fetch is
|
|
507
|
+
* idempotent (identical content is not a conflict, see {@link noDivergentRewrite}) and the
|
|
508
|
+
* coverage converges.
|
|
509
|
+
*
|
|
510
|
+
* NOTE: accepted tradeoff — a lying peer's answer is therefore STICKY across the whole span it
|
|
511
|
+
* was asked about: reads between M and N are served locally from M's content and never re-ask, so
|
|
512
|
+
* a later honest peer is never consulted for them. Weighed against an unbounded re-fetch loop and
|
|
513
|
+
* kept; that is the same "ranges records what this node can locally reconstruct, freshness is a
|
|
514
|
+
* separate concern" position `setLatest` and `saveForwardRevision` already take. Revisit if a
|
|
515
|
+
* restore ever gains a way to verify an archive (a commit proof chain over the served revision
|
|
516
|
+
* would do it) — at that point record only what verifies.
|
|
517
|
+
*/
|
|
518
|
+
async vetRestoredArchive(archive, rev) {
|
|
519
|
+
const refuse = (why, ...args) => {
|
|
520
|
+
log(`restore:refused blockId=%s rev=%d ${why}`, this.blockId, rev, ...args);
|
|
521
|
+
return undefined;
|
|
522
|
+
};
|
|
523
|
+
if (archive.blockId !== this.blockId) {
|
|
524
|
+
return refuse('archive is for blockId=%s', archive.blockId);
|
|
525
|
+
}
|
|
526
|
+
const entries = [];
|
|
527
|
+
let lowest;
|
|
528
|
+
let highest;
|
|
529
|
+
for (const [key, entry] of Object.entries(archive.revisions ?? {})) {
|
|
530
|
+
const entryRev = Number(key);
|
|
531
|
+
// `String(entryRev) === key`, not merely "parses as an integer". `saveRestored` re-derives
|
|
532
|
+
// the number with its own `Number(key)`, so any key with a second spelling (`"02"`, `" 2"`,
|
|
533
|
+
// `"2e1"`) lets ONE archive file two entries under one revision: the vet checks both, the
|
|
534
|
+
// write keeps whichever `Object.entries` yields last, and which one that is was never the
|
|
535
|
+
// question either check answered.
|
|
536
|
+
if (!Number.isInteger(entryRev) || entryRev < 1 || String(entryRev) !== key) {
|
|
537
|
+
return refuse('revision key %s is not a revision', key);
|
|
538
|
+
}
|
|
539
|
+
const action = entry?.action;
|
|
540
|
+
if (!action?.actionId) {
|
|
541
|
+
return refuse('revision %d carries no action', entryRev);
|
|
542
|
+
}
|
|
543
|
+
if (action.rev !== undefined && action.rev !== entryRev) {
|
|
544
|
+
return refuse('revision %d is filed under an action declaring rev=%d', entryRev, action.rev);
|
|
545
|
+
}
|
|
546
|
+
entries.push({ rev: entryRev, action, block: entry.block });
|
|
547
|
+
if (lowest === undefined || entryRev < lowest)
|
|
548
|
+
lowest = entryRev;
|
|
549
|
+
if (highest === undefined || entryRev > highest)
|
|
550
|
+
highest = entryRev;
|
|
551
|
+
}
|
|
552
|
+
if (lowest === undefined || highest === undefined) {
|
|
553
|
+
return refuse('carries no revisions');
|
|
554
|
+
}
|
|
555
|
+
if (lowest > rev) {
|
|
556
|
+
return refuse('lowest revision %d is above the pin', lowest);
|
|
557
|
+
}
|
|
558
|
+
const range = archive.range;
|
|
559
|
+
if (!Array.isArray(range)) {
|
|
560
|
+
return refuse('declares no range');
|
|
561
|
+
}
|
|
562
|
+
const [start, end] = range;
|
|
563
|
+
if (start !== lowest) {
|
|
564
|
+
return refuse('range starts at %o but revisions start at %d', start, lowest);
|
|
565
|
+
}
|
|
566
|
+
if (end === undefined || !Number.isInteger(end) || end <= highest) {
|
|
567
|
+
return refuse('range ends at %o but revisions end at %d', end, highest);
|
|
568
|
+
}
|
|
569
|
+
if (!await this.noDivergentRewrite(entries, refuse)) {
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
return [lowest, rev + 1];
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* True when none of `entries` would overwrite content this node ALREADY holds with different
|
|
576
|
+
* content. False (having logged which entry, via `refuse`) when any would.
|
|
577
|
+
*
|
|
578
|
+
* The refusal is all-or-nothing: one divergent entry rejects the WHOLE archive rather than
|
|
579
|
+
* landing the entries this node happens to lack. Two reasons. An archive that contradicts locally
|
|
580
|
+
* held content is evidence the peer is wrong or hostile about this block, which makes the rest of
|
|
581
|
+
* it no more trustworthy than the part that was caught; and a partial apply would leave
|
|
582
|
+
* {@link vetRestoredArchive}'s coverage claiming a span the applied subset may not support.
|
|
583
|
+
*
|
|
584
|
+
* Identical content is NOT a conflict — a re-restore of the same archive must stay idempotent,
|
|
585
|
+
* which it has to be for the pin-extended coverage above to converge.
|
|
586
|
+
*
|
|
587
|
+
* NOTE: accepted tradeoff — first writer wins, permanently. Once a revision record is held, no
|
|
588
|
+
* later archive can replace it, so a lying peer that answers a gap FIRST makes every honest
|
|
589
|
+
* archive restating that revision refuse forever: the revision becomes unreadable rather than
|
|
590
|
+
* wrong. That is the deliberate direction — this guard exists precisely so a peer cannot rewrite
|
|
591
|
+
* held history, and it cannot tell "the held copy is the lie" from "the incoming copy is". Repair
|
|
592
|
+
* from that state is an operator action (drop the block's local records and re-fetch). Revisit
|
|
593
|
+
* only alongside a way to verify an archive, which would let the guard prefer the provable copy
|
|
594
|
+
* instead of the earlier one.
|
|
595
|
+
*
|
|
596
|
+
* Comparison is by `canonicalJson`, db-core's one deterministic encoding, so key ORDER across a
|
|
597
|
+
* JSON round trip over the wire never reads as divergence.
|
|
598
|
+
*
|
|
599
|
+
* The three keys mirror {@link saveRestored}'s three writes exactly; an entry that carries no
|
|
600
|
+
* `block` writes no materialization, so it cannot clobber one and is not checked for it.
|
|
601
|
+
*
|
|
602
|
+
* NOTE: costs up to three raw-storage reads per revision entry, on the restore path only — which
|
|
603
|
+
* has already paid for a network round trip, so it is not the term that matters. If a restore
|
|
604
|
+
* ever carries thousands of revisions and this shows up, check `getRevision` first and skip the
|
|
605
|
+
* other two for a revision this node does not hold at all.
|
|
606
|
+
*/
|
|
607
|
+
async noDivergentRewrite(entries, refuse) {
|
|
608
|
+
for (const { rev, action, block } of entries) {
|
|
609
|
+
const heldActionId = await this.storage.getRevision(this.blockId, rev);
|
|
610
|
+
if (heldActionId !== undefined && heldActionId !== action.actionId) {
|
|
611
|
+
refuse('revision %d is already held as action %s, archive names %s', rev, heldActionId, action.actionId);
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const heldTransform = await this.storage.getTransaction(this.blockId, action.actionId);
|
|
615
|
+
if (heldTransform !== undefined && canonicalJson(heldTransform) !== canonicalJson(action.transform)) {
|
|
616
|
+
refuse('action %s (revision %d) is already held with a different transform', action.actionId, rev);
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
if (block) {
|
|
620
|
+
const heldBlock = await this.storage.getMaterializedBlock(this.blockId, action.actionId);
|
|
621
|
+
if (heldBlock !== undefined && canonicalJson(heldBlock) !== canonicalJson(block)) {
|
|
622
|
+
refuse('action %s (revision %d) is already materialized with different content', action.actionId, rev);
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Persist a fetched archive's revisions locally.
|
|
631
|
+
*
|
|
632
|
+
* A revision entry's own `proof` is deliberately IGNORED. An archive is remote wire data —
|
|
633
|
+
* {@link restoreBlock}'s `RestorationCoordinator` fetch verifies nothing, and a peer chooses
|
|
634
|
+
* what to attach — so persisting a proof read out of the archive body would re-serve a hostile
|
|
635
|
+
* peer's artifact as evidence this node retained itself. A proof reaches storage only through
|
|
636
|
+
* `verified`, passed out-of-band alongside the archive, which exactly one caller chain supplies:
|
|
637
|
+
* `cluster/reconcile-block.ts` → `StorageRepo.saveReplicatedBlock` → {@link saveReplica} →
|
|
638
|
+
* {@link saveForwardRevision}, where `certifyContent` had already bound the proof to these exact
|
|
639
|
+
* bytes. A separate parameter rather than a caller obligation to strip is what makes "an
|
|
640
|
+
* unverified proof reached `saveBlockProof`" unrepresentable instead of merely documented.
|
|
641
|
+
*
|
|
642
|
+
* This is a WRITER, not a gate: it trusts what it is handed, and each of its two callers is
|
|
643
|
+
* responsible for having earned that on its own terms.
|
|
644
|
+
*
|
|
645
|
+
* - {@link restoreRevision} — the unverified restore wire — runs {@link vetRestoredArchive}
|
|
646
|
+
* first. Those checks are ABOUT the pinned request (does the archive answer the revision that
|
|
647
|
+
* was asked for?), and this function has no pin to check against, so they cannot live here.
|
|
648
|
+
* - {@link saveForwardRevision} — reached by `saveReplica`/`saveDeletion` through
|
|
649
|
+
* `StorageRepo.saveReplicatedBlock` — builds the archive it passes from local arguments, and
|
|
650
|
+
* on the replica path `cluster/reconcile-block.ts` has already bound those bytes to a verified
|
|
651
|
+
* proof. It writes strictly ABOVE its own `latest` (the monotonic guard returns first
|
|
652
|
+
* otherwise), so it cannot rewrite held history, and it deliberately pays nothing for the
|
|
653
|
+
* restore wire's checks.
|
|
654
|
+
*
|
|
655
|
+
* A THIRD caller would not inherit either argument. Any future one that takes an archive off a
|
|
656
|
+
* network must route through `vetRestoredArchive` (or an equivalent for its own trust model)
|
|
657
|
+
* before reaching here.
|
|
658
|
+
*/
|
|
659
|
+
async saveRestored(archive, verified) {
|
|
398
660
|
const revisions = Object.entries(archive.revisions)
|
|
399
661
|
.map(([rev, data]) => ({ rev: Number(rev), data }));
|
|
400
|
-
// Save all revisions, actions, and
|
|
662
|
+
// Save all revisions, actions, materializations, and the caller-verified proof (if any).
|
|
401
663
|
for (const { rev, data: { action, block } } of revisions) {
|
|
402
664
|
await Promise.all([
|
|
403
665
|
this.storage.saveRevision(this.blockId, rev, action.actionId),
|
|
404
666
|
this.storage.saveTransaction(this.blockId, action.actionId, action.transform),
|
|
405
|
-
block ? this.storage.saveMaterializedBlock(this.blockId, action.actionId, block) : Promise.resolve()
|
|
667
|
+
block ? this.storage.saveMaterializedBlock(this.blockId, action.actionId, block) : Promise.resolve(),
|
|
668
|
+
verified?.rev === rev ? this.storage.saveBlockProof(this.blockId, rev, verified.proof) : Promise.resolve()
|
|
406
669
|
]);
|
|
407
670
|
}
|
|
408
671
|
}
|