@optimystic/db-p2p 0.22.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.
Files changed (177) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/stream-util.d.ts +22 -6
  14. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.js +56 -10
  16. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  17. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  18. package/dist/src/dispute/dispute-service.js +9 -3
  19. package/dist/src/dispute/dispute-service.js.map +1 -1
  20. package/dist/src/index.d.ts +3 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/libp2p-key-network.d.ts +88 -2
  25. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  26. package/dist/src/libp2p-key-network.js +134 -28
  27. package/dist/src/libp2p-key-network.js.map +1 -1
  28. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  29. package/dist/src/libp2p-node-base.js +25 -1
  30. package/dist/src/libp2p-node-base.js.map +1 -1
  31. package/dist/src/logger.d.ts +17 -1
  32. package/dist/src/logger.d.ts.map +1 -1
  33. package/dist/src/logger.js +19 -2
  34. package/dist/src/logger.js.map +1 -1
  35. package/dist/src/owned-block-seed.d.ts +6 -3
  36. package/dist/src/owned-block-seed.d.ts.map +1 -1
  37. package/dist/src/owned-block-seed.js +16 -3
  38. package/dist/src/owned-block-seed.js.map +1 -1
  39. package/dist/src/peer-address-book.d.ts +72 -0
  40. package/dist/src/peer-address-book.d.ts.map +1 -0
  41. package/dist/src/peer-address-book.js +123 -0
  42. package/dist/src/peer-address-book.js.map +1 -0
  43. package/dist/src/repo/client.d.ts.map +1 -1
  44. package/dist/src/repo/client.js +11 -2
  45. package/dist/src/repo/client.js.map +1 -1
  46. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  47. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  48. package/dist/src/repo/cluster-coordinator.js +95 -3
  49. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  50. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  51. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  52. package/dist/src/repo/coordinator-repo.js +242 -73
  53. package/dist/src/repo/coordinator-repo.js.map +1 -1
  54. package/dist/src/rn.d.ts +3 -0
  55. package/dist/src/rn.d.ts.map +1 -1
  56. package/dist/src/rn.js +3 -0
  57. package/dist/src/rn.js.map +1 -1
  58. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  59. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  60. package/dist/src/storage/cached-raw-storage.js +152 -0
  61. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  62. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  63. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  64. package/dist/src/storage/cached-store-driver.js +775 -0
  65. package/dist/src/storage/cached-store-driver.js.map +1 -0
  66. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  67. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  68. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  69. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  70. package/dist/src/storage/shared-cache-pool.js +354 -0
  71. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  72. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  73. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  74. package/dist/src/testing/raw-storage-conformance.js +35 -2
  75. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  76. package/package.json +3 -3
  77. package/readme.md +668 -668
  78. package/src/cluster/block-transfer.ts +424 -424
  79. package/src/cluster/client.ts +119 -88
  80. package/src/cluster/cluster-error.ts +64 -64
  81. package/src/cluster/cluster-policy.ts +203 -203
  82. package/src/cluster/cluster-repo.ts +242 -122
  83. package/src/cluster/cluster-size-coupling.ts +45 -45
  84. package/src/cluster/commit-cert.ts +139 -139
  85. package/src/cluster/i-transaction-state-store.ts +43 -43
  86. package/src/cluster/memory-transaction-state-store.ts +56 -56
  87. package/src/cluster/peer-key-binding.ts +37 -37
  88. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  89. package/src/cluster/quorum-restore.ts +223 -223
  90. package/src/cluster/reconcile-block.ts +203 -203
  91. package/src/cluster/service.ts +293 -241
  92. package/src/cluster/supermajority-coupling.ts +37 -37
  93. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  94. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  95. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  96. package/src/cohort-topic/change-bridge.ts +109 -109
  97. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  98. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  99. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  100. package/src/cohort-topic/host.ts +2901 -2901
  101. package/src/cohort-topic/index.ts +13 -13
  102. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  103. package/src/cohort-topic/membership-source.ts +68 -68
  104. package/src/cohort-topic/peer-codec.ts +31 -31
  105. package/src/cohort-topic/peer-sig.ts +86 -86
  106. package/src/cohort-topic/protocols.ts +71 -71
  107. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  108. package/src/cohort-topic/size-estimator.ts +16 -16
  109. package/src/cohort-topic/stream-util.ts +135 -87
  110. package/src/cohort-topic/threshold-crypto.ts +239 -239
  111. package/src/cohort-topic/topic-router.ts +77 -77
  112. package/src/dispute/arbitrator-selection.ts +138 -138
  113. package/src/dispute/cascade.ts +524 -524
  114. package/src/dispute/dispute-service.ts +11 -5
  115. package/src/dispute/invalidation.ts +625 -625
  116. package/src/inbound-authorization.ts +190 -190
  117. package/src/index.ts +52 -49
  118. package/src/libp2p-key-network.ts +1120 -990
  119. package/src/libp2p-node-base.ts +1675 -1651
  120. package/src/libp2p-node-rn.ts +30 -30
  121. package/src/libp2p-node.ts +36 -36
  122. package/src/logger.ts +19 -2
  123. package/src/matchmaking/aggregate-counts.ts +104 -104
  124. package/src/matchmaking/index.ts +20 -20
  125. package/src/matchmaking/module.ts +363 -363
  126. package/src/matchmaking/protocols.ts +51 -51
  127. package/src/matchmaking/provider-manager.ts +95 -95
  128. package/src/matchmaking/query-handler.ts +88 -88
  129. package/src/matchmaking/query-transport.ts +492 -492
  130. package/src/matchmaking/seeker-manager.ts +64 -64
  131. package/src/matchmaking/seeker-walk-client.ts +293 -293
  132. package/src/matchmaking/traffic-validation.ts +195 -195
  133. package/src/optimystic-node.ts +36 -36
  134. package/src/owned-block-seed.ts +53 -40
  135. package/src/peer-address-book.ts +149 -0
  136. package/src/protocol-limits.ts +33 -33
  137. package/src/reactivity/forwarder-host.ts +438 -438
  138. package/src/reactivity/index.ts +19 -19
  139. package/src/reactivity/notify-transport.ts +144 -144
  140. package/src/reactivity/origination-manager.ts +192 -192
  141. package/src/reactivity/protocols.ts +61 -61
  142. package/src/reactivity/push-state-gossip.ts +291 -291
  143. package/src/reactivity/recover-transport.ts +408 -408
  144. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  145. package/src/reactivity/subscriber-registry.ts +96 -96
  146. package/src/reactivity/subscription-manager.ts +450 -450
  147. package/src/reactivity/topic-bytes.ts +37 -37
  148. package/src/repo/client.ts +12 -2
  149. package/src/repo/cluster-coordinator.ts +99 -3
  150. package/src/repo/coordinator-repo.ts +281 -74
  151. package/src/repo/types.ts +7 -7
  152. package/src/rn.ts +39 -36
  153. package/src/rpc-deadline.ts +45 -45
  154. package/src/storage/arachnode-partition.ts +74 -74
  155. package/src/storage/cached-raw-storage.ts +180 -0
  156. package/src/storage/cached-store-driver.ts +859 -0
  157. package/src/storage/i-kv-store.ts +8 -8
  158. package/src/storage/i-raw-storage.ts +12 -5
  159. package/src/storage/kv-raw-storage.ts +135 -135
  160. package/src/storage/memory-kv-store.ts +28 -28
  161. package/src/storage/memory-storage.ts +25 -25
  162. package/src/storage/memory-store-driver.ts +157 -157
  163. package/src/storage/raw-store-codec.ts +42 -42
  164. package/src/storage/raw-store-driver.ts +80 -80
  165. package/src/storage/ring-selector.ts +317 -317
  166. package/src/storage/ring-shift-coordinator.ts +271 -271
  167. package/src/storage/shared-cache-pool.ts +452 -0
  168. package/src/storage/storage-repo.ts +1014 -1014
  169. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  170. package/src/testing/index.ts +8 -8
  171. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  172. package/src/testing/raw-storage-conformance.ts +453 -417
  173. package/src/testing/reactivity-mesh-harness.ts +922 -922
  174. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,203 +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, 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
- }
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
+ }