@optimystic/db-p2p 0.24.2 → 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 +13 -4
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +17 -18
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +8 -2
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +7 -7
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +8 -7
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/cohort-topic/stream-util.d.ts +7 -6
- package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
- package/dist/src/cohort-topic/stream-util.js +14 -41
- package/dist/src/cohort-topic/stream-util.js.map +1 -1
- package/dist/src/dispute/cascade.d.ts +7 -8
- package/dist/src/dispute/cascade.d.ts.map +1 -1
- package/dist/src/dispute/cascade.js +24 -7
- package/dist/src/dispute/cascade.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +5 -0
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +13 -3
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +33 -15
- package/dist/src/dispute/invalidation.d.ts.map +1 -1
- package/dist/src/dispute/invalidation.js +127 -34
- package/dist/src/dispute/invalidation.js.map +1 -1
- package/dist/src/dispute/service.d.ts.map +1 -1
- package/dist/src/dispute/service.js +2 -1
- package/dist/src/dispute/service.js.map +1 -1
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +14 -10
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +28 -58
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +38 -7
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +148 -63
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/network/open-protocol-stream.d.ts +56 -0
- package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
- package/dist/src/network/open-protocol-stream.js +60 -0
- package/dist/src/network/open-protocol-stream.js.map +1 -0
- package/dist/src/network/register-protocol-handler.d.ts +66 -0
- package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
- package/dist/src/network/register-protocol-handler.js +51 -0
- package/dist/src/network/register-protocol-handler.js.map +1 -0
- package/dist/src/peer-address-book.d.ts +60 -2
- package/dist/src/peer-address-book.d.ts.map +1 -1
- package/dist/src/peer-address-book.js +89 -1
- package/dist/src/peer-address-book.js.map +1 -1
- package/dist/src/pend-validation.d.ts +65 -0
- package/dist/src/pend-validation.d.ts.map +1 -0
- package/dist/src/pend-validation.js +69 -0
- package/dist/src/pend-validation.js.map +1 -0
- package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
- package/dist/src/reactivity/notify-transport.js +2 -1
- package/dist/src/reactivity/notify-transport.js.map +1 -1
- package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
- package/dist/src/reactivity/push-state-gossip.js +2 -1
- package/dist/src/reactivity/push-state-gossip.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +32 -6
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +44 -10
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +181 -25
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +650 -62
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/served-repo-proxy.d.ts +30 -0
- package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
- package/dist/src/repo/served-repo-proxy.js +50 -0
- package/dist/src/repo/served-repo-proxy.js.map +1 -0
- package/dist/src/repo/service.d.ts +16 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +18 -16
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/types.d.ts +3 -1
- package/dist/src/reputation/types.d.ts.map +1 -1
- package/dist/src/reputation/types.js +3 -1
- package/dist/src/reputation/types.js.map +1 -1
- package/dist/src/rn.d.ts +24 -2
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +33 -4
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/block-archive.d.ts +153 -0
- package/dist/src/storage/block-archive.d.ts.map +1 -0
- package/dist/src/storage/block-archive.js +194 -0
- package/dist/src/storage/block-archive.js.map +1 -0
- package/dist/src/storage/block-latch.d.ts +92 -0
- package/dist/src/storage/block-latch.d.ts.map +1 -0
- package/dist/src/storage/block-latch.js +132 -0
- package/dist/src/storage/block-latch.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +169 -12
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +415 -152
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +5 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +16 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +14 -4
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +39 -5
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +70 -19
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +21 -1
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +38 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +14 -8
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +29 -9
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/memory-store-driver.d.ts +4 -1
- package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
- package/dist/src/storage/memory-store-driver.js +22 -2
- package/dist/src/storage/memory-store-driver.js.map +1 -1
- package/dist/src/storage/raw-store-codec.js +2 -2
- package/dist/src/storage/raw-store-driver.d.ts +44 -3
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +60 -3
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +68 -4
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +204 -26
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +422 -119
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/store-identity.d.ts +21 -0
- package/dist/src/storage/store-identity.d.ts.map +1 -0
- package/dist/src/storage/store-identity.js +33 -0
- package/dist/src/storage/store-identity.js.map +1 -0
- package/dist/src/storage/struct.d.ts +15 -0
- package/dist/src/storage/struct.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +92 -0
- package/dist/src/storage/with-read-cache.d.ts.map +1 -0
- package/dist/src/storage/with-read-cache.js +132 -0
- package/dist/src/storage/with-read-cache.js.map +1 -0
- package/dist/src/sync/service.d.ts +13 -3
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +7 -30
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts +75 -2
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +193 -81
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
- package/dist/src/testing/raw-storage-conformance.js +97 -2
- package/dist/src/testing/raw-storage-conformance.js.map +1 -1
- package/package.json +5 -6
- package/src/cluster/block-transfer-service.ts +528 -312
- package/src/cluster/block-transfer.ts +547 -424
- package/src/cluster/certified-claims.ts +319 -0
- package/src/cluster/client-signature-verifier.ts +34 -0
- package/src/cluster/cluster-policy.ts +294 -203
- package/src/cluster/cluster-repo.ts +2501 -2034
- package/src/cluster/commit-proof.ts +344 -0
- package/src/cluster/quorum-restore.ts +348 -223
- package/src/cluster/rebalance-monitor.ts +539 -254
- package/src/cluster/reconcile-block.ts +404 -203
- package/src/cluster/service.ts +23 -20
- package/src/cluster/spread-on-churn.ts +352 -346
- package/src/cohort-topic/host.ts +8 -7
- package/src/cohort-topic/stream-util.ts +14 -43
- package/src/dispute/cascade.ts +31 -15
- package/src/dispute/dispute-service.ts +706 -696
- package/src/dispute/invalidation.ts +162 -52
- package/src/dispute/service.ts +126 -125
- package/src/index.ts +7 -0
- package/src/libp2p-key-network.ts +1204 -1235
- package/src/libp2p-node-base.ts +185 -70
- package/src/network/open-protocol-stream.ts +87 -0
- package/src/network/register-protocol-handler.ts +85 -0
- package/src/peer-address-book.ts +96 -2
- package/src/pend-validation.ts +89 -0
- package/src/reactivity/notify-transport.ts +2 -1
- package/src/reactivity/push-state-gossip.ts +2 -1
- package/src/repo/cluster-coordinator.ts +65 -11
- package/src/repo/coordinator-repo.ts +729 -71
- package/src/repo/served-repo-proxy.ts +55 -0
- package/src/repo/service.ts +25 -16
- package/src/reputation/types.ts +3 -1
- package/src/rn.ts +34 -4
- package/src/storage/block-archive.ts +242 -0
- package/src/storage/block-latch.ts +144 -0
- package/src/storage/block-storage.ts +787 -498
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +898 -859
- package/src/storage/i-block-storage.ts +172 -116
- package/src/storage/i-raw-storage.ts +40 -0
- package/src/storage/kv-raw-storage.ts +34 -9
- package/src/storage/memory-store-driver.ts +26 -2
- package/src/storage/raw-store-codec.ts +42 -42
- package/src/storage/raw-store-driver.ts +45 -3
- package/src/storage/shared-cache-pool.ts +516 -452
- package/src/storage/storage-repo.ts +1421 -1014
- package/src/storage/store-identity.ts +41 -0
- package/src/storage/struct.ts +52 -34
- package/src/storage/with-read-cache.ts +182 -0
- package/src/sync/service.ts +178 -198
- package/src/testing/mesh-harness.ts +524 -344
- package/src/testing/raw-storage-conformance.ts +114 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, StaleFailure, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionRev, ActionContext, ClusterRecord, BlockUnavailableReason } from "@optimystic/db-core";
|
|
2
|
-
import { LruMap, blockIdsForTransforms, highestStaleAt, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
|
|
1
|
+
import type { PendRequest, ActionBlocks, IRepo, MessageOptions, CommitResult, GetBlockResults, PendResult, StaleFailure, BlockGets, CommitRequest, RepoMessage, IKeyNetwork, ICluster, ClusterConsensusConfig, BlockId, ActionId, ActionRev, ActionContext, ClusterRecord, BlockUnavailableReason, ActionPending } from "@optimystic/db-core";
|
|
2
|
+
import { LruMap, blockIdsForTransforms, highestStaleAt, isConflictFailure, isOwnRevision, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
|
|
3
3
|
import { ClusterCoordinator, ConflictRaceLostError, ValidatorRejectionError } from "./cluster-coordinator.js";
|
|
4
4
|
import type { PeerId } from "@libp2p/interface";
|
|
5
5
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
@@ -8,11 +8,13 @@ import { createLogger } from '../logger.js';
|
|
|
8
8
|
import type { IPeerReputation } from "../reputation/types.js";
|
|
9
9
|
import { PenaltyReason } from "../reputation/types.js";
|
|
10
10
|
import type { ITransactionStateStore } from "../cluster/i-transaction-state-store.js";
|
|
11
|
-
import { quorumSize, corroboratorCapacity, selectQuorumRev, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
|
|
11
|
+
import { quorumSize, corroboratorCapacity, selectQuorumRev, certifiedEquivocation, CORROBORATION_FLOOR, type RevClaim, type QuorumRev } from "../cluster/quorum-restore.js";
|
|
12
|
+
import { certifyClaim, isAttributableProofFailure, proofThresholds, type ProofAnchoring } from "../cluster/certified-claims.js";
|
|
12
13
|
import { DEFAULT_CLUSTER_SIZE } from "../cluster/cluster-policy.js";
|
|
13
14
|
import { RECONCILE_TIMEOUT_MS } from "../cluster/reconcile-block.js";
|
|
14
|
-
import { isMissingBaseRevisionFailure, MISSING_BASE_REVISION_REASON } from "../storage/storage-repo.js";
|
|
15
|
+
import { isMissingBaseRevisionFailure, MISSING_BASE_REVISION_REASON, type IRevisionActionReader } from "../storage/storage-repo.js";
|
|
15
16
|
import type { ReconcileBlockCallback } from "../cluster/cluster-repo.js";
|
|
17
|
+
import type { CertifiedActionRev } from "../storage/block-archive.js";
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* Acquire a block's content for a cohort-corroborated revision, from the cohort, and persist it.
|
|
@@ -58,8 +60,13 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
|
|
|
58
60
|
interface ClusterLatestQuery {
|
|
59
61
|
/** Highest `(rev, actionId)` corroborated by peers other than this node, if any. */
|
|
60
62
|
corroborated?: ActionRev;
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
+
/**
|
|
64
|
+
* This node's own latest for the block, as answered by the callback's self short-circuit.
|
|
65
|
+
* Typed as a {@link CertifiedActionRev} because that short-circuit reads the local proof too —
|
|
66
|
+
* of no use to this node (it trusts its own storage), but the type stays honest about what the
|
|
67
|
+
* value carries rather than silently erasing it.
|
|
68
|
+
*/
|
|
69
|
+
local?: CertifiedActionRev;
|
|
63
70
|
/**
|
|
64
71
|
* Cohort peers (self excluded) that never answered the consult — the callback rejected
|
|
65
72
|
* (dial failure, protocol error) or blew the per-peer deadline. Silence, not evidence:
|
|
@@ -89,6 +96,96 @@ interface ClusterLatestQuery {
|
|
|
89
96
|
answered: number;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
/**
|
|
100
|
+
* What earlier repair passes left unresolved for one block. Two independent facts share one entry
|
|
101
|
+
* — and one map — on purpose: both are "what the last repair pass could not finish", both are
|
|
102
|
+
* cleared by the same event (the block converging), and `CoordinatorRepo` already keeps more
|
|
103
|
+
* per-block maps than anyone can hold in their head (backlog
|
|
104
|
+
* `debt-freshness-state-scattered-across-coordinator-repo`).
|
|
105
|
+
*
|
|
106
|
+
* An entry exists exactly while at least one of the two is set; both clear together in
|
|
107
|
+
* {@link CoordinatorRepo.flagUnconfirmedCurrency} once this node reaches the claimed revision.
|
|
108
|
+
*/
|
|
109
|
+
interface AheadClaimState {
|
|
110
|
+
/**
|
|
111
|
+
* The cohort-claimed revision the last freshness consult could not settle — the doubt
|
|
112
|
+
* {@link CoordinatorRepo.flagUnconfirmedCurrency} stamps onto reads served below it. Absent once a
|
|
113
|
+
* consult finds nothing ahead of what this node holds.
|
|
114
|
+
*/
|
|
115
|
+
rev?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Which `cluster-fetch:repair-deadlock` reasons have already been said for this block (see
|
|
118
|
+
* {@link CoordinatorRepo.reportRepairDeadlock}). Neither reason is about any one revision — one is
|
|
119
|
+
* about the cohort's size, the other about how many of its peers hold the block — so both survive
|
|
120
|
+
* {@link CoordinatorRepo.recordAheadClaim} clearing `rev`: without that, a block whose cohort
|
|
121
|
+
* claims nothing *ahead* of the reader would re-announce the same permanent condition on every
|
|
122
|
+
* single pass, the noise this line exists to replace.
|
|
123
|
+
*
|
|
124
|
+
* Tracked per REASON rather than as one flag: the two diagnose different faults and send the
|
|
125
|
+
* operator to different places, so an episode that starts as `cohort-too-small` and becomes
|
|
126
|
+
* `sole-holder` (the operator added machines, which is what that reason told them to do) has to be
|
|
127
|
+
* able to say the second thing. Bounded at two entries by the reason union itself.
|
|
128
|
+
*/
|
|
129
|
+
deadlocksReported?: readonly DeadlockReason[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Why a corroboration decline is provably permanent — see {@link CoordinatorRepo.reportRepairDeadlock}
|
|
134
|
+
* for what makes each provable and which remedy each sends the operator to.
|
|
135
|
+
*/
|
|
136
|
+
type DeadlockReason = 'cohort-too-small' | 'sole-holder';
|
|
137
|
+
|
|
138
|
+
/** The `cohort-too-small` wording: the cohort cannot field the quorum however healthy its peers are. */
|
|
139
|
+
function cohortTooSmallMessage(
|
|
140
|
+
cohortPeers: number,
|
|
141
|
+
claimants: number,
|
|
142
|
+
requiredEvenIfAllAnswered: number,
|
|
143
|
+
repairCorroborationClusterSize: number
|
|
144
|
+
): string {
|
|
145
|
+
return `Block repair cannot converge for this block and the condition is PERMANENT, not transient: ` +
|
|
146
|
+
`this node's cohort has ${cohortPeers} peer(s) besides itself, all of them answered ` +
|
|
147
|
+
`(${claimants} hold the block), but accepting a revision would need ${requiredEvenIfAllAnswered} ` +
|
|
148
|
+
`agreeing peers even if every one of those ${cohortPeers} answered and agreed. No later pass can reach ` +
|
|
149
|
+
`that, however healthy every peer is, so this node's copy of the block stays as it is. Repair needs ` +
|
|
150
|
+
`${CORROBORATION_FLOOR} cohort peers BESIDES the reader to answer and agree, relaxed to 1 only for a ` +
|
|
151
|
+
`cohort that DECLARES it is smaller; repairCorroborationClusterSize currently resolves to ` +
|
|
152
|
+
`${repairCorroborationClusterSize}. Two things produce this, and this node cannot tell them ` +
|
|
153
|
+
`apart: (1) the deployment really does run this few machines — set clusterPolicy.assumedClusterSize ` +
|
|
154
|
+
`to the number you actually run (it does not lower clusterSize / the replication factor), or set an ` +
|
|
155
|
+
`honest clusterSize, and run at least ${CORROBORATION_FLOOR + 2} machines for any tolerance of one ` +
|
|
156
|
+
`unreachable peer; or (2) this node's view of the cohort has shrunk below the real deployment — a ` +
|
|
157
|
+
`partition or a routing problem, which configuration will not fix. Check the peer count above ` +
|
|
158
|
+
`against the machines you run before changing anything.`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The `sole-holder` wording: the cohort is big enough, but only one of its peers holds the block.
|
|
163
|
+
*
|
|
164
|
+
* Every claim here is scoped to THIS NODE'S COHORT PEERS, which is the whole of what the pass
|
|
165
|
+
* observed. It deliberately does not say "only one machine in the deployment holds this block": this
|
|
166
|
+
* node's own copy is excluded from the claim set (it cannot corroborate the revision it is trying to
|
|
167
|
+
* repair), so a reader that holds the block itself would make that reading false — and a scary
|
|
168
|
+
* all-caps line an operator can disprove by looking at their own disks is worth less than no line.
|
|
169
|
+
* For the same reason the remedy is "another COHORT PEER holding it" rather than "a second copy":
|
|
170
|
+
* with the reader holding one, a second copy already exists and is still not enough.
|
|
171
|
+
*/
|
|
172
|
+
function soleHolderMessage(cohortPeers: number): string {
|
|
173
|
+
return `Block repair cannot converge for this block and the condition is PERMANENT, not transient: ` +
|
|
174
|
+
`ONLY ONE COHORT PEER HOLDS THIS BLOCK. Of this node's ${cohortPeers} cohort peers, 1 reports holding ` +
|
|
175
|
+
`it and the other ${cohortPeers - 1} answered that they hold NOTHING — an answer, not silence, so this ` +
|
|
176
|
+
`is the whole picture and not a slow pass. Repair adopts a revision only when ${CORROBORATION_FLOOR} ` +
|
|
177
|
+
`peers BESIDES this node agree on it, and a lone holder cannot second itself, so every later pass ` +
|
|
178
|
+
`declines identically. This node's own copy, if it has one, is the copy being repaired and does not ` +
|
|
179
|
+
`count toward that number. MORE MACHINES DO NOT FIX THIS, and neither does any cluster-size setting — ` +
|
|
180
|
+
`what is missing is ANOTHER COHORT PEER HOLDING THE BLOCK. The usual cause is data written while the ` +
|
|
181
|
+
`deployment (or this block's cohort) was smaller: a block that had one holder then still has one holder ` +
|
|
182
|
+
`now, because the two paths that would replicate it — read-repair and reconcile — both decline on this ` +
|
|
183
|
+
`same rule. Committing any new revision of the block writes it to the current cohort and clears this. ` +
|
|
184
|
+
`(A lone holder whose answer carries a valid cohort commit proof for its revision IS adopted without a ` +
|
|
185
|
+
`second voter — reaching this message means the one holder attached no such proof, or one that did not ` +
|
|
186
|
+
`verify.)`;
|
|
187
|
+
}
|
|
188
|
+
|
|
92
189
|
/**
|
|
93
190
|
* What one repair pass established about a block that is still MISSING locally after it.
|
|
94
191
|
* Ordered by how firmly the block is ruled out; `get` consults it only on the missing path.
|
|
@@ -116,11 +213,23 @@ type AbsenceVerdict =
|
|
|
116
213
|
*/
|
|
117
214
|
interface LocalClusterWithExecutionTracking extends ICluster {
|
|
118
215
|
wasTransactionExecuted?(messageHash: string): boolean;
|
|
216
|
+
/** Local storage's verdict for a pend applied during consensus; see ClusterMember.getExecutedPendResult. */
|
|
217
|
+
getExecutedPendResult?(messageHash: string): PendResult | undefined;
|
|
218
|
+
/** Local storage's verdict for a commit applied during consensus; see ClusterMember.getExecutedCommitResult. */
|
|
219
|
+
getExecutedCommitResult?(messageHash: string): CommitResult | undefined;
|
|
119
220
|
}
|
|
120
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A cohort peer's answer to the latest-revision consult. Defined with the archive shape it is
|
|
224
|
+
* projected from (`storage/block-archive.ts`) and re-exported here so it reads next to the callback
|
|
225
|
+
* that returns it; see {@link CertifiedActionRev} there for what the optional proof does and does
|
|
226
|
+
* not mean.
|
|
227
|
+
*/
|
|
228
|
+
export type { CertifiedActionRev } from "../storage/block-archive.js";
|
|
229
|
+
|
|
121
230
|
/**
|
|
122
231
|
* Callback to query a cluster peer for their latest revision of a block. Three-way contract:
|
|
123
|
-
* - resolves
|
|
232
|
+
* - resolves a `CertifiedActionRev` — the peer answered and holds the block at that revision;
|
|
124
233
|
* - resolves `undefined` — the peer answered and holds NOTHING (an absent claim);
|
|
125
234
|
* - REJECTS — the peer could not be asked at all (dial failure, protocol error).
|
|
126
235
|
*
|
|
@@ -132,7 +241,7 @@ interface LocalClusterWithExecutionTracking extends ICluster {
|
|
|
132
241
|
* harness's `silentPeers` failure knob). Slowness needs no handling here — the caller deadlines
|
|
133
242
|
* each query and treats expiry as silence.
|
|
134
243
|
*/
|
|
135
|
-
export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId, context?: ActionContext) => Promise<
|
|
244
|
+
export type ClusterLatestCallback = (peerId: PeerId, blockId: BlockId, context?: ActionContext) => Promise<CertifiedActionRev | undefined>;
|
|
136
245
|
|
|
137
246
|
interface CoordinatorRepoComponents {
|
|
138
247
|
storageRepo: IRepo;
|
|
@@ -150,6 +259,14 @@ interface CoordinatorRepoComponents {
|
|
|
150
259
|
* holds the corroborated action as a promotable pending. See {@link AcquireBlockCallback}.
|
|
151
260
|
*/
|
|
152
261
|
acquireBlockFromCohort?: AcquireBlockCallback;
|
|
262
|
+
/**
|
|
263
|
+
* Optional layer-2 anchoring for the cohort commit proofs the latest-revision consult verifies
|
|
264
|
+
* (`cluster/certified-claims.ts`): re-derive the block's cohort and LOG the overlap with the
|
|
265
|
+
* proof's signers, plus surface proofs accepted without that comparison. Purely observational —
|
|
266
|
+
* never a gate — and absent in production wiring today; `certifyClaim` logs unanchored
|
|
267
|
+
* acceptance internally regardless.
|
|
268
|
+
*/
|
|
269
|
+
proofAnchoring?: ProofAnchoring;
|
|
153
270
|
}
|
|
154
271
|
|
|
155
272
|
/**
|
|
@@ -183,7 +300,8 @@ export function coordinatorRepo(
|
|
|
183
300
|
components.clusterLatestCallback,
|
|
184
301
|
reputation,
|
|
185
302
|
stateStore,
|
|
186
|
-
components.acquireBlockFromCohort
|
|
303
|
+
components.acquireBlockFromCohort,
|
|
304
|
+
components.proofAnchoring
|
|
187
305
|
);
|
|
188
306
|
}
|
|
189
307
|
|
|
@@ -195,13 +313,13 @@ export class CoordinatorRepo implements IRepo {
|
|
|
195
313
|
private readonly responsibilityCache = new LruMap<string, { inCluster: boolean, expires: number }>(1000);
|
|
196
314
|
private static readonly RESPONSIBILITY_TTL_MS = 60_000;
|
|
197
315
|
private readonly lastSeenCommitMs = new LruMap<string, number>(1000);
|
|
198
|
-
/** Per block,
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* a doubt dropped there is a stale answer served as confirmed again.
|
|
316
|
+
/** Per block, what earlier repair passes left unresolved — see {@link AheadClaimState}.
|
|
317
|
+
* Outlives the consult on purpose: the read-repair window skips consults for blocks checked
|
|
318
|
+
* recently, and a doubt dropped there is a stale answer served as confirmed again.
|
|
202
319
|
* NOTE: LRU-bounded like `lastSeenCommitMs`; an eviction under >1000 doubted blocks loses the
|
|
203
|
-
* doubt until the next consult re-derives it (one read-repair window later, at worst)
|
|
204
|
-
|
|
320
|
+
* doubt until the next consult re-derives it (one read-repair window later, at worst) and lets
|
|
321
|
+
* {@link reportRepairDeadlock} say its piece a second time. */
|
|
322
|
+
private readonly unsettledAheadClaims = new LruMap<string, AheadClaimState>(1000);
|
|
205
323
|
private readonly readRepairMode: 'off' | 'lazy' | 'paranoid';
|
|
206
324
|
private readonly readRepairWindowMs: number;
|
|
207
325
|
private readonly readRepairSampleRate: number;
|
|
@@ -235,7 +353,8 @@ export class CoordinatorRepo implements IRepo {
|
|
|
235
353
|
private readonly clusterLatestCallback?: ClusterLatestCallback,
|
|
236
354
|
reputation?: IPeerReputation,
|
|
237
355
|
stateStore?: ITransactionStateStore,
|
|
238
|
-
private readonly acquireBlockFromCohort?: AcquireBlockCallback
|
|
356
|
+
private readonly acquireBlockFromCohort?: AcquireBlockCallback,
|
|
357
|
+
private readonly proofAnchoring?: ProofAnchoring
|
|
239
358
|
) {
|
|
240
359
|
this.localPeerId = localPeerId;
|
|
241
360
|
this.log = createLogger('coordinator-repo', localPeerId?.toString());
|
|
@@ -282,7 +401,9 @@ export class CoordinatorRepo implements IRepo {
|
|
|
282
401
|
const localClusterRef = localCluster && localPeerId ? {
|
|
283
402
|
update: localCluster.update.bind(localCluster),
|
|
284
403
|
peerId: localPeerId,
|
|
285
|
-
wasTransactionExecuted: localCluster.wasTransactionExecuted?.bind(localCluster)
|
|
404
|
+
wasTransactionExecuted: localCluster.wasTransactionExecuted?.bind(localCluster),
|
|
405
|
+
getExecutedPendResult: localCluster.getExecutedPendResult?.bind(localCluster),
|
|
406
|
+
getExecutedCommitResult: localCluster.getExecutedCommitResult?.bind(localCluster)
|
|
286
407
|
} : undefined;
|
|
287
408
|
this.coordinator = new ClusterCoordinator(keyNetwork, createClusterClient, policy, localClusterRef, fretService, reputation, stateStore);
|
|
288
409
|
}
|
|
@@ -512,8 +633,20 @@ export class CoordinatorRepo implements IRepo {
|
|
|
512
633
|
* claimed revision (see {@link flagUnconfirmedCurrency}), which is what bounds the map.
|
|
513
634
|
*/
|
|
514
635
|
private recordAheadClaim(blockId: BlockId, claimedRev: number | undefined): void {
|
|
515
|
-
|
|
516
|
-
|
|
636
|
+
const prior = this.unsettledAheadClaims.get(blockId);
|
|
637
|
+
if (claimedRev === undefined) {
|
|
638
|
+
// The consult is the authority on the CLAIM, and only on the claim. A recorded deadlock is
|
|
639
|
+
// not about any revision — it is about how many machines this deployment can field, or how
|
|
640
|
+
// many of them hold the block — so it outlives the claim that first exposed it and is
|
|
641
|
+
// dropped only when the block converges (see {@link flagUnconfirmedCurrency}).
|
|
642
|
+
if (prior?.deadlocksReported) this.unsettledAheadClaims.set(blockId, { deadlocksReported: prior.deadlocksReported });
|
|
643
|
+
else this.unsettledAheadClaims.delete(blockId);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.unsettledAheadClaims.set(blockId, {
|
|
647
|
+
rev: claimedRev,
|
|
648
|
+
...(prior?.deadlocksReported ? { deadlocksReported: prior.deadlocksReported } : {})
|
|
649
|
+
});
|
|
517
650
|
}
|
|
518
651
|
|
|
519
652
|
/**
|
|
@@ -541,18 +674,20 @@ export class CoordinatorRepo implements IRepo {
|
|
|
541
674
|
* from the tail block's `latest.rev`, and `syncInternal` commits every block of an action at
|
|
542
675
|
* `context.rev + 1` — so `context.rev >= claimedRev` is a well-defined comparison. `state.latest`
|
|
543
676
|
* is this node's newest revision for the block even on a pinned read (StorageRepo reports the
|
|
544
|
-
* content's own revision separately as `
|
|
677
|
+
* content's own revision separately as `materialized`), which is exactly the number "is this
|
|
545
678
|
* node behind the claim?" asks about.
|
|
546
679
|
*/
|
|
547
680
|
private flagUnconfirmedCurrency(results: GetBlockResults, blockId: BlockId, context?: ActionContext): void {
|
|
548
|
-
const claimedRev = this.unsettledAheadClaims.get(blockId);
|
|
681
|
+
const claimedRev = this.unsettledAheadClaims.get(blockId)?.rev;
|
|
549
682
|
if (claimedRev === undefined) return;
|
|
550
683
|
const entry = results[blockId];
|
|
551
684
|
if (!entry || entry.unavailable !== undefined) return;
|
|
552
685
|
const servedRev = entry.state?.latest?.rev;
|
|
553
686
|
if (typeof servedRev !== 'number') return;
|
|
554
687
|
if (servedRev >= claimedRev) {
|
|
555
|
-
// Caught up — by this pass's repair or by a commit that landed since. Nothing to doubt
|
|
688
|
+
// Caught up — by this pass's repair or by a commit that landed since. Nothing to doubt, and
|
|
689
|
+
// nothing deadlocked either: repair demonstrably converged for this block, so a later
|
|
690
|
+
// non-convergence is a new episode and gets to say so again.
|
|
556
691
|
this.unsettledAheadClaims.delete(blockId);
|
|
557
692
|
return;
|
|
558
693
|
}
|
|
@@ -763,7 +898,7 @@ export class CoordinatorRepo implements IRepo {
|
|
|
763
898
|
}
|
|
764
899
|
try {
|
|
765
900
|
// Bounded: a stalled cohort peer must not hold up the caller's read. Persisting happens
|
|
766
|
-
// inside the callback via `saveReplicatedBlock`, which takes the
|
|
901
|
+
// inside the callback via `saveReplicatedBlock`, which takes the block write latch —
|
|
767
902
|
// safe to call from here because the read path holds no latch of its own (`StorageRepo.get`
|
|
768
903
|
// acquires and releases it around the promotion above, and nothing wraps this method).
|
|
769
904
|
// NOTE: `get` walks its block ids sequentially, so the bound is per block, not per call — a
|
|
@@ -791,7 +926,8 @@ export class CoordinatorRepo implements IRepo {
|
|
|
791
926
|
* the repair. Returns the local revision afterwards.
|
|
792
927
|
*
|
|
793
928
|
* A pending-only block (metadata seeded by `savePendingTransaction`, no committed revision) asked
|
|
794
|
-
* for a forward revision no promotion can reach used to throw out of
|
|
929
|
+
* for a forward revision no promotion can reach used to throw out of the restore step (now
|
|
930
|
+
* `BlockStorage.restoreRevision`, driven by `StorageRepo.get`'s healing helper).
|
|
795
931
|
* It no longer does: "no committed base here" is an absence, so that read comes back as a plain
|
|
796
932
|
* unflagged `{ state: {} }` and this method simply returns `undefined` — acquisition then supplies
|
|
797
933
|
* the revision. The `unavailable` arm below still fires for the shapes that ARE a guess (a `latest`
|
|
@@ -839,11 +975,14 @@ export class CoordinatorRepo implements IRepo {
|
|
|
839
975
|
* repair. It is returned separately so the caller can compare, not vote.
|
|
840
976
|
*
|
|
841
977
|
* NOTE: the quorum is corroboration-of-a-claim, NOT Sybil-resistant cohort
|
|
842
|
-
* membership — a peer minting fresh keypairs still casts a vote
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
978
|
+
* membership — a peer minting fresh keypairs still casts a vote. A claim that arrives
|
|
979
|
+
* with a cohort commit proof is additionally VERIFIED here (`certifyClaim`,
|
|
980
|
+
* `cluster/certified-claims.ts`); when the proof holds, the claim is certified and
|
|
981
|
+
* {@link selectQuorumRev} accepts it without a second voter — the cohort's signature set
|
|
982
|
+
* is its corroboration. What a passing proof does NOT prove is that its signers are the
|
|
983
|
+
* block's responsible cohort (anyone controlling N keys can sign their own N-peer
|
|
984
|
+
* proof); anchoring the signer set to topology is the optional, observational-only
|
|
985
|
+
* {@link ProofAnchoring} layer, unwired in production today.
|
|
847
986
|
*/
|
|
848
987
|
private async queryClusterForLatest(peerIds: string[], blockId: BlockId, context?: ActionContext): Promise<ClusterLatestQuery> {
|
|
849
988
|
// Query peers in parallel for their latest revision. Each query is DEADLINED (rejects), not
|
|
@@ -876,7 +1015,7 @@ export class CoordinatorRepo implements IRepo {
|
|
|
876
1015
|
// reads as `answered === 0` and reports isolation ('cohort-unreachable') rather than a local
|
|
877
1016
|
// fault. Same fix if it ever matters — require `localPeerId`.
|
|
878
1017
|
const selfId = this.localPeerId?.toString();
|
|
879
|
-
let local:
|
|
1018
|
+
let local: CertifiedActionRev | undefined;
|
|
880
1019
|
const claims: RevClaim[] = [];
|
|
881
1020
|
const silent: string[] = [];
|
|
882
1021
|
// `allSettled` preserves input order, so results correlate to `peerIds` by index — a
|
|
@@ -897,65 +1036,296 @@ export class CoordinatorRepo implements IRepo {
|
|
|
897
1036
|
continue;
|
|
898
1037
|
}
|
|
899
1038
|
if (!value) continue; // responded, holds nothing — an absent claim, not silence
|
|
900
|
-
|
|
1039
|
+
// The proof rides along here and is verified BELOW (certifyClaim) before selection reads
|
|
1040
|
+
// the claim set: presence proves nothing — the peer chose what to attach — but a proof
|
|
1041
|
+
// that verifies certifies the claim, and a certified claim needs no second voter.
|
|
1042
|
+
claims.push({
|
|
1043
|
+
peerId: peerIdStr, rev: value.rev, actionId: value.actionId,
|
|
1044
|
+
...(value.proof ? { proof: value.proof } : {})
|
|
1045
|
+
});
|
|
901
1046
|
}
|
|
902
1047
|
if (silent.length > 0) {
|
|
903
1048
|
this.log('cluster-fetch:peers-silent', { blockId, silent: silent.length, consulted: peerIds.length });
|
|
904
1049
|
}
|
|
905
1050
|
|
|
1051
|
+
// Verify every attached proof, in parallel, BEFORE selection — and penalize provable proof
|
|
1052
|
+
// misbehavior HERE, at verification time, independent of what selection later does with the
|
|
1053
|
+
// claim. Only attributable failures (isAttributableProofFailure) are penalized: a failure
|
|
1054
|
+
// whose signer identities were never proven — unknown/non-ed25519 signer, malformed
|
|
1055
|
+
// signature or proof, a legacy record, the oversized-cohort cap — could have been authored
|
|
1056
|
+
// by anyone in the chain, and penalizing on it would let an attacker frame a peer (the same
|
|
1057
|
+
// discipline as VerifyOutcome.penalize in cluster-repo.ts). A claim whose proof fails stays
|
|
1058
|
+
// in the claim set UNCERTIFIED: it still corroborates by distinct-peer count exactly as a
|
|
1059
|
+
// proof-less claim does — a peer that could fabricate a bad proof could equally have sent no
|
|
1060
|
+
// proof, so dropping the vote would buy nothing.
|
|
1061
|
+
// NOTE: cost is one verification pass per proof-carrying answer per consult, each bounded by
|
|
1062
|
+
// MAX_PROOF_SIGNERS (256) signature checks. In `lazy` mode consults are rate-limited by the
|
|
1063
|
+
// read-repair window; in `paranoid` mode every read of every block pays cohort-width
|
|
1064
|
+
// verifications. Fine at deployment cohort sizes (~10) — if paranoid readers ever show CPU
|
|
1065
|
+
// time in `certifyClaim`, cache verdicts per (blockId, rev, actionId, proof hash) rather than
|
|
1066
|
+
// skipping verification.
|
|
1067
|
+
await Promise.all(claims.map(async claim => {
|
|
1068
|
+
if (!claim.proof) return;
|
|
1069
|
+
const verdict = await certifyClaim(
|
|
1070
|
+
claim.proof,
|
|
1071
|
+
{ blockId, rev: claim.rev, actionId: claim.actionId },
|
|
1072
|
+
// Shared with the reconcile path, so the two cannot drift on what the members actually
|
|
1073
|
+
// enforced — see `proofThresholds` for why the simple-majority term is not
|
|
1074
|
+
// this.simpleMajorityThreshold.
|
|
1075
|
+
proofThresholds(this.superMajorityThreshold),
|
|
1076
|
+
this.proofAnchoring
|
|
1077
|
+
);
|
|
1078
|
+
if (verdict.certified) {
|
|
1079
|
+
claim.certified = true;
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
this.log('cluster-fetch:proof-uncertified', {
|
|
1083
|
+
blockId, peerId: claim.peerId, rev: claim.rev, failure: verdict.failure
|
|
1084
|
+
});
|
|
1085
|
+
if (isAttributableProofFailure(verdict.failure)) {
|
|
1086
|
+
this.penalizeProofService(claim.peerId, blockId);
|
|
1087
|
+
}
|
|
1088
|
+
}));
|
|
1089
|
+
|
|
906
1090
|
const nonSelfCount = peerIds.filter(id => id !== selfId).length;
|
|
907
1091
|
const answered = nonSelfCount - silent.length;
|
|
908
1092
|
const capacity = corroboratorCapacity(nonSelfCount, this.repairCorroborationClusterSize);
|
|
1093
|
+
const required = quorumSize(claims.length, this.simpleMajorityThreshold, capacity);
|
|
909
1094
|
const selected = selectQuorumRev(claims, this.simpleMajorityThreshold, capacity);
|
|
910
1095
|
if (!selected) {
|
|
1096
|
+
// A decline can be the certified path REFUSING to pick a side: two distinct actions each
|
|
1097
|
+
// carrying a verified cohort proof for the same top revision. Name that apart from the
|
|
1098
|
+
// routine no-quorum — the cohort (or whoever holds its keys) provably signed both sides,
|
|
1099
|
+
// an incident rather than a shortage of answers. Neither claimant is penalized: both
|
|
1100
|
+
// proofs verified, so which side is "wrong" is exactly what this node cannot know.
|
|
1101
|
+
const equivocation = certifiedEquivocation(claims);
|
|
1102
|
+
if (equivocation) {
|
|
1103
|
+
this.log('cluster-fetch:certified-equivocation', {
|
|
1104
|
+
blockId, rev: equivocation.rev, actionIds: equivocation.actionIds
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
// The three populations are reported SEPARATELY, never rolled into one "responders" count:
|
|
1108
|
+
// "1 of 2 responded" and "1 holder, 1 confirmed non-holder, 0 silent" call for completely
|
|
1109
|
+
// different operator actions — the first says wait or fix reachability, the second says the
|
|
1110
|
+
// block has only one copy and no amount of waiting produces a second.
|
|
911
1111
|
this.log('cluster-fetch:no-quorum', {
|
|
912
1112
|
blockId,
|
|
913
|
-
|
|
914
|
-
|
|
1113
|
+
cohortPeers: nonSelfCount,
|
|
1114
|
+
holders: claims.length,
|
|
1115
|
+
absent: answered - claims.length,
|
|
1116
|
+
silent: silent.length,
|
|
1117
|
+
required,
|
|
915
1118
|
repairCorroborationClusterSize: this.repairCorroborationClusterSize
|
|
916
1119
|
});
|
|
1120
|
+
// ...and, when this decline is provably permanent rather than transient, say THAT once,
|
|
1121
|
+
// in words. The `no-quorum` line above fires on every pass and cannot tell the two apart.
|
|
1122
|
+
this.reportRepairDeadlock({
|
|
1123
|
+
blockId, claims, silentCount: silent.length, cohortPeers: nonSelfCount, answered, required, capacity
|
|
1124
|
+
});
|
|
917
1125
|
// The claims themselves must not drive restoration — but their existence is
|
|
918
1126
|
// evidence the caller needs: an answer served below the highest claim cannot be
|
|
919
1127
|
// confirmed current (see ClusterLatestQuery.uncorroboratedRev).
|
|
920
|
-
// NOTE: ONE claim is enough to raise that doubt, and
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
1128
|
+
// NOTE: ONE claim is enough to raise that doubt, and the claims reaching this branch
|
|
1129
|
+
// are unverified assertions — a certified claim converges above instead of declining
|
|
1130
|
+
// (the only certified shape that lands here is the equivocation decline). So a single
|
|
1131
|
+
// lying cohort peer can deny unpinned reads of a block by claiming a revision nobody
|
|
1132
|
+
// else holds — an availability lever it did not have while uncorroborated claims were
|
|
1133
|
+
// discarded. Deliberate for now: the alternative is the silent stale serve this marker
|
|
1134
|
+
// exists to end, and the same liar can already force a silent-treated absence by
|
|
1135
|
+
// staying quiet. If the lever is ever exercised, gate the stamp on a certified claim
|
|
1136
|
+
// (the verification machinery now exists) rather than on the bare assertion — at the
|
|
1137
|
+
// cost of re-opening the stale-serve window for the proof-less honest majority.
|
|
929
1138
|
const uncorroboratedRev = claims.length > 0 ? Math.max(...claims.map(c => c.rev)) : undefined;
|
|
930
1139
|
return { local, silent, answered, ...(uncorroboratedRev !== undefined ? { uncorroboratedRev } : {}) };
|
|
931
1140
|
}
|
|
932
1141
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1142
|
+
if (selected.certified) {
|
|
1143
|
+
// Which rule won matters when reading a repair log: a certified selection may rest on a
|
|
1144
|
+
// SINGLE claimant whose corroboration is the cohort's signature set, not other voters.
|
|
1145
|
+
this.log('cluster-fetch:certified-selected', {
|
|
1146
|
+
blockId, rev: selected.rev, claimants: selected.supporters.length
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Best-effort: penalize peers whose claim contradicts a CORROBORATED selection — a different
|
|
1151
|
+
// action at the very same revision. A higher rev may be honest leadership and a lower rev is
|
|
1152
|
+
// just lag; neither is penalized, nor is anything contradicting a certified-only selection
|
|
1153
|
+
// (an unanchored proof must not be able to convict the honest cohort). Never let this throw.
|
|
936
1154
|
this.penalizeContradictingRevClaims(claims, selected, blockId);
|
|
937
1155
|
|
|
938
1156
|
return { corroborated: { actionId: selected.actionId, rev: selected.rev }, local, silent, answered };
|
|
939
1157
|
}
|
|
940
1158
|
|
|
941
1159
|
/**
|
|
942
|
-
*
|
|
1160
|
+
* Say ONCE per block, in words, when a corroboration decline is provably PERMANENT rather than a
|
|
1161
|
+
* transient shortage of answers. There are exactly TWO permanent shapes, and they send the operator
|
|
1162
|
+
* to different places, so each gets its own `reason` and its own wording:
|
|
1163
|
+
*
|
|
1164
|
+
* - `cohort-too-small` — this node's cohort has fewer peers than the quorum would demand even if
|
|
1165
|
+
* every one of them answered and agreed. The remedy is machines or an honest declared size.
|
|
1166
|
+
* - `sole-holder` — the cohort is big enough, but exactly ONE of its peers holds the block at all
|
|
1167
|
+
* and every other peer answered that it holds nothing. The remedy is another cohort peer
|
|
1168
|
+
* holding the block; machines and configuration are both irrelevant. Note the scope: this node's
|
|
1169
|
+
* own copy is excluded from the claim set, so a reader that holds the block itself still sees
|
|
1170
|
+
* `sole-holder` — the message says "cohort peer", never "machine in the deployment".
|
|
1171
|
+
*
|
|
1172
|
+
* **What makes `cohort-too-small` provable.** Not "this pass fell short" — a pass falls short
|
|
1173
|
+
* whenever some peer simply does not hold the block *yet*. The decisive question is whether the
|
|
1174
|
+
* cohort could supply the quorum AT ALL: ask what would be required if every cohort peer answered
|
|
1175
|
+
* and agreed — the best case any later pass can reach without new machines — and compare it to how
|
|
1176
|
+
* many peers the cohort has. Short of that best case the shortfall is not the machine count, and
|
|
1177
|
+
* saying PERMANENT would send the operator to change a number that was never the problem. Twelve
|
|
1178
|
+
* days of log archaeology went into re-deriving the real condition from a thousand identical
|
|
1179
|
+
* `cluster-fetch:no-quorum` lines; the node knows it at the moment of each decline.
|
|
1180
|
+
*
|
|
1181
|
+
* **What makes `sole-holder` provable.** Note it is only reachable for a lone UNCERTIFIED holder:
|
|
1182
|
+
* a lone holder whose cohort commit proof verified is selected by the certified path and converges
|
|
1183
|
+
* before any decline — so the wording's "a lone holder cannot second itself" stays accurate for
|
|
1184
|
+
* every claim that gets here. "That peer will hold it later" is an assumption, and for a
|
|
1185
|
+
* peer that ANSWERED "I hold nothing" it is false: the only two mechanisms that would turn a
|
|
1186
|
+
* non-holder into a holder — `queryClusterForLatest` (read-repair) and `createReconcileBlock`
|
|
1187
|
+
* (reconcile) — consume this very decision, so they decline for exactly the same reason on that
|
|
1188
|
+
* peer. Every peer answered, one holds the block, the rest hold nothing, and no later pass changes
|
|
1189
|
+
* any of that. What DOES change it is a new copy: a commit that writes the block again pushes it to
|
|
1190
|
+
* the current cohort. (Sibling work `replicate-owned-blocks-when-the-cohort-grows` makes that
|
|
1191
|
+
* automatic; until it lands the operator has to cause the write.)
|
|
943
1192
|
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
946
|
-
*
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
*
|
|
950
|
-
* `
|
|
1193
|
+
* **What is deliberately NOT reported.** A cohort that answers unanimously "I hold nothing" — an
|
|
1194
|
+
* agreed absence is an answer, not a failed repair. A pass with any silent peer: silence cannot
|
|
1195
|
+
* change the arithmetic (`cohortPeers` counts silent peers too), but it does mean this node saw less
|
|
1196
|
+
* than the whole picture, and the next clean pass says the same thing at no cost. Note there is
|
|
1197
|
+
* deliberately NO "the claims disagreed" exemption for `cohort-too-small`: a cohort too small to
|
|
1198
|
+
* reach quorum stays too small whether its peers agree or not, so disagreement would suppress a line
|
|
1199
|
+
* that is still true. Two or more disagreeing holders DO suppress `sole-holder`, because that is a
|
|
1200
|
+
* cohort with two copies whose peers have not settled yet — a later pass can settle it.
|
|
1201
|
+
*
|
|
1202
|
+
* **Never a lever.** This only classifies and logs; it never relaxes a floor. Which is also why the
|
|
1203
|
+
* `cohort-too-small` message names *two* readings of the same numbers — a deployment that genuinely
|
|
1204
|
+
* runs this few machines, or a cohort view shrunk below the real deployment by a partition or by an
|
|
1205
|
+
* attacker with routing influence. `corroboratorCapacity` keeps the shrunken view out of the relaxed
|
|
1206
|
+
* branch, but this node cannot tell the two apart from the inside, and an operator sent to fix the
|
|
1207
|
+
* wrong one is the failure this line exists to end.
|
|
1208
|
+
*
|
|
1209
|
+
* NOTE: the reader is still told only "this may be stale" — `BlockPossiblyStaleError` implies a
|
|
1210
|
+
* retry might help, which is wrong advice for a block whose repair is deadlocked as configured.
|
|
1211
|
+
* Carrying this condition into the error needs a new field on `GetBlockResult` plus a change to
|
|
1212
|
+
* that error's documented contract; deliberately out of scope here (see the ticket
|
|
1213
|
+
* `repair-deadlock-is-never-named`, *Not this ticket*).
|
|
1214
|
+
*/
|
|
1215
|
+
private reportRepairDeadlock(pass: {
|
|
1216
|
+
blockId: BlockId;
|
|
1217
|
+
claims: RevClaim[];
|
|
1218
|
+
silentCount: number;
|
|
1219
|
+
/** Cohort peers besides this node, from the cohort view — whether they answered or not. */
|
|
1220
|
+
cohortPeers: number;
|
|
1221
|
+
answered: number;
|
|
1222
|
+
/** The quorum THIS pass demanded, computed from the peers that actually claimed. */
|
|
1223
|
+
required: number;
|
|
1224
|
+
/** `corroboratorCapacity` for this pass — a function of the view and the resolved size, not of who answered. */
|
|
1225
|
+
capacity: number;
|
|
1226
|
+
}): void {
|
|
1227
|
+
const { blockId, claims, silentCount, cohortPeers, answered, required, capacity } = pass;
|
|
1228
|
+
// An incomplete picture proves nothing about the deployment; the next clean pass says it.
|
|
1229
|
+
if (silentCount > 0) return;
|
|
1230
|
+
// Nobody claimed anything: the cohort agrees the block is absent, which is an answer, not a
|
|
1231
|
+
// deadlock.
|
|
1232
|
+
if (claims.length === 0) return;
|
|
1233
|
+
// The decisive test for the first shape. `requiredEvenIfAllAnswered` is the quorum this cohort
|
|
1234
|
+
// would face with every one of its peers answering and agreeing — the best case reachable
|
|
1235
|
+
// without adding machines. A cohort that can meet it is not too small.
|
|
1236
|
+
const requiredEvenIfAllAnswered = quorumSize(cohortPeers, this.simpleMajorityThreshold, capacity);
|
|
1237
|
+
const cohortTooSmall = cohortPeers < requiredEvenIfAllAnswered;
|
|
1238
|
+
// The second shape: exactly one cohort peer holds the block AT ALL, and — since a claim is one
|
|
1239
|
+
// peer's latest, so a single claim is a single distinct (rev, actionId) group with a single
|
|
1240
|
+
// supporter — every other cohort peer answered that it holds nothing. `answered === cohortPeers`
|
|
1241
|
+
// is already implied by the silence guard above; it is stated because the two counts arrive as
|
|
1242
|
+
// independent parameters and "everybody answered" is half of what makes this provable.
|
|
1243
|
+
//
|
|
1244
|
+
// NOTE: there is a narrow window where `sole-holder` is true of the instant but not of the
|
|
1245
|
+
// deployment — a commit that has landed on one cohort member and has not yet been pushed to the
|
|
1246
|
+
// rest presents exactly this shape. Calling it PERMANENT is defensible even there (repair
|
|
1247
|
+
// genuinely cannot converge until the push lands, and the once-per-episode flag clears the
|
|
1248
|
+
// moment the block converges, so the line does not repeat), and widening the window is what the
|
|
1249
|
+
// push path's own threat model decides — see
|
|
1250
|
+
// `tickets/blocked/repair-floor-defends-a-door-the-push-path-leaves-open`. If commit-to-push
|
|
1251
|
+
// latency ever grows enough that operators see `sole-holder` on blocks that heal moments later,
|
|
1252
|
+
// gate the line on the block having been quiet for longer than that latency rather than
|
|
1253
|
+
// softening the wording.
|
|
1254
|
+
const soleHolder = claims.length === 1 && answered === cohortPeers;
|
|
1255
|
+
if (!cohortTooSmall && !soleHolder) return;
|
|
1256
|
+
|
|
1257
|
+
// Both shapes can hold at once (an undeclared two-machine deployment whose single peer holds the
|
|
1258
|
+
// block is both). `cohort-too-small` is reported in preference because its remedy is the one
|
|
1259
|
+
// that actually works there: declaring the real size makes the floor reachable, after which the
|
|
1260
|
+
// lone peer's claim IS adopted — so calling it a sole-holder problem would send the operator
|
|
1261
|
+
// looking for a copy they do not need.
|
|
1262
|
+
const reason: DeadlockReason = cohortTooSmall ? 'cohort-too-small' : 'sole-holder';
|
|
1263
|
+
const state = this.unsettledAheadClaims.get(blockId);
|
|
1264
|
+
const alreadySaid = state?.deadlocksReported ?? [];
|
|
1265
|
+
// Suppressed per REASON, not once outright: an episode that starts as `cohort-too-small` and
|
|
1266
|
+
// becomes `sole-holder` — the operator added the machines that reason asked for, and the block
|
|
1267
|
+
// is still stuck — has a second thing to say, and a silent log there is the failure this line
|
|
1268
|
+
// exists to end. Neither reason repeats within an episode.
|
|
1269
|
+
if (alreadySaid.includes(reason)) return;
|
|
1270
|
+
|
|
1271
|
+
this.log('cluster-fetch:repair-deadlock', {
|
|
1272
|
+
blockId,
|
|
1273
|
+
reason,
|
|
1274
|
+
cohortPeers,
|
|
1275
|
+
answered,
|
|
1276
|
+
claimants: claims.length,
|
|
1277
|
+
required,
|
|
1278
|
+
requiredEvenIfAllAnswered,
|
|
1279
|
+
repairCorroborationClusterSize: this.repairCorroborationClusterSize,
|
|
1280
|
+
message: cohortTooSmall
|
|
1281
|
+
? cohortTooSmallMessage(cohortPeers, claims.length, requiredEvenIfAllAnswered, this.repairCorroborationClusterSize)
|
|
1282
|
+
: soleHolderMessage(cohortPeers)
|
|
1283
|
+
});
|
|
1284
|
+
// Hung off the existing per-block freshness entry rather than a fourth per-block map. The entry
|
|
1285
|
+
// survives `recordAheadClaim` clearing its `rev`, and is dropped wholesale once the block
|
|
1286
|
+
// converges — so each reason is said once per non-convergence episode, not once per pass.
|
|
1287
|
+
// NOTE: per BLOCK, though the condition is a property of the cohort, not of any block — so a node
|
|
1288
|
+
// in this state that reads N distinct blocks emits N lines. Deliberate: the operator wants to
|
|
1289
|
+
// know which blocks are stuck, and N is bounded by blocks actually read (1821 lines for a single
|
|
1290
|
+
// block was the defect). If a deployment in this state ever makes this the noisy line again, add
|
|
1291
|
+
// a node-level once-flag keyed on (cohortPeers, requiredEvenIfAllAnswered) and let the per-block
|
|
1292
|
+
// entry only suppress repeats.
|
|
1293
|
+
this.unsettledAheadClaims.set(blockId, { ...(state ?? {}), deadlocksReported: [...alreadySaid, reason] });
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Report peers whose reported latest PROVABLY contradicts a CORROBORATED selection: the same
|
|
1298
|
+
* revision under a different actionId. Two actions cannot both be the commit at one revision,
|
|
1299
|
+
* and the pair a quorum of distinct peers agreed on is the one this node can stand behind, so
|
|
1300
|
+
* the disagreeing claimant is wrong. Best-effort.
|
|
1301
|
+
*
|
|
1302
|
+
* A CERTIFIED selection is deliberately excluded — no claim is penalized against it. A passing
|
|
1303
|
+
* proof shows the cohort it names signed the commit, never that those signers are the block's
|
|
1304
|
+
* responsible cohort: anyone holding N keys can mint a proof that verifies (see caller
|
|
1305
|
+
* obligation #1 in `cluster/commit-proof.ts`, and the unwired {@link ProofAnchoring} layer).
|
|
1306
|
+
* Penalizing here would therefore hand one forged proof a lever it must not have — every honest
|
|
1307
|
+
* peer holding the real action at that revision reported for InvalidRestoration (weight 30,
|
|
1308
|
+
* above the deprioritize threshold of 20), on every consult. Losing the selection to the proof
|
|
1309
|
+
* is already the accepted cost of the certified path; deprioritizing the honest cohort on top of
|
|
1310
|
+
* it is not. Revisit when certification is anchored to the block's derived cohort
|
|
1311
|
+
* (`feat-cluster-membership-threshold-cert-anchoring`): a gated proof makes the contradiction
|
|
1312
|
+
* provable again.
|
|
1313
|
+
*
|
|
1314
|
+
* A claim at a HIGHER rev than the selection is deliberately NOT penalized: a peer can honestly
|
|
1315
|
+
* be ahead of the sampled quorum — an in-flight commit it durably stored before the rest of the
|
|
1316
|
+
* cohort, or other honest holders dropped from the sample by the 1s per-peer consult deadline —
|
|
1317
|
+
* and the InvalidRestoration weight (30) sits above the deprioritize threshold (20), so a single
|
|
1318
|
+
* false hit used to deprioritize an honest, up-to-date peer. Declining to RESTORE from the
|
|
1319
|
+
* uncorroborated higher claim already happens in selection; the affirmative penalty on that
|
|
1320
|
+
* ambiguous evidence is what this method no longer applies. Provably-bad proof SERVICE is
|
|
1321
|
+
* penalized at verification time instead (the certifyClaim pass in
|
|
1322
|
+
* {@link queryClusterForLatest}).
|
|
951
1323
|
*/
|
|
952
1324
|
private penalizeContradictingRevClaims(claims: RevClaim[], selected: QuorumRev, blockId: BlockId): void {
|
|
953
|
-
if (!this.reputation) return;
|
|
1325
|
+
if (!this.reputation || selected.certified) return;
|
|
954
1326
|
try {
|
|
955
1327
|
for (const c of claims) {
|
|
956
|
-
|
|
957
|
-
|| (c.rev === selected.rev && c.actionId !== selected.actionId);
|
|
958
|
-
if (contradicts) {
|
|
1328
|
+
if (c.rev === selected.rev && c.actionId !== selected.actionId) {
|
|
959
1329
|
this.reputation.reportPeer(c.peerId, PenaltyReason.InvalidRestoration, `read-repair:${blockId}`);
|
|
960
1330
|
}
|
|
961
1331
|
}
|
|
@@ -964,6 +1334,21 @@ export class CoordinatorRepo implements IRepo {
|
|
|
964
1334
|
}
|
|
965
1335
|
}
|
|
966
1336
|
|
|
1337
|
+
/**
|
|
1338
|
+
* Best-effort penalty for a peer whose SERVED PROOF provably lies or provably does not cover the
|
|
1339
|
+
* claim it was attached to (see the attributability classification in
|
|
1340
|
+
* `cluster/certified-claims.ts`). Never throws — mirrors
|
|
1341
|
+
* {@link penalizeContradictingRevClaims}.
|
|
1342
|
+
*/
|
|
1343
|
+
private penalizeProofService(peerId: string, blockId: BlockId): void {
|
|
1344
|
+
if (!this.reputation) return;
|
|
1345
|
+
try {
|
|
1346
|
+
this.reputation.reportPeer(peerId, PenaltyReason.InvalidRestoration, `read-repair:${blockId}`);
|
|
1347
|
+
} catch (err) {
|
|
1348
|
+
this.log('cluster-fetch:penalize-error', { blockId, error: (err as Error).message });
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
967
1352
|
async pend(request: PendRequest, options?: MessageOptions): Promise<PendResult> {
|
|
968
1353
|
const allBlockIds = blockIdsForTransforms(request.transforms);
|
|
969
1354
|
await this.verifyResponsibility(allBlockIds);
|
|
@@ -981,10 +1366,13 @@ export class CoordinatorRepo implements IRepo {
|
|
|
981
1366
|
};
|
|
982
1367
|
|
|
983
1368
|
try {
|
|
984
|
-
const { localExecuted } = await this.coordinator.executeClusterTransaction(coordinatingBlockIds[0]!, message, options);
|
|
1369
|
+
const { localExecuted, localPendResult } = await this.coordinator.executeClusterTransaction(coordinatingBlockIds[0]!, message, options);
|
|
985
1370
|
this.log('coordinator-repo:pend-cluster-complete', {
|
|
986
1371
|
actionId: request.actionId,
|
|
987
|
-
localExecuted
|
|
1372
|
+
localExecuted,
|
|
1373
|
+
localVerdict: localPendResult === undefined ? 'none'
|
|
1374
|
+
: localPendResult.success ? 'success'
|
|
1375
|
+
: isConflictFailure(localPendResult) ? 'conflict' : 'fault'
|
|
988
1376
|
});
|
|
989
1377
|
// Only call storageRepo if local cluster didn't already execute during consensus
|
|
990
1378
|
if (!localExecuted) {
|
|
@@ -997,7 +1385,29 @@ export class CoordinatorRepo implements IRepo {
|
|
|
997
1385
|
});
|
|
998
1386
|
return result;
|
|
999
1387
|
}
|
|
1000
|
-
// Local cluster already executed
|
|
1388
|
+
// Local cluster already executed during consensus — return storage's own verdict rather
|
|
1389
|
+
// than fabricating a success (the peerCount <= 1 path above returns storage's real result
|
|
1390
|
+
// verbatim; the cluster path must never answer differently). Pend-consensus confers no
|
|
1391
|
+
// durability: a refusal carrying `pending` (a rival's unresolved action holds the blocks)
|
|
1392
|
+
// or `missing` (the requested revision is already committed) is the optimistic-concurrency
|
|
1393
|
+
// verdict — the same scan every member runs, not a local fault — and must reach the writer
|
|
1394
|
+
// as a retryable conflict so NetworkTransactor.pendPhase cancels the partial pend and the
|
|
1395
|
+
// writer rebases. This deliberately differs from `commit`'s divergence split below: a
|
|
1396
|
+
// commit that reached commit-consensus IS the authoritative commit (Theorem 9), whereas a
|
|
1397
|
+
// pend that reached pend-consensus may still have been stored by nobody.
|
|
1398
|
+
if (localPendResult !== undefined) {
|
|
1399
|
+
if (localPendResult.success || isConflictFailure(localPendResult)) {
|
|
1400
|
+
return localPendResult;
|
|
1401
|
+
}
|
|
1402
|
+
// A bare-reason refusal (no pending/missing — e.g. a local validation-hook fault)
|
|
1403
|
+
// stays tolerated local divergence: consensus is authoritative and the pend may well
|
|
1404
|
+
// have landed on the rest of the cohort.
|
|
1405
|
+
this.log('coordinator-repo:pend-local-fault-tolerated', {
|
|
1406
|
+
actionId: request.actionId,
|
|
1407
|
+
reason: localPendResult.reason
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
// No verdict retained (member predates retention, restart, or TTL): the prior shape.
|
|
1001
1411
|
return {
|
|
1002
1412
|
success: true,
|
|
1003
1413
|
pending: [],
|
|
@@ -1015,10 +1425,28 @@ export class CoordinatorRepo implements IRepo {
|
|
|
1015
1425
|
// has no field for it and the retry loop only needs "retryable". If a caller ever needs to
|
|
1016
1426
|
// know WHICH transaction won (e.g. to wait on it rather than re-race it), add a typed field
|
|
1017
1427
|
// for it; never recover it by parsing `reason`.
|
|
1428
|
+
//
|
|
1429
|
+
// NOTE: with three or more contenders the members can split so that EVERY contender is
|
|
1430
|
+
// told it lost the race — an all-lose round where nobody wins and each writer retries.
|
|
1431
|
+
// The cause is `ClusterMember.resolveRace`'s approvals-first rule, not its tie-break:
|
|
1432
|
+
// each member compares the rivals as IT holds them, so a member that already approved X
|
|
1433
|
+
// keeps X while a member that approved Y first keeps Y, and no rival reaches a promise
|
|
1434
|
+
// supermajority. (The hash tie-break is already symmetric — it cannot be the fix.)
|
|
1435
|
+
// Fine as it stands: since the torn-action fixes landed, an all-lose round costs one
|
|
1436
|
+
// retry cycle rather than wedging, and the contenders are separated next round by the
|
|
1437
|
+
// jittered backoff plus the aged retry priority carried on the re-pend
|
|
1438
|
+
// (`clampPriority(consecutiveFailures)` in `Collection.syncInternal`), which out-ranks
|
|
1439
|
+
// fresh priority-0 rivals at EQUAL approval counts — priority sits below the approval
|
|
1440
|
+
// count in `resolveRace`, so it does not displace a more-progressed rival. If a
|
|
1441
|
+
// high-contention workload ever shows syncs exhausting `maxAttempts` on repeated
|
|
1442
|
+
// all-lose rounds, the fix is reserve/defer at pend time (backlog
|
|
1443
|
+
// `feat-occ-priority-reservation`, which `resolveRace`'s own residual-fairness NOTE
|
|
1444
|
+
// already points at) rather than raising maxAttempts.
|
|
1018
1445
|
if (error instanceof ConflictRaceLostError) {
|
|
1019
1446
|
return { success: false, conflict: true, reason: error.message };
|
|
1020
1447
|
}
|
|
1021
|
-
const stale = await this.classifyStaleRejection(error, request, allBlockIds)
|
|
1448
|
+
const stale = await this.classifyStaleRejection(error, request, allBlockIds)
|
|
1449
|
+
?? await this.classifyPendingConflictRejection(error, request, allBlockIds);
|
|
1022
1450
|
if (stale) return stale;
|
|
1023
1451
|
throw error;
|
|
1024
1452
|
}
|
|
@@ -1062,7 +1490,16 @@ export class CoordinatorRepo implements IRepo {
|
|
|
1062
1490
|
// number and the reason prose name that block, so they never disagree.
|
|
1063
1491
|
const staleAt = highestStaleAt(blockIds.map(blockId => {
|
|
1064
1492
|
const latest = results[blockId]?.state.latest;
|
|
1065
|
-
|
|
1493
|
+
if (!latest || latest.rev < requestedRev) return undefined;
|
|
1494
|
+
// Per-block self-exclusion (see {@link isOwnRevision}): our own durable half of a torn
|
|
1495
|
+
// action is not a confirmed loss. Deliberately per-block, NOT the bail-entirely
|
|
1496
|
+
// 'own-durable' shape of confirmCommitRivalAgainstLocal — a confirmed rival on ANOTHER
|
|
1497
|
+
// block still confirms, and when none is confirmed anywhere the rejection stays a throw
|
|
1498
|
+
// exactly as before. With the two pend-tier sites upstream fixed (StorageRepo.pend and
|
|
1499
|
+
// ClusterMember.validatePendOperations) this shape should not reach here; mirrored so
|
|
1500
|
+
// all three pend-tier checks agree.
|
|
1501
|
+
if (isOwnRevision(latest, requestedRev, request.actionId)) return undefined;
|
|
1502
|
+
return { blockId, rev: latest.rev };
|
|
1066
1503
|
}));
|
|
1067
1504
|
if (staleAt) {
|
|
1068
1505
|
this.log('coordinator-repo:pend-stale-classified', {
|
|
@@ -1089,6 +1526,53 @@ export class CoordinatorRepo implements IRepo {
|
|
|
1089
1526
|
return undefined;
|
|
1090
1527
|
}
|
|
1091
1528
|
|
|
1529
|
+
/**
|
|
1530
|
+
* Sibling of {@link classifyStaleRejection} for the OTHER optimistic-concurrency refusal shape:
|
|
1531
|
+
* the promise-phase pending-conflict vote (`validatePendOperations` rejecting a pend whose
|
|
1532
|
+
* blocks are held by a different unresolved pending action). That vote surfaces here as a
|
|
1533
|
+
* {@link ValidatorRejectionError}, and without classification it would escape as a throw —
|
|
1534
|
+
* splitting multi-tree pends mid-batch instead of taking the retry path a lost race deserves.
|
|
1535
|
+
*
|
|
1536
|
+
* Same confirmation discipline as the stale classifier: purely local. Re-read the affected
|
|
1537
|
+
* blocks from our own storage and require some block's `state.pendings` to carry a rival
|
|
1538
|
+
* actionId; the signed reject text is never consulted. A confirmed rival returns a
|
|
1539
|
+
* {@link StaleFailure} with `conflict: true` and the rivals as `pending` (`ActionPending`
|
|
1540
|
+
* without `transform` — the type allows it, and no consumer rebases from it). Unconfirmed —
|
|
1541
|
+
* including read errors during confirmation — stays a throw, preserving fail-fast for genuine
|
|
1542
|
+
* validation faults. Checked after `classifyStaleRejection` so a confirmed committed loss
|
|
1543
|
+
* (which carries the sharper `staleAt`) wins when both hold.
|
|
1544
|
+
*/
|
|
1545
|
+
private async classifyPendingConflictRejection(error: unknown, request: PendRequest, blockIds: BlockId[]): Promise<StaleFailure | undefined> {
|
|
1546
|
+
if (!(error instanceof ValidatorRejectionError)) return undefined;
|
|
1547
|
+
let results: GetBlockResults;
|
|
1548
|
+
try {
|
|
1549
|
+
results = await this.storageRepo.get({ blockIds });
|
|
1550
|
+
} catch (readError) {
|
|
1551
|
+
this.log('coordinator-repo:pend-conflict-classify-read-error', {
|
|
1552
|
+
actionId: request.actionId,
|
|
1553
|
+
error: (readError as Error).message
|
|
1554
|
+
});
|
|
1555
|
+
return undefined;
|
|
1556
|
+
}
|
|
1557
|
+
const pending: ActionPending[] = [];
|
|
1558
|
+
for (const blockId of blockIds) {
|
|
1559
|
+
for (const actionId of results[blockId]?.state?.pendings ?? []) {
|
|
1560
|
+
if (actionId !== request.actionId) pending.push({ blockId, actionId });
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (pending.length === 0) return undefined;
|
|
1564
|
+
this.log('coordinator-repo:pend-conflict-classified', {
|
|
1565
|
+
actionId: request.actionId,
|
|
1566
|
+
rivals: pending.map(p => `${p.blockId}:${p.actionId}`)
|
|
1567
|
+
});
|
|
1568
|
+
return {
|
|
1569
|
+
success: false,
|
|
1570
|
+
conflict: true,
|
|
1571
|
+
pending,
|
|
1572
|
+
reason: `pending conflict: block(s) held by unresolved rival action(s) ${[...new Set(pending.map(p => p.actionId))].join(', ')}`
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1092
1576
|
async cancel(actionRef: ActionBlocks, options?: MessageOptions): Promise<void> {
|
|
1093
1577
|
const blockIds = actionRef.blockIds;
|
|
1094
1578
|
await this.verifyResponsibility(blockIds);
|
|
@@ -1100,16 +1584,30 @@ export class CoordinatorRepo implements IRepo {
|
|
|
1100
1584
|
};
|
|
1101
1585
|
|
|
1102
1586
|
try {
|
|
1103
|
-
//
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
)
|
|
1107
|
-
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1587
|
+
// One cluster transaction per block ID — but a block whose cohort is just this node
|
|
1588
|
+
// short-circuits to local storage, exactly as `pend` and `commit` do above. Without the
|
|
1589
|
+
// short-circuit a solo cohort enters `executeTransaction`, fails `minAbsoluteClusterSize`
|
|
1590
|
+
// (2), and throws `Cluster size 1 below minimum 2 and not validated` — so a single-peer
|
|
1591
|
+
// deployment could pend and commit but never cancel, unless the operator had opened the
|
|
1592
|
+
// `allowUnvalidatedSmallCluster` hatch. Decided per block rather than once for
|
|
1593
|
+
// `blockIds[0]`, because a multi-block cancel can span cohorts of different sizes.
|
|
1594
|
+
//
|
|
1595
|
+
// NOTE: `getClusterSize` is a second `findCluster` for the same key that
|
|
1596
|
+
// `executeClusterTransaction` is about to look up again, so a cancel over N blocks now
|
|
1597
|
+
// costs 2N cohort lookups instead of N. Same shape `pend` and `commit` already pay, but
|
|
1598
|
+
// they pay it once (they only ever consult `blockIds[0]`) where this scales with N. Fine
|
|
1599
|
+
// while cancels span a handful of blocks; if wide multi-block cancels ever show up hot,
|
|
1600
|
+
// have `executeClusterTransaction` return the cohort it already fetched (or own the
|
|
1601
|
+
// short-circuit itself) rather than adding a cache here.
|
|
1602
|
+
const results = await Promise.all(blockIds.map(async blockId => {
|
|
1603
|
+
const peerCount = await this.coordinator.getClusterSize(blockId);
|
|
1604
|
+
if (peerCount <= 1) return false;
|
|
1605
|
+
const { localExecuted } = await this.coordinator.executeClusterTransaction(blockId, message, options);
|
|
1606
|
+
return localExecuted;
|
|
1607
|
+
}));
|
|
1110
1608
|
|
|
1111
1609
|
// Only call storageRepo if local cluster didn't already execute during consensus
|
|
1112
|
-
const anyLocalExecuted = results.some(
|
|
1610
|
+
const anyLocalExecuted = results.some(Boolean);
|
|
1113
1611
|
if (!anyLocalExecuted) {
|
|
1114
1612
|
await this.storageRepo.cancel(actionRef, options);
|
|
1115
1613
|
}
|
|
@@ -1136,8 +1634,40 @@ export class CoordinatorRepo implements IRepo {
|
|
|
1136
1634
|
};
|
|
1137
1635
|
|
|
1138
1636
|
try {
|
|
1139
|
-
const { record, localExecuted } = await this.coordinator.executeClusterTransaction(blockIds[0]!, message, options);
|
|
1637
|
+
const { record, localExecuted, localCommitResult } = await this.coordinator.executeClusterTransaction(blockIds[0]!, message, options);
|
|
1140
1638
|
if (localExecuted) {
|
|
1639
|
+
// Our own member applied this commit during consensus. Its retained storage verdict is
|
|
1640
|
+
// the one honest signal we have about durability: the member-side apply tolerates an
|
|
1641
|
+
// "ahead" refusal as divergence (see the NOTE in ClusterMember.applyConsensusOperation),
|
|
1642
|
+
// which is correct for a redelivered or lagging commit — but when the refusal's real
|
|
1643
|
+
// cause is a RIVAL action holding the requested revision, that tolerance turns a commit
|
|
1644
|
+
// no member durably stored into a fabricated success. This is the
|
|
1645
|
+
// signed-but-not-yet-applied window: two commits for one revision can BOTH assemble
|
|
1646
|
+
// consensus when every member signs the second after signing (but before applying) the
|
|
1647
|
+
// first, because signing drops the member's reservation. Confirm the rival against local
|
|
1648
|
+
// storage (never the verdict's prose) and answer the writer with a retryable conflict so
|
|
1649
|
+
// it re-drives at a fresh revision. Own-action or unconfirmed refusals keep the
|
|
1650
|
+
// prior fabricated-success shape: consensus is authoritative and this member converges
|
|
1651
|
+
// via replication.
|
|
1652
|
+
//
|
|
1653
|
+
// NOTE: a CONFIRMED rival is trusted over the consensus outcome here. That is right in
|
|
1654
|
+
// the window this closes (the cohort refused the loser too), but it inverts if the two
|
|
1655
|
+
// ever disagree — a local rival at the requested revision while a super-majority
|
|
1656
|
+
// approved OUR commit means this node is on a forked lineage, and refusing then tells a
|
|
1657
|
+
// writer whose write did land to re-drive it (a duplicate entry). Members holding the
|
|
1658
|
+
// rival reject at the promise round, so consensus and a local rival can only disagree
|
|
1659
|
+
// after a fork; that is partition-healing scope (docs/partition-healing.md). If forks
|
|
1660
|
+
// are ever observed here, weigh the retained verdict against the cohort's votes instead
|
|
1661
|
+
// of trusting the local re-read alone.
|
|
1662
|
+
if (localCommitResult !== undefined && !localCommitResult.success) {
|
|
1663
|
+
const rival = await this.confirmCommitRivalAgainstLocal(request);
|
|
1664
|
+
if (typeof rival === 'object') return rival;
|
|
1665
|
+
this.log('coordinator-repo:commit-local-refusal-tolerated', {
|
|
1666
|
+
actionId: request.actionId,
|
|
1667
|
+
confirmation: rival ?? 'unconfirmed',
|
|
1668
|
+
reason: localCommitResult.reason
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1141
1671
|
this.markBlocksSeen(blockIds);
|
|
1142
1672
|
return { success: true };
|
|
1143
1673
|
}
|
|
@@ -1172,10 +1702,138 @@ export class CoordinatorRepo implements IRepo {
|
|
|
1172
1702
|
}
|
|
1173
1703
|
} catch (error) {
|
|
1174
1704
|
this.log('coordinator-repo:commit-error', { actionId: request.actionId, error: (error as Error).message });
|
|
1705
|
+
// A lost commit-consensus race is an optimistic-concurrency loss, not a fault — mirror
|
|
1706
|
+
// `pend`'s conversion above. At the moment this is thrown, zero members approved and the
|
|
1707
|
+
// members hold the winner: nothing of the loser landed, so a retryable-conflict answer is
|
|
1708
|
+
// truthful. Returning it (rather than rethrowing) matters more here than on the pend path:
|
|
1709
|
+
// db-core's `commitCollection` retries a THROWN commit error verbatim up to 3 times, and by
|
|
1710
|
+
// the retry the members have applied the winner and cleared its reservation — the re-driven
|
|
1711
|
+
// commit can then assemble a consensus no member will durably store (the writer's append
|
|
1712
|
+
// fulfills, the entry exists on no node). A RETURNED `success:false` is instead surfaced
|
|
1713
|
+
// immediately as a stale loss; the writer cancels the pend, re-reads, and re-drives the
|
|
1714
|
+
// whole pend+commit at a fresh revision. `staleAt` stays absent for the same reason as
|
|
1715
|
+
// pend's: it is confirmed-only, and a lost race is a rival commit racing the same revision,
|
|
1716
|
+
// not a locally-confirmed revision claim.
|
|
1717
|
+
if (error instanceof ConflictRaceLostError) {
|
|
1718
|
+
return { success: false, conflict: true, reason: error.message };
|
|
1719
|
+
}
|
|
1720
|
+
// A promise-phase stale-commit reject (`ClusterMember.validateCommitRevisions` — a member
|
|
1721
|
+
// holds the requested revision under a different action) surfaces here as a
|
|
1722
|
+
// ValidatorRejectionError; classify it against local storage the way `pend` does, so the
|
|
1723
|
+
// writer gets a clean retryable conflict instead of three verbatim re-drives and a hard
|
|
1724
|
+
// failure.
|
|
1725
|
+
const stale = await this.classifyCommitStaleRejection(error, request);
|
|
1726
|
+
if (stale) return stale;
|
|
1175
1727
|
throw error;
|
|
1176
1728
|
}
|
|
1177
1729
|
}
|
|
1178
1730
|
|
|
1731
|
+
/**
|
|
1732
|
+
* Commit-shaped sibling of {@link classifyStaleRejection}: decide whether a cluster validator
|
|
1733
|
+
* rejection of a COMMIT was an optimistic-concurrency loss — the requested revision is already
|
|
1734
|
+
* committed under a different action — rather than a genuine validation fault. A confirmed loss
|
|
1735
|
+
* returns a {@link StaleFailure} with `conflict: true` so db-core's `commitCollection` surfaces
|
|
1736
|
+
* it immediately as a stale loss (no verbatim retry) and the writer re-drives at a fresh
|
|
1737
|
+
* revision.
|
|
1738
|
+
*
|
|
1739
|
+
* Same confirmation discipline as the pend classifiers: purely local re-read; the signed reject
|
|
1740
|
+
* text is never consulted. One commit-specific delta — confirmation must EXCLUDE the
|
|
1741
|
+
* own-action-at-rev case: a block whose requested revision is held by THIS action is already
|
|
1742
|
+
* durable, and answering `conflict` for it would make the writer rebase and re-append an
|
|
1743
|
+
* already-committed action at a new revision — a duplicate entry. So:
|
|
1744
|
+
* - `latest.rev === request.rev` → compare `latest.actionId`: ours ⇒ bail (stays a throw),
|
|
1745
|
+
* a rival's ⇒ confirmed loss;
|
|
1746
|
+
* - `latest.rev > request.rev` → ask the {@link IRevisionActionReader} capability who holds
|
|
1747
|
+
* `request.rev`: ours ⇒ bail, a rival's ⇒ confirmed loss, unknown/absent/fault ⇒ unconfirmed;
|
|
1748
|
+
* - anything unconfirmed (including read errors) stays a throw — fail-fast for genuine faults.
|
|
1749
|
+
*/
|
|
1750
|
+
private async classifyCommitStaleRejection(error: unknown, request: CommitRequest): Promise<StaleFailure | undefined> {
|
|
1751
|
+
if (!(error instanceof ValidatorRejectionError)) return undefined;
|
|
1752
|
+
const rival = await this.confirmCommitRivalAgainstLocal(request);
|
|
1753
|
+
// 'own-durable' and unconfirmed both stay a throw here: fail-fast for genuine faults, and a
|
|
1754
|
+
// commit already durable under this action must never be answered `conflict` (the writer
|
|
1755
|
+
// would rebase and re-append it — a duplicate entry).
|
|
1756
|
+
return typeof rival === 'object' ? rival : undefined;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/**
|
|
1760
|
+
* Shared confirmation core for the two commit-tier conversion sites ({@link classifyCommitStaleRejection}
|
|
1761
|
+
* and the locally-executed refusal check in {@link commit}): decide, from LOCAL storage only, who
|
|
1762
|
+
* holds the requested revision.
|
|
1763
|
+
* - a confirmed RIVAL → the {@link StaleFailure} conflict answer (with `staleAt` = highest
|
|
1764
|
+
* confirmed holder);
|
|
1765
|
+
* - our OWN action durable at the requested revision → `'own-durable'` (callers must not answer
|
|
1766
|
+
* `conflict` — the writer would rebase an already-landed action into a duplicate entry);
|
|
1767
|
+
* - anything else (behind, truncated history, read faults, capability absent) → `undefined`,
|
|
1768
|
+
* unconfirmed.
|
|
1769
|
+
* The signed reject text / retained verdict prose is never consulted.
|
|
1770
|
+
*/
|
|
1771
|
+
private async confirmCommitRivalAgainstLocal(request: CommitRequest): Promise<StaleFailure | 'own-durable' | undefined> {
|
|
1772
|
+
const blockIds = request.blockIds;
|
|
1773
|
+
let results: GetBlockResults;
|
|
1774
|
+
try {
|
|
1775
|
+
results = await this.storageRepo.get({ blockIds });
|
|
1776
|
+
} catch (readError) {
|
|
1777
|
+
this.log('coordinator-repo:commit-stale-classify-read-error', {
|
|
1778
|
+
actionId: request.actionId,
|
|
1779
|
+
error: (readError as Error).message
|
|
1780
|
+
});
|
|
1781
|
+
return undefined;
|
|
1782
|
+
}
|
|
1783
|
+
const reader = this.storageRepo as IRepo & Partial<IRevisionActionReader>;
|
|
1784
|
+
// Scan EVERY block (same rule as the pend classifier): report the highest confirmed rival
|
|
1785
|
+
// revision, but bail the moment any block shows OUR action durable at the requested revision.
|
|
1786
|
+
const rivalStales: ({ blockId: BlockId; rev: number } | undefined)[] = [];
|
|
1787
|
+
for (const blockId of blockIds) {
|
|
1788
|
+
const latest = results[blockId]?.state?.latest;
|
|
1789
|
+
if (!latest || latest.rev < request.rev) continue;
|
|
1790
|
+
if (latest.rev === request.rev) {
|
|
1791
|
+
if (latest.actionId === request.actionId) {
|
|
1792
|
+
this.log('coordinator-repo:commit-stale-classify-own-action', {
|
|
1793
|
+
actionId: request.actionId, blockId, rev: request.rev
|
|
1794
|
+
});
|
|
1795
|
+
return 'own-durable';
|
|
1796
|
+
}
|
|
1797
|
+
rivalStales.push({ blockId, rev: latest.rev });
|
|
1798
|
+
continue;
|
|
1799
|
+
}
|
|
1800
|
+
// latest.rev > request.rev — latest can no longer name who took request.rev.
|
|
1801
|
+
if (typeof reader.getRevisionAction !== 'function') continue;
|
|
1802
|
+
let takenBy: ActionId | undefined;
|
|
1803
|
+
try {
|
|
1804
|
+
takenBy = await reader.getRevisionAction(blockId, request.rev);
|
|
1805
|
+
} catch (readError) {
|
|
1806
|
+
this.log('coordinator-repo:commit-stale-classify-revision-read-error', {
|
|
1807
|
+
actionId: request.actionId, blockId, rev: request.rev,
|
|
1808
|
+
error: (readError as Error).message
|
|
1809
|
+
});
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
if (takenBy === request.actionId) {
|
|
1813
|
+
this.log('coordinator-repo:commit-stale-classify-own-action', {
|
|
1814
|
+
actionId: request.actionId, blockId, rev: request.rev, latestRev: latest.rev
|
|
1815
|
+
});
|
|
1816
|
+
return 'own-durable';
|
|
1817
|
+
}
|
|
1818
|
+
if (takenBy !== undefined) rivalStales.push({ blockId, rev: latest.rev });
|
|
1819
|
+
// takenBy undefined (truncated history): unconfirmed for this block.
|
|
1820
|
+
}
|
|
1821
|
+
const staleAt = highestStaleAt(rivalStales);
|
|
1822
|
+
if (!staleAt) return undefined;
|
|
1823
|
+
this.log('coordinator-repo:commit-stale-classified', {
|
|
1824
|
+
actionId: request.actionId,
|
|
1825
|
+
blockId: staleAt.blockId,
|
|
1826
|
+
latestRev: staleAt.rev,
|
|
1827
|
+
requestedRev: request.rev
|
|
1828
|
+
});
|
|
1829
|
+
return {
|
|
1830
|
+
success: false,
|
|
1831
|
+
conflict: true,
|
|
1832
|
+
reason: `stale commit: block ${staleAt.blockId} at rev ${staleAt.rev}, requested rev ${request.rev}`,
|
|
1833
|
+
staleAt
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1179
1837
|
/**
|
|
1180
1838
|
* Report success for a commit the cluster carried but this peer could not apply locally. The
|
|
1181
1839
|
* blocks are marked seen so the read path treats them as freshness-checked; convergence comes
|