@optimystic/db-p2p 0.21.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cluster/client.d.ts +10 -0
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +30 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-policy.d.ts +13 -2
- package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
- package/dist/src/cluster/cluster-policy.js +51 -4
- package/dist/src/cluster/cluster-policy.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +42 -17
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +229 -122
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
- package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
- package/dist/src/cluster/cluster-size-coupling.js +35 -0
- package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +6 -0
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +1 -1
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +15 -3
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/service.d.ts +32 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +43 -2
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +22 -6
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +56 -10
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +9 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +134 -7
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +174 -37
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +3 -2
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +859 -778
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/libp2p-node-rn.d.ts +2 -2
- package/dist/src/libp2p-node-rn.d.ts.map +1 -1
- package/dist/src/libp2p-node-rn.js.map +1 -1
- package/dist/src/libp2p-node.d.ts +2 -2
- package/dist/src/libp2p-node.d.ts.map +1 -1
- package/dist/src/libp2p-node.js.map +1 -1
- package/dist/src/logger.d.ts +17 -1
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +19 -2
- package/dist/src/logger.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +2 -0
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +4 -0
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/optimystic-node.d.ts +35 -0
- package/dist/src/optimystic-node.d.ts.map +1 -0
- package/dist/src/optimystic-node.js +2 -0
- package/dist/src/optimystic-node.js.map +1 -0
- package/dist/src/owned-block-seed.d.ts +6 -3
- package/dist/src/owned-block-seed.d.ts.map +1 -1
- package/dist/src/owned-block-seed.js +16 -3
- package/dist/src/owned-block-seed.js.map +1 -1
- package/dist/src/peer-address-book.d.ts +72 -0
- package/dist/src/peer-address-book.d.ts.map +1 -0
- package/dist/src/peer-address-book.js +123 -0
- package/dist/src/peer-address-book.js.map +1 -0
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +11 -2
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +30 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +95 -3
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +78 -14
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +266 -81
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/rn.d.ts +5 -0
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +5 -0
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +57 -5
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +83 -0
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/cached-raw-storage.js +152 -0
- package/dist/src/storage/cached-raw-storage.js.map +1 -0
- package/dist/src/storage/cached-store-driver.d.ts +186 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
- package/dist/src/storage/cached-store-driver.js +775 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -0
- package/dist/src/storage/i-block-storage.d.ts +20 -1
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +12 -5
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +234 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
- package/dist/src/storage/shared-cache-pool.js +354 -0
- package/dist/src/storage/shared-cache-pool.js.map +1 -0
- package/dist/src/storage/storage-repo.d.ts +56 -3
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +124 -18
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +52 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +668 -653
- package/src/cluster/block-transfer.ts +424 -424
- package/src/cluster/client.ts +119 -88
- package/src/cluster/cluster-error.ts +64 -64
- package/src/cluster/cluster-policy.ts +203 -152
- package/src/cluster/cluster-repo.ts +245 -125
- package/src/cluster/cluster-size-coupling.ts +45 -0
- package/src/cluster/commit-cert.ts +139 -139
- package/src/cluster/i-transaction-state-store.ts +43 -43
- package/src/cluster/memory-transaction-state-store.ts +56 -56
- package/src/cluster/peer-key-binding.ts +37 -37
- package/src/cluster/persistent-transaction-state-store.ts +92 -92
- package/src/cluster/quorum-restore.ts +223 -223
- package/src/cluster/reconcile-block.ts +203 -191
- package/src/cluster/service.ts +293 -241
- package/src/cluster/supermajority-coupling.ts +37 -37
- package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
- package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
- package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
- package/src/cohort-topic/change-bridge.ts +109 -109
- package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
- package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
- package/src/cohort-topic/fret-trust-anchor.ts +153 -153
- package/src/cohort-topic/host.ts +2901 -2901
- package/src/cohort-topic/index.ts +13 -13
- package/src/cohort-topic/membership-publish-sink.ts +20 -20
- package/src/cohort-topic/membership-source.ts +68 -68
- package/src/cohort-topic/peer-codec.ts +31 -31
- package/src/cohort-topic/peer-sig.ts +86 -86
- package/src/cohort-topic/protocols.ts +71 -71
- package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
- package/src/cohort-topic/size-estimator.ts +16 -16
- package/src/cohort-topic/stream-util.ts +135 -87
- package/src/cohort-topic/threshold-crypto.ts +239 -239
- package/src/cohort-topic/topic-router.ts +77 -77
- package/src/dispute/arbitrator-selection.ts +138 -138
- package/src/dispute/cascade.ts +524 -524
- package/src/dispute/dispute-service.ts +11 -5
- package/src/dispute/invalidation.ts +625 -625
- package/src/inbound-authorization.ts +190 -190
- package/src/index.ts +52 -47
- package/src/libp2p-key-network.ts +1120 -958
- package/src/libp2p-node-base.ts +1675 -1591
- package/src/libp2p-node-rn.ts +30 -30
- package/src/libp2p-node.ts +36 -36
- package/src/logger.ts +19 -2
- package/src/matchmaking/aggregate-counts.ts +104 -104
- package/src/matchmaking/index.ts +20 -20
- package/src/matchmaking/module.ts +363 -363
- package/src/matchmaking/protocols.ts +51 -51
- package/src/matchmaking/provider-manager.ts +95 -95
- package/src/matchmaking/query-handler.ts +88 -88
- package/src/matchmaking/query-transport.ts +492 -492
- package/src/matchmaking/seeker-manager.ts +64 -64
- package/src/matchmaking/seeker-walk-client.ts +293 -293
- package/src/matchmaking/traffic-validation.ts +195 -195
- package/src/network/network-manager-service.ts +5 -0
- package/src/optimystic-node.ts +36 -0
- package/src/owned-block-seed.ts +53 -40
- package/src/peer-address-book.ts +149 -0
- package/src/protocol-limits.ts +33 -33
- package/src/reactivity/forwarder-host.ts +438 -438
- package/src/reactivity/index.ts +19 -19
- package/src/reactivity/notify-transport.ts +144 -144
- package/src/reactivity/origination-manager.ts +192 -192
- package/src/reactivity/protocols.ts +61 -61
- package/src/reactivity/push-state-gossip.ts +291 -291
- package/src/reactivity/recover-transport.ts +408 -408
- package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
- package/src/reactivity/subscriber-registry.ts +96 -96
- package/src/reactivity/subscription-manager.ts +450 -450
- package/src/reactivity/topic-bytes.ts +37 -37
- package/src/repo/client.ts +12 -2
- package/src/repo/cluster-coordinator.ts +99 -3
- package/src/repo/coordinator-repo.ts +305 -82
- package/src/repo/types.ts +7 -7
- package/src/rn.ts +39 -34
- package/src/rpc-deadline.ts +45 -45
- package/src/storage/arachnode-partition.ts +74 -74
- package/src/storage/block-storage.ts +59 -6
- package/src/storage/cached-raw-storage.ts +180 -0
- package/src/storage/cached-store-driver.ts +859 -0
- package/src/storage/i-block-storage.ts +20 -1
- package/src/storage/i-kv-store.ts +8 -8
- package/src/storage/i-raw-storage.ts +12 -5
- package/src/storage/kv-raw-storage.ts +135 -135
- package/src/storage/memory-kv-store.ts +28 -28
- package/src/storage/memory-storage.ts +25 -25
- package/src/storage/memory-store-driver.ts +157 -157
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +80 -80
- package/src/storage/ring-selector.ts +317 -317
- package/src/storage/ring-shift-coordinator.ts +271 -271
- package/src/storage/shared-cache-pool.ts +452 -0
- package/src/storage/storage-repo.ts +1014 -903
- package/src/testing/cohort-topic-mesh-harness.ts +663 -663
- package/src/testing/index.ts +8 -8
- package/src/testing/matchmaking-mesh-harness.ts +475 -475
- package/src/testing/raw-storage-conformance.ts +453 -397
- package/src/testing/reactivity-mesh-harness.ts +922 -922
- package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
- package/dist/src/storage/restoration-coordinator-v2.js +0 -172
- package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
|
@@ -1,191 +1,203 @@
|
|
|
1
|
-
import type { ActionRev, BlockId, IBlock } from "@optimystic/db-core";
|
|
2
|
-
import type { BlockArchive } from "../storage/struct.js";
|
|
3
|
-
import type { ReconcileBlockCallback } from "./cluster-repo.js";
|
|
4
|
-
import type { IPeerReputation } from "../reputation/types.js";
|
|
5
|
-
import { PenaltyReason } from "../reputation/types.js";
|
|
6
|
-
import {
|
|
7
|
-
selectQuorumRev, selectQuorumBlock, canonicalBlockHash, corroboratorCapacity,
|
|
8
|
-
type RevClaim, type BlockHashCandidate, type QuorumRev
|
|
9
|
-
} from "./quorum-restore.js";
|
|
10
|
-
import { createLogger } from '../logger.js';
|
|
11
|
-
|
|
12
|
-
const log = createLogger('reconcile-block');
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Wall-clock bound on one whole reconcile pass (all cohort peers, both quorums, the persist).
|
|
16
|
-
* Shared by both callers so a slow or unreachable cohort peer stalls neither the commit path
|
|
17
|
-
* (`ClusterMember.withReconcileTimeout` — a stall there holds up consensus execution) nor the read
|
|
18
|
-
* path (`CoordinatorRepo.restoreCorroborated` — a stall there holds up a caller's `get`).
|
|
19
|
-
*/
|
|
20
|
-
export const RECONCILE_TIMEOUT_MS = 5000;
|
|
21
|
-
|
|
22
|
-
/** One cohort peer's answer for a block: its highest revision, and the block bytes if it carried them. */
|
|
23
|
-
interface ReconcileCandidate {
|
|
24
|
-
peerId: string;
|
|
25
|
-
rev: number;
|
|
26
|
-
actionId: string;
|
|
27
|
-
/** Present only when the serving archive carried a materialized block for `rev`. */
|
|
28
|
-
block?: IBlock;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Collaborators {@link createReconcileBlock} needs, injected so the logic stays transport-agnostic. */
|
|
32
|
-
export interface ReconcileBlockDeps {
|
|
33
|
-
/** This node's own peer id; excluded from the cohort targets. */
|
|
34
|
-
selfPeerId: string;
|
|
35
|
-
/** Fetch one cohort peer's archive for `blockId` — `undefined` when it is unreachable or holds nothing. */
|
|
36
|
-
fetchArchive: (peerId: string, blockId: BlockId) => Promise<BlockArchive | undefined>;
|
|
37
|
-
/** Persist the agreed content through the churn-replication funnel. */
|
|
38
|
-
saveReplicatedBlock: (blockId: BlockId, block: IBlock, source: ActionRev) => Promise<void>;
|
|
39
|
-
/** Proportional corroboration threshold; the cohort's `simpleMajorityThreshold`. */
|
|
40
|
-
simpleMajorityThreshold: number;
|
|
41
|
-
/**
|
|
42
|
-
* Yardstick the corroboration floor is measured against — the floor for
|
|
43
|
-
* {@link corroboratorCapacity}. Required, not optional: unlike the membership admission gate there
|
|
44
|
-
* is no "unknown" handling here, so a caller that cannot state an asserted cohort size should pass
|
|
45
|
-
* its configured `clusterSize` (the strict direction) rather than a small placeholder. The failure
|
|
46
|
-
* mode of overstating it is a block that stays unrepaired — degraded, not dead; of understating it,
|
|
47
|
-
* a shrunken cohort view that can relax the floor to a single voter. `resolveClusterPolicy`
|
|
48
|
-
* (`cluster/cluster-policy.ts`) resolves it for a real node and defaults it to `clusterSize`.
|
|
49
|
-
*/
|
|
50
|
-
repairCorroborationClusterSize: number;
|
|
51
|
-
/** Best-effort misbehavior reporting; a throwing implementation is swallowed. */
|
|
52
|
-
reputation?: Pick<IPeerReputation, 'reportPeer'>;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Highest revision an archive covers. Keys arrive as strings off the wire from an untrusted peer,
|
|
57
|
-
* so a non-numeric one is skipped rather than poisoning the maximum with `NaN`; folding instead of
|
|
58
|
-
* `Math.max(...keys)` also keeps a wide archive off the argument-count limit.
|
|
59
|
-
*/
|
|
60
|
-
function maxRevision(revisions: BlockArchive['revisions']): number | undefined {
|
|
61
|
-
let max: number | undefined;
|
|
62
|
-
for (const key of Object.keys(revisions)) {
|
|
63
|
-
const rev = Number(key);
|
|
64
|
-
if (Number.isFinite(rev) && (max === undefined || rev > max)) max = rev;
|
|
65
|
-
}
|
|
66
|
-
return max;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The claim a peer's archive makes: its highest revision, provided that revision is at least the
|
|
71
|
-
* one we committed. `undefined` when the peer served nothing usable (unreachable, empty archive,
|
|
72
|
-
* or only revisions older than the commit we are healing).
|
|
73
|
-
*/
|
|
74
|
-
function toCandidate(peerId: string, archive: BlockArchive | undefined, committedRev: number): ReconcileCandidate | undefined {
|
|
75
|
-
if (!archive) return undefined;
|
|
76
|
-
const maxRev = maxRevision(archive.revisions);
|
|
77
|
-
if (maxRev === undefined || maxRev < committedRev) return undefined;
|
|
78
|
-
const data = archive.revisions[maxRev];
|
|
79
|
-
if (!data?.action) return undefined;
|
|
80
|
-
return { peerId, rev: maxRev, actionId: data.action.actionId, block: data.block };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* One peer's answer, isolated. `fetchArchive` is contracted to answer `undefined` for an
|
|
85
|
-
* unreachable peer, but a raw `Promise.all` over the cohort would let a single rejecting fetch
|
|
86
|
-
* discard the answers every other peer already gave — turning a heal the cohort could complete
|
|
87
|
-
* into a decline. One peer's failure costs only that peer's vote.
|
|
88
|
-
*/
|
|
89
|
-
async function fetchCandidate(
|
|
90
|
-
deps: ReconcileBlockDeps,
|
|
91
|
-
peerId: string,
|
|
92
|
-
blockId: BlockId,
|
|
93
|
-
committedRev: number
|
|
94
|
-
): Promise<ReconcileCandidate | undefined> {
|
|
95
|
-
try {
|
|
96
|
-
return toCandidate(peerId, await deps.fetchArchive(peerId, blockId), committedRev);
|
|
97
|
-
} catch (err) {
|
|
98
|
-
log('reconcile:fetch-error', { blockId, peerId, error: (err as Error).message });
|
|
99
|
-
return undefined;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Hash the block bytes of every candidate that both corroborates `selected` and actually carried content.
|
|
105
|
-
*
|
|
106
|
-
* NOTE: this canonical-JSON-serializes and sha256s every carrier's whole block on every reconcile.
|
|
107
|
-
* Negligible at today's cohort widths and block sizes; if blocks grow large or cohorts wide enough
|
|
108
|
-
* for this to show up on a commit-path profile, hash incrementally at receive time instead.
|
|
109
|
-
*/
|
|
110
|
-
async function hashCarriers(candidates: ReconcileCandidate[], selected: QuorumRev): Promise<BlockHashCandidate[]> {
|
|
111
|
-
const carriers = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
|
|
112
|
-
return await Promise.all(
|
|
113
|
-
carriers.map(async c => ({ peerId: c.peerId, hash: await canonicalBlockHash(c.block!), block: c.block! }))
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Report cohort members that served content contradicting the agreed hash. Best-effort; never throws. */
|
|
118
|
-
function penalizeContradictingContent(
|
|
119
|
-
reputation: Pick<IPeerReputation, 'reportPeer'> | undefined,
|
|
120
|
-
candidates: BlockHashCandidate[],
|
|
121
|
-
agreedHash: string,
|
|
122
|
-
blockId: BlockId
|
|
123
|
-
): void {
|
|
124
|
-
if (!reputation) return;
|
|
125
|
-
try {
|
|
126
|
-
for (const c of candidates) {
|
|
127
|
-
if (c.hash !== agreedHash) {
|
|
128
|
-
reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
} catch (err) {
|
|
132
|
-
log('reconcile:penalize-error', { blockId, error: (err as Error).message });
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Active reconciliation for a block this member committed without a materializable base
|
|
138
|
-
* (cohort drift between the independent pend and commit cluster-transactions, or a refused
|
|
139
|
-
* `missing-base-revision` commit). Queries the commit cohort — self already excluded by
|
|
140
|
-
* `ClusterMember.reconcileDivergentCommit` — for the block, picks the target revision by quorum
|
|
141
|
-
* corroboration rather than raw `Math.max` (a lone peer inflating its rev cannot steer
|
|
142
|
-
* reconciliation), verifies the cohort agrees on the *content* at that revision, and persists it.
|
|
143
|
-
*
|
|
144
|
-
* Both quorums are capped by {@link corroboratorCapacity}: demanding two corroborators from a
|
|
145
|
-
* cohort that contains exactly one other peer is a permanent deadlock, not a safety property —
|
|
146
|
-
* the node can never heal and stays unreadable forever.
|
|
147
|
-
*
|
|
148
|
-
* **Exposure at capacity 1 (documented, not accidental).** Block ids are random 256-bit strings
|
|
149
|
-
* (`db-core` `structs.ts`), NOT content-addressed, so nothing on the receive path can re-derive
|
|
150
|
-
* the id from the bytes: `canonicalBlockHash` is a cross-peer *agreement* hash, never a check
|
|
151
|
-
* against `blockId`. A sole cohort peer's content is therefore believed on its word. That adds no
|
|
152
|
-
* trust the cohort had not already extended — the same peer's `(rev, actionId)` claim is likewise
|
|
153
|
-
* uncorroborable at that size (see `selectQuorumRev`'s capacity note), and a two-member cohort has
|
|
154
|
-
* no honest majority to appeal to in the first place. Closing it needs commit-cert verification,
|
|
155
|
-
* tracked by backlog `debt-read-repair-commit-cert-verification`.
|
|
156
|
-
*
|
|
157
|
-
* Declines are cheap and retryable: nothing is persisted, nothing is marked, and the next commit
|
|
158
|
-
* or churn/rebalance pass tries again.
|
|
159
|
-
*/
|
|
160
|
-
export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCallback {
|
|
161
|
-
return async (blockId, committed, cohortPeerIds) => {
|
|
162
|
-
const targets = cohortPeerIds.filter(id => id !== deps.selfPeerId);
|
|
163
|
-
if (targets.length === 0) return;
|
|
164
|
-
|
|
165
|
-
const fetched = await Promise.all(
|
|
166
|
-
targets.map(peerId => fetchCandidate(deps, peerId, blockId, committed.rev))
|
|
167
|
-
);
|
|
168
|
-
const candidates = fetched.filter((c): c is ReconcileCandidate => c !== undefined);
|
|
169
|
-
const capacity = corroboratorCapacity(targets.length, deps.repairCorroborationClusterSize);
|
|
170
|
-
|
|
171
|
-
const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId }) => ({ peerId, rev, actionId }));
|
|
172
|
-
const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
|
|
173
|
-
if (!selected) {
|
|
174
|
-
// Leave the block behind; churn/rebalance and the next commit retry.
|
|
175
|
-
log('reconcile:no-rev-quorum', {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
1
|
+
import type { ActionRev, BlockId, IBlock } from "@optimystic/db-core";
|
|
2
|
+
import type { BlockArchive } from "../storage/struct.js";
|
|
3
|
+
import type { ReconcileBlockCallback } from "./cluster-repo.js";
|
|
4
|
+
import type { IPeerReputation } from "../reputation/types.js";
|
|
5
|
+
import { PenaltyReason } from "../reputation/types.js";
|
|
6
|
+
import {
|
|
7
|
+
selectQuorumRev, selectQuorumBlock, canonicalBlockHash, corroboratorCapacity, quorumSize,
|
|
8
|
+
type RevClaim, type BlockHashCandidate, type QuorumRev
|
|
9
|
+
} from "./quorum-restore.js";
|
|
10
|
+
import { createLogger } from '../logger.js';
|
|
11
|
+
|
|
12
|
+
const log = createLogger('reconcile-block');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Wall-clock bound on one whole reconcile pass (all cohort peers, both quorums, the persist).
|
|
16
|
+
* Shared by both callers so a slow or unreachable cohort peer stalls neither the commit path
|
|
17
|
+
* (`ClusterMember.withReconcileTimeout` — a stall there holds up consensus execution) nor the read
|
|
18
|
+
* path (`CoordinatorRepo.restoreCorroborated` — a stall there holds up a caller's `get`).
|
|
19
|
+
*/
|
|
20
|
+
export const RECONCILE_TIMEOUT_MS = 5000;
|
|
21
|
+
|
|
22
|
+
/** One cohort peer's answer for a block: its highest revision, and the block bytes if it carried them. */
|
|
23
|
+
interface ReconcileCandidate {
|
|
24
|
+
peerId: string;
|
|
25
|
+
rev: number;
|
|
26
|
+
actionId: string;
|
|
27
|
+
/** Present only when the serving archive carried a materialized block for `rev`. */
|
|
28
|
+
block?: IBlock;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Collaborators {@link createReconcileBlock} needs, injected so the logic stays transport-agnostic. */
|
|
32
|
+
export interface ReconcileBlockDeps {
|
|
33
|
+
/** This node's own peer id; excluded from the cohort targets. */
|
|
34
|
+
selfPeerId: string;
|
|
35
|
+
/** Fetch one cohort peer's archive for `blockId` — `undefined` when it is unreachable or holds nothing. */
|
|
36
|
+
fetchArchive: (peerId: string, blockId: BlockId) => Promise<BlockArchive | undefined>;
|
|
37
|
+
/** Persist the agreed content through the churn-replication funnel. */
|
|
38
|
+
saveReplicatedBlock: (blockId: BlockId, block: IBlock, source: ActionRev) => Promise<void>;
|
|
39
|
+
/** Proportional corroboration threshold; the cohort's `simpleMajorityThreshold`. */
|
|
40
|
+
simpleMajorityThreshold: number;
|
|
41
|
+
/**
|
|
42
|
+
* Yardstick the corroboration floor is measured against — the floor for
|
|
43
|
+
* {@link corroboratorCapacity}. Required, not optional: unlike the membership admission gate there
|
|
44
|
+
* is no "unknown" handling here, so a caller that cannot state an asserted cohort size should pass
|
|
45
|
+
* its configured `clusterSize` (the strict direction) rather than a small placeholder. The failure
|
|
46
|
+
* mode of overstating it is a block that stays unrepaired — degraded, not dead; of understating it,
|
|
47
|
+
* a shrunken cohort view that can relax the floor to a single voter. `resolveClusterPolicy`
|
|
48
|
+
* (`cluster/cluster-policy.ts`) resolves it for a real node and defaults it to `clusterSize`.
|
|
49
|
+
*/
|
|
50
|
+
repairCorroborationClusterSize: number;
|
|
51
|
+
/** Best-effort misbehavior reporting; a throwing implementation is swallowed. */
|
|
52
|
+
reputation?: Pick<IPeerReputation, 'reportPeer'>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Highest revision an archive covers. Keys arrive as strings off the wire from an untrusted peer,
|
|
57
|
+
* so a non-numeric one is skipped rather than poisoning the maximum with `NaN`; folding instead of
|
|
58
|
+
* `Math.max(...keys)` also keeps a wide archive off the argument-count limit.
|
|
59
|
+
*/
|
|
60
|
+
function maxRevision(revisions: BlockArchive['revisions']): number | undefined {
|
|
61
|
+
let max: number | undefined;
|
|
62
|
+
for (const key of Object.keys(revisions)) {
|
|
63
|
+
const rev = Number(key);
|
|
64
|
+
if (Number.isFinite(rev) && (max === undefined || rev > max)) max = rev;
|
|
65
|
+
}
|
|
66
|
+
return max;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The claim a peer's archive makes: its highest revision, provided that revision is at least the
|
|
71
|
+
* one we committed. `undefined` when the peer served nothing usable (unreachable, empty archive,
|
|
72
|
+
* or only revisions older than the commit we are healing).
|
|
73
|
+
*/
|
|
74
|
+
function toCandidate(peerId: string, archive: BlockArchive | undefined, committedRev: number): ReconcileCandidate | undefined {
|
|
75
|
+
if (!archive) return undefined;
|
|
76
|
+
const maxRev = maxRevision(archive.revisions);
|
|
77
|
+
if (maxRev === undefined || maxRev < committedRev) return undefined;
|
|
78
|
+
const data = archive.revisions[maxRev];
|
|
79
|
+
if (!data?.action) return undefined;
|
|
80
|
+
return { peerId, rev: maxRev, actionId: data.action.actionId, block: data.block };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One peer's answer, isolated. `fetchArchive` is contracted to answer `undefined` for an
|
|
85
|
+
* unreachable peer, but a raw `Promise.all` over the cohort would let a single rejecting fetch
|
|
86
|
+
* discard the answers every other peer already gave — turning a heal the cohort could complete
|
|
87
|
+
* into a decline. One peer's failure costs only that peer's vote.
|
|
88
|
+
*/
|
|
89
|
+
async function fetchCandidate(
|
|
90
|
+
deps: ReconcileBlockDeps,
|
|
91
|
+
peerId: string,
|
|
92
|
+
blockId: BlockId,
|
|
93
|
+
committedRev: number
|
|
94
|
+
): Promise<ReconcileCandidate | undefined> {
|
|
95
|
+
try {
|
|
96
|
+
return toCandidate(peerId, await deps.fetchArchive(peerId, blockId), committedRev);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
log('reconcile:fetch-error', { blockId, peerId, error: (err as Error).message });
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Hash the block bytes of every candidate that both corroborates `selected` and actually carried content.
|
|
105
|
+
*
|
|
106
|
+
* NOTE: this canonical-JSON-serializes and sha256s every carrier's whole block on every reconcile.
|
|
107
|
+
* Negligible at today's cohort widths and block sizes; if blocks grow large or cohorts wide enough
|
|
108
|
+
* for this to show up on a commit-path profile, hash incrementally at receive time instead.
|
|
109
|
+
*/
|
|
110
|
+
async function hashCarriers(candidates: ReconcileCandidate[], selected: QuorumRev): Promise<BlockHashCandidate[]> {
|
|
111
|
+
const carriers = candidates.filter(c => c.rev === selected.rev && c.actionId === selected.actionId && c.block);
|
|
112
|
+
return await Promise.all(
|
|
113
|
+
carriers.map(async c => ({ peerId: c.peerId, hash: await canonicalBlockHash(c.block!), block: c.block! }))
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Report cohort members that served content contradicting the agreed hash. Best-effort; never throws. */
|
|
118
|
+
function penalizeContradictingContent(
|
|
119
|
+
reputation: Pick<IPeerReputation, 'reportPeer'> | undefined,
|
|
120
|
+
candidates: BlockHashCandidate[],
|
|
121
|
+
agreedHash: string,
|
|
122
|
+
blockId: BlockId
|
|
123
|
+
): void {
|
|
124
|
+
if (!reputation) return;
|
|
125
|
+
try {
|
|
126
|
+
for (const c of candidates) {
|
|
127
|
+
if (c.hash !== agreedHash) {
|
|
128
|
+
reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `reconcile:${blockId}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
log('reconcile:penalize-error', { blockId, error: (err as Error).message });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Active reconciliation for a block this member committed without a materializable base
|
|
138
|
+
* (cohort drift between the independent pend and commit cluster-transactions, or a refused
|
|
139
|
+
* `missing-base-revision` commit). Queries the commit cohort — self already excluded by
|
|
140
|
+
* `ClusterMember.reconcileDivergentCommit` — for the block, picks the target revision by quorum
|
|
141
|
+
* corroboration rather than raw `Math.max` (a lone peer inflating its rev cannot steer
|
|
142
|
+
* reconciliation), verifies the cohort agrees on the *content* at that revision, and persists it.
|
|
143
|
+
*
|
|
144
|
+
* Both quorums are capped by {@link corroboratorCapacity}: demanding two corroborators from a
|
|
145
|
+
* cohort that contains exactly one other peer is a permanent deadlock, not a safety property —
|
|
146
|
+
* the node can never heal and stays unreadable forever.
|
|
147
|
+
*
|
|
148
|
+
* **Exposure at capacity 1 (documented, not accidental).** Block ids are random 256-bit strings
|
|
149
|
+
* (`db-core` `structs.ts`), NOT content-addressed, so nothing on the receive path can re-derive
|
|
150
|
+
* the id from the bytes: `canonicalBlockHash` is a cross-peer *agreement* hash, never a check
|
|
151
|
+
* against `blockId`. A sole cohort peer's content is therefore believed on its word. That adds no
|
|
152
|
+
* trust the cohort had not already extended — the same peer's `(rev, actionId)` claim is likewise
|
|
153
|
+
* uncorroborable at that size (see `selectQuorumRev`'s capacity note), and a two-member cohort has
|
|
154
|
+
* no honest majority to appeal to in the first place. Closing it needs commit-cert verification,
|
|
155
|
+
* tracked by backlog `debt-read-repair-commit-cert-verification`.
|
|
156
|
+
*
|
|
157
|
+
* Declines are cheap and retryable: nothing is persisted, nothing is marked, and the next commit
|
|
158
|
+
* or churn/rebalance pass tries again.
|
|
159
|
+
*/
|
|
160
|
+
export function createReconcileBlock(deps: ReconcileBlockDeps): ReconcileBlockCallback {
|
|
161
|
+
return async (blockId, committed, cohortPeerIds) => {
|
|
162
|
+
const targets = cohortPeerIds.filter(id => id !== deps.selfPeerId);
|
|
163
|
+
if (targets.length === 0) return;
|
|
164
|
+
|
|
165
|
+
const fetched = await Promise.all(
|
|
166
|
+
targets.map(peerId => fetchCandidate(deps, peerId, blockId, committed.rev))
|
|
167
|
+
);
|
|
168
|
+
const candidates = fetched.filter((c): c is ReconcileCandidate => c !== undefined);
|
|
169
|
+
const capacity = corroboratorCapacity(targets.length, deps.repairCorroborationClusterSize);
|
|
170
|
+
|
|
171
|
+
const revClaims: RevClaim[] = candidates.map(({ peerId, rev, actionId }) => ({ peerId, rev, actionId }));
|
|
172
|
+
const selected = selectQuorumRev(revClaims, deps.simpleMajorityThreshold, capacity);
|
|
173
|
+
if (!selected) {
|
|
174
|
+
// Leave the block behind; churn/rebalance and the next commit retry.
|
|
175
|
+
log('reconcile:no-rev-quorum', {
|
|
176
|
+
blockId,
|
|
177
|
+
rev: committed.rev,
|
|
178
|
+
responders: revClaims.length,
|
|
179
|
+
required: quorumSize(revClaims.length, deps.simpleMajorityThreshold, capacity),
|
|
180
|
+
repairCorroborationClusterSize: deps.repairCorroborationClusterSize
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const hashCandidates = await hashCarriers(candidates, selected);
|
|
186
|
+
const agreed = selectQuorumBlock(hashCandidates, deps.simpleMajorityThreshold, capacity);
|
|
187
|
+
if (!agreed) {
|
|
188
|
+
log('reconcile:no-content-quorum', {
|
|
189
|
+
blockId,
|
|
190
|
+
rev: selected.rev,
|
|
191
|
+
carriers: hashCandidates.length,
|
|
192
|
+
required: quorumSize(hashCandidates.length, deps.simpleMajorityThreshold, capacity),
|
|
193
|
+
repairCorroborationClusterSize: deps.repairCorroborationClusterSize
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
penalizeContradictingContent(deps.reputation, hashCandidates, agreed.hash, blockId);
|
|
199
|
+
|
|
200
|
+
await deps.saveReplicatedBlock(blockId, agreed.block, { actionId: selected.actionId, rev: selected.rev });
|
|
201
|
+
log('reconcile:restored', { blockId, rev: selected.rev, actionId: selected.actionId });
|
|
202
|
+
};
|
|
203
|
+
}
|