@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,4 +1,4 @@
|
|
|
1
|
-
import { blockIdsForTransforms, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
|
|
1
|
+
import { blockIdsForTransforms, isOwnRevision, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
|
|
2
2
|
import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clampPriority, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
|
|
3
3
|
import { verifyInvalidationCertificate } from "../dispute/invalidation.js";
|
|
4
4
|
import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
|
|
@@ -10,6 +10,8 @@ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } fr
|
|
|
10
10
|
import { createLogger } from '../logger.js';
|
|
11
11
|
import { PenaltyReason } from "../reputation/types.js";
|
|
12
12
|
import { isMissingBaseRevisionFailure } from "../storage/storage-repo.js";
|
|
13
|
+
import { checkPendValidation } from "../pend-validation.js";
|
|
14
|
+
import { buildBlockCommitProof } from "./commit-proof.js";
|
|
13
15
|
import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
|
|
14
16
|
const log = createLogger('cluster-member');
|
|
15
17
|
/** State of a transaction in the cluster */
|
|
@@ -26,6 +28,21 @@ var TransactionPhase;
|
|
|
26
28
|
})(TransactionPhase || (TransactionPhase = {}));
|
|
27
29
|
/** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
|
|
28
30
|
export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
|
|
31
|
+
/**
|
|
32
|
+
* Stable reject reason a member emits when its own materialization of a commit's block disagrees
|
|
33
|
+
* with the content digest the transaction author declared (`CommitRequest.blockDigests`). Rides in
|
|
34
|
+
* the reject vote's `rejectReason`, which `clusterVoteSigningPayload` folds into the signed bytes —
|
|
35
|
+
* so the rejection itself is integrity-protected.
|
|
36
|
+
*/
|
|
37
|
+
export const CONTENT_DIGEST_MISMATCH = 'content-digest-mismatch';
|
|
38
|
+
/**
|
|
39
|
+
* The two stable reject reasons a validator-configured member emits from the shared
|
|
40
|
+
* {@link checkPendValidation}: `PEND_NOT_VALIDATABLE` for a pend carrying no `validation` payload
|
|
41
|
+
* under `ClusterConsensusConfig.unvalidatablePendPolicy: 'reject'`, and `VALIDATOR_FAULT` for a
|
|
42
|
+
* checker that threw. Defined in `pend-validation.ts` (which the storage tier runs too, so both
|
|
43
|
+
* tiers refuse with the same prefixes) and re-exported here next to its siblings above.
|
|
44
|
+
*/
|
|
45
|
+
export { PEND_NOT_VALIDATABLE, VALIDATOR_FAULT } from "../pend-validation.js";
|
|
29
46
|
export function clusterMember(components) {
|
|
30
47
|
return new ClusterMember(components.storageRepo, components.peerNetwork, components.peerId, components.privateKey, components.protocolPrefix, components.partitionDetector, components.fretService, components.validator, components.reputation, components.consensusConfig, components.stateStore, components.reconcileBlock, components.onCommitCertificate, components.onInvalidate, components.recomputeArbitratorSet, components.deriveExpectedCluster);
|
|
31
48
|
}
|
|
@@ -68,6 +85,19 @@ export class ClusterMember {
|
|
|
68
85
|
activeTransactions = new Map();
|
|
69
86
|
// Track executed consensus transactions to prevent duplicate execution (messageHash -> executedAt timestamp)
|
|
70
87
|
executedTransactions = new Map();
|
|
88
|
+
// Local storage's verdict for a pend operation applied during consensus (messageHash -> PendResult).
|
|
89
|
+
// Retained so the coordinator can return storage's real answer instead of fabricating success —
|
|
90
|
+
// a pend that every member refused (rival pending action, or the revision already taken) must
|
|
91
|
+
// reach the writer as a conflict, not a win. Pruned alongside executedTransactions (same TTL).
|
|
92
|
+
executedPendResults = new Map();
|
|
93
|
+
// Local storage's verdict for a COMMIT operation applied during consensus (messageHash ->
|
|
94
|
+
// CommitResult). Retained so the coordinator can detect when the ahead-divergence tolerance in
|
|
95
|
+
// applyConsensusOperation swallowed a refusal whose real cause was a RIVAL action holding the
|
|
96
|
+
// requested revision — the commit-tier acknowledgement hole: a commit that assembled consensus
|
|
97
|
+
// inside every member's signed-but-not-yet-applied window is refused by every member's storage
|
|
98
|
+
// at apply, and without this verdict the coordinator fabricates a success no member durably
|
|
99
|
+
// stored. Pruned alongside executedTransactions (same TTL).
|
|
100
|
+
executedCommitResults = new Map();
|
|
71
101
|
// Fast in-memory dedup for applied invalidations, keyed `${invalidatedActionId}:${disputeId}`.
|
|
72
102
|
// The durable source of truth is the invalidation log entry (Log.findInvalidation, re-checked
|
|
73
103
|
// inside the sink); this map only spares redundant work when the same invalidation reaches
|
|
@@ -101,6 +131,9 @@ export class ClusterMember {
|
|
|
101
131
|
/** Operator-asserted smallest genuine cohort size, or undefined when unknown. */
|
|
102
132
|
assumedClusterSize;
|
|
103
133
|
allowUnvalidatedSmallCluster;
|
|
134
|
+
/** What a validator-configured member does with a pend carrying no `validation` payload — see
|
|
135
|
+
* {@link ClusterConsensusConfig.unvalidatablePendPolicy}. Read once, like the gate parameters. */
|
|
136
|
+
unvalidatablePendPolicy;
|
|
104
137
|
constructor(storageRepo, peerNetwork, peerId, privateKey, protocolPrefix,
|
|
105
138
|
// Reserved for partition-healing (backlog ticket 6.5-partition-healing); held but not yet consumed.
|
|
106
139
|
_partitionDetector, fretService, validator, reputation, consensusConfig, stateStore, reconcileBlock, onCommitCertificate, onInvalidate, recomputeArbitratorSet, deriveExpectedCluster) {
|
|
@@ -124,6 +157,7 @@ export class ClusterMember {
|
|
|
124
157
|
this.membershipAdmissionFraction = consensusConfig?.membershipAdmissionFraction ?? 0.75;
|
|
125
158
|
this.assumedClusterSize = consensusConfig?.assumedClusterSize;
|
|
126
159
|
this.allowUnvalidatedSmallCluster = consensusConfig?.allowUnvalidatedSmallCluster ?? false;
|
|
160
|
+
this.unvalidatablePendPolicy = consensusConfig?.unvalidatablePendPolicy ?? 'accept';
|
|
127
161
|
// State the resolved gate parameters once, so an operator diagnosing a membership rejection can see
|
|
128
162
|
// what this node actually resolved. A fact, not a warning: `assumedClusterSize < clusterSize` is the
|
|
129
163
|
// normal default state, so warning on it would fire for every node and be ignored.
|
|
@@ -164,6 +198,8 @@ export class ClusterMember {
|
|
|
164
198
|
}
|
|
165
199
|
this.activeTransactions.clear();
|
|
166
200
|
this.cleanupQueue.length = 0;
|
|
201
|
+
this.executedPendResults.clear();
|
|
202
|
+
this.executedCommitResults.clear();
|
|
167
203
|
}
|
|
168
204
|
/**
|
|
169
205
|
* Checks if a transaction's operations were already executed during consensus.
|
|
@@ -172,6 +208,31 @@ export class ClusterMember {
|
|
|
172
208
|
wasTransactionExecuted(messageHash) {
|
|
173
209
|
return this.executedTransactions.has(messageHash);
|
|
174
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* Local storage's verdict for the pend operation this member applied at consensus for
|
|
213
|
+
* `messageHash`, when one was retained. The coordinator reads this so the answer a writer gets
|
|
214
|
+
* is the answer storage gave — the cluster path must not fabricate a success the single-node
|
|
215
|
+
* path (`CoordinatorRepo.pend`'s `peerCount <= 1` short-circuit) would never produce. Absent for
|
|
216
|
+
* transactions carrying no pend operation, for transactions applied before this member restarted
|
|
217
|
+
* (the map is in-memory only; the coordinator then falls back to its fabricated-success shape,
|
|
218
|
+
* with the promise-phase pending check in {@link validatePendOperations} narrowing that window),
|
|
219
|
+
* and after the executed-transaction TTL prunes it.
|
|
220
|
+
*/
|
|
221
|
+
getExecutedPendResult(messageHash) {
|
|
222
|
+
return this.executedPendResults.get(messageHash);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Commit-shaped sibling of {@link getExecutedPendResult}: local storage's verdict for a commit
|
|
226
|
+
* operation applied during consensus, when this member retained one. `CoordinatorRepo.commit`
|
|
227
|
+
* consults it after a locally-executed commit-consensus — a retained refusal whose cause a local
|
|
228
|
+
* re-read confirms as a rival holding the requested revision is returned to the writer as a
|
|
229
|
+
* retryable conflict instead of the fabricated success the ahead-divergence tolerance would
|
|
230
|
+
* otherwise imply. Same availability caveats as the pend accessor: in-memory only, absent for
|
|
231
|
+
* pre-restart applies, pruned on the executed-transaction TTL.
|
|
232
|
+
*/
|
|
233
|
+
getExecutedCommitResult(messageHash) {
|
|
234
|
+
return this.executedCommitResults.get(messageHash);
|
|
235
|
+
}
|
|
175
236
|
/**
|
|
176
237
|
* Handles an incoming cluster update, managing the two-phase commit process
|
|
177
238
|
* and coordinating with the local storage repo
|
|
@@ -642,9 +703,19 @@ export class ClusterMember {
|
|
|
642
703
|
// the FIRST delivery when the record already arrives at super-majority, rather than a
|
|
643
704
|
// round-trip later. The safety argument is quorum intersection (Theorem 9: no rival can
|
|
644
705
|
// assemble its own super-majority once this one has), NOT the reservation — the reservation
|
|
645
|
-
// only orders *concurrently-pending* rivals.
|
|
646
|
-
//
|
|
647
|
-
//
|
|
706
|
+
// only orders *concurrently-pending* rivals.
|
|
707
|
+
//
|
|
708
|
+
// The lost update this predicted WAS observed (a pend admitted between a rival's
|
|
709
|
+
// pend-consensus and commit-consensus, then refused by every member's storage at apply and
|
|
710
|
+
// still reported to the writer as a success). Holding the reservation until `handleConsensus`
|
|
711
|
+
// would not have closed it — the loser was approved before the winner's apply had even
|
|
712
|
+
// reached most members — so the cure went elsewhere: `validatePendOperations` now rejects a
|
|
713
|
+
// pend whose blocks are held by a different unresolved STORAGE pending record (the durable
|
|
714
|
+
// reservation that spans pend-apply → commit/cancel), and the coordinator returns storage's
|
|
715
|
+
// retained apply verdict instead of fabricating success (`getExecutedPendResult`). Residual:
|
|
716
|
+
// a member that has not yet applied the rival's pend abstains from that vote, and only the
|
|
717
|
+
// coordinating node's own verdict is threaded back — see the handoff notes on
|
|
718
|
+
// `CoordinatorRepo.pend`.
|
|
648
719
|
const approvedPromises = Object.values(record.promises).filter(s => s.type === 'approve');
|
|
649
720
|
if (approvedPromises.length >= superMajority && !record.commits[ourId]) {
|
|
650
721
|
return { phase: TransactionPhase.OurCommitNeeded };
|
|
@@ -717,17 +788,31 @@ export class ClusterMember {
|
|
|
717
788
|
}
|
|
718
789
|
/**
|
|
719
790
|
* The full promise-phase decision for a record: admit the declared membership FIRST, then (only if
|
|
720
|
-
* admitted) validate its pend operations. Failing
|
|
721
|
-
* turns into a `reject` vote.
|
|
722
|
-
* distinct — a `membership-not-admitted` reject is a different signal (feeds the
|
|
723
|
-
* stale-revision / custom-validator reject
|
|
791
|
+
* admitted) validate its pend operations, then its commit operations. Failing any yields a
|
|
792
|
+
* `{ valid:false, reason }` the caller turns into a `reject` vote. Keeping the three separate keeps
|
|
793
|
+
* the reason strings distinct — a `membership-not-admitted` reject is a different signal (feeds the
|
|
794
|
+
* dispute path) than a stale-revision / custom-validator reject, which is different again from a
|
|
795
|
+
* `content-digest-mismatch` (see {@link validateCommitOperations}). A record carries pend OR commit
|
|
796
|
+
* operations, so in practice exactly one of the latter two has anything to inspect.
|
|
724
797
|
*/
|
|
725
798
|
async evaluatePromise(record) {
|
|
726
799
|
const admission = await this.admitMembership(record);
|
|
727
800
|
if (!admission.admit) {
|
|
728
801
|
return { valid: false, reason: admission.reason ?? MEMBERSHIP_NOT_ADMITTED };
|
|
729
802
|
}
|
|
730
|
-
|
|
803
|
+
const pendValidation = await this.validatePendOperations(record);
|
|
804
|
+
if (!pendValidation.valid) {
|
|
805
|
+
return pendValidation;
|
|
806
|
+
}
|
|
807
|
+
// Revision staleness before content digests: a commit whose revision a rival already took can
|
|
808
|
+
// never win, and the sharper stale reject also skips the per-block digest previews (the digest
|
|
809
|
+
// check would abstain on such a block anyway — its local base rev no longer matches the
|
|
810
|
+
// declared one).
|
|
811
|
+
const commitRevValidation = await this.validateCommitRevisions(record);
|
|
812
|
+
if (!commitRevValidation.valid) {
|
|
813
|
+
return commitRevValidation;
|
|
814
|
+
}
|
|
815
|
+
return await this.validateCommitOperations(record);
|
|
731
816
|
}
|
|
732
817
|
/**
|
|
733
818
|
* Membership admission gate. Decides whether the coordinator-declared peer set (`record.peers`, call it
|
|
@@ -868,18 +953,44 @@ export class ClusterMember {
|
|
|
868
953
|
}
|
|
869
954
|
/**
|
|
870
955
|
* Derive this member's own view of the record's block cluster via the injected capability, or
|
|
871
|
-
* `undefined` when it cannot (no capability, no coordinating block id,
|
|
872
|
-
*
|
|
873
|
-
* the
|
|
956
|
+
* `undefined` when it cannot (no capability, no coordinating block id, a coordinating block not bound
|
|
957
|
+
* to the record's own operations, or a derivation error — all of which the gate treats as "not
|
|
958
|
+
* confident"). Derived from the record's coordinating block, the same key the coordinator used to
|
|
959
|
+
* select the cluster.
|
|
960
|
+
*
|
|
961
|
+
* Read off `record.message`, NOT a top-level record field: `messageHash` covers the message only, so
|
|
962
|
+
* only the in-message copy is tamper-evident to a relaying peer. (There is no top-level copy any more —
|
|
963
|
+
* see {@link ClusterRecord.message}.)
|
|
874
964
|
*/
|
|
875
965
|
async deriveExpectedClusterView(record) {
|
|
876
966
|
if (!this.deriveExpectedCluster) {
|
|
877
967
|
return undefined;
|
|
878
968
|
}
|
|
879
|
-
const blockId = record.coordinatingBlockIds?.[0];
|
|
969
|
+
const blockId = record.message.coordinatingBlockIds?.[0];
|
|
880
970
|
if (blockId === undefined) {
|
|
881
971
|
return undefined;
|
|
882
972
|
}
|
|
973
|
+
// Hashing the field makes it tamper-evident to RELAYS, but the coordinator is the party this gate
|
|
974
|
+
// exists to check and it picks the field before it computes the hash. Unbound, a Byzantine
|
|
975
|
+
// coordinator declares a shrunken cohort `D` and names a coordinating block whose real cohort
|
|
976
|
+
// resembles `D`: every member then derives that block's cohort, finds kEst = |D|, symmetric
|
|
977
|
+
// difference 0, and admits — the gate fully defeated. Binding the coordinating block to a block the
|
|
978
|
+
// record's OWN operations touch removes that free choice. `getAffectedBlockIds` is the same block
|
|
979
|
+
// extraction conflict detection already runs on this message — one definition, so the set a
|
|
980
|
+
// coordinating id must come from cannot drift from the set the record is judged to touch.
|
|
981
|
+
if (!this.getAffectedBlockIds(record.message.operations).includes(blockId)) {
|
|
982
|
+
log('cluster-member:coordinating-block-unbound', {
|
|
983
|
+
messageHash: record.messageHash,
|
|
984
|
+
coordinatingBlockId: blockId
|
|
985
|
+
});
|
|
986
|
+
// Fail closed into the branch that already exists rather than throwing: a hard reject would
|
|
987
|
+
// change `validateRecord`'s failure surface, and "not confident" already refuses any downsize.
|
|
988
|
+
return undefined;
|
|
989
|
+
}
|
|
990
|
+
// Which block a member derived its cohort view from is the single most useful fact when an
|
|
991
|
+
// admission decision has to be explained after the fact — and the only externally visible sign
|
|
992
|
+
// that the confident predicates ran at all rather than the fallback floor.
|
|
993
|
+
log('cluster-member:derive-expected-cluster', { messageHash: record.messageHash, blockId });
|
|
883
994
|
try {
|
|
884
995
|
// NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
|
|
885
996
|
// per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
|
|
@@ -906,7 +1017,8 @@ export class ClusterMember {
|
|
|
906
1017
|
}
|
|
907
1018
|
/**
|
|
908
1019
|
* Validates pend operations in a cluster record using the transaction validator.
|
|
909
|
-
* Also checks for stale revisions
|
|
1020
|
+
* Also checks for stale revisions, and for blocks held by a different unresolved pending
|
|
1021
|
+
* action, to prevent consensus on operations that storage would refuse at apply.
|
|
910
1022
|
* Returns success if no validator is configured (backwards compatibility).
|
|
911
1023
|
*/
|
|
912
1024
|
async validatePendOperations(record) {
|
|
@@ -914,11 +1026,12 @@ export class ClusterMember {
|
|
|
914
1026
|
for (const operation of record.message.operations) {
|
|
915
1027
|
if ('pend' in operation) {
|
|
916
1028
|
const pendRequest = operation.pend;
|
|
1029
|
+
const blockIds = blockIdsForTransforms(pendRequest.transforms);
|
|
1030
|
+
// One state read serves both checks below: `latest` for staleness, `pendings` for the
|
|
1031
|
+
// unresolved-rival check.
|
|
1032
|
+
const blockResults = await this.storageRepo.get({ blockIds });
|
|
917
1033
|
// Check for stale revisions before allowing consensus
|
|
918
1034
|
if (pendRequest.rev !== undefined) {
|
|
919
|
-
const blockIds = blockIdsForTransforms(pendRequest.transforms);
|
|
920
|
-
// Get block states to check latest revisions
|
|
921
|
-
const blockResults = await this.storageRepo.get({ blockIds });
|
|
922
1035
|
for (const blockId of blockIds) {
|
|
923
1036
|
const blockResult = blockResults[blockId];
|
|
924
1037
|
if (blockResult?.unavailable !== undefined) {
|
|
@@ -935,13 +1048,19 @@ export class ClusterMember {
|
|
|
935
1048
|
});
|
|
936
1049
|
return { valid: false, reason: `block ${blockId} unavailable (${blockResult.unavailable}): cannot verify revision` };
|
|
937
1050
|
}
|
|
938
|
-
const
|
|
939
|
-
if (
|
|
1051
|
+
const latest = blockResult?.state?.latest;
|
|
1052
|
+
if (latest !== undefined && latest.rev >= pendRequest.rev) {
|
|
1053
|
+
// Self is excluded so a redelivered pend for this same action stays
|
|
1054
|
+
// approvable — the same exclusion the pending-rival check below documents,
|
|
1055
|
+
// and the same rule storage applies (see {@link isOwnRevision}).
|
|
1056
|
+
if (isOwnRevision(latest, pendRequest.rev, pendRequest.actionId)) {
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
940
1059
|
log('cluster-member:validation-stale-revision', {
|
|
941
1060
|
messageHash: record.messageHash,
|
|
942
1061
|
blockId,
|
|
943
1062
|
requestedRev: pendRequest.rev,
|
|
944
|
-
latestRev
|
|
1063
|
+
latestRev: latest.rev
|
|
945
1064
|
});
|
|
946
1065
|
// Deliberately prose-only: this reason is fed to computeSigningPayload, signed,
|
|
947
1066
|
// and carried as Signature.rejectReason, so adding a structured revision here
|
|
@@ -950,17 +1069,294 @@ export class ClusterMember {
|
|
|
950
1069
|
// is NOT a StaleFailure producer, so StaleFailure.staleAt does not apply; the
|
|
951
1070
|
// coordinator's own local re-read (CoordinatorRepo.classifyStaleRejection)
|
|
952
1071
|
// supplies that number when it can confirm the revision itself.
|
|
953
|
-
return { valid: false, reason: `stale revision: block ${blockId} at rev ${
|
|
1072
|
+
return { valid: false, reason: `stale revision: block ${blockId} at rev ${latest.rev}, requested rev ${pendRequest.rev}` };
|
|
954
1073
|
}
|
|
955
1074
|
}
|
|
956
1075
|
}
|
|
957
|
-
//
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1076
|
+
// Reject a pend whose blocks are held by a DIFFERENT unresolved pending action. This is
|
|
1077
|
+
// the durable reservation the in-memory table (`findConflict` / `activeTransactions`)
|
|
1078
|
+
// cannot provide: that table clears the moment the rival's PEND record reaches
|
|
1079
|
+
// consensus, but the rival's storage pending record — written at pend-apply, removed at
|
|
1080
|
+
// commit or cancel — spans exactly the pend→commit window in which `latest.rev` has not
|
|
1081
|
+
// yet advanced. Storage's own pend would refuse this request at consensus-apply for the
|
|
1082
|
+
// same reason (`StorageRepo.pend`'s listPendingTransactions scan); voting reject here
|
|
1083
|
+
// moves that verdict into the phase where the cohort aggregates it, so the loser is
|
|
1084
|
+
// refused with a real answer instead of burning a consensus round it cannot win. A
|
|
1085
|
+
// member that has not yet applied the rival's pend has no record and simply abstains
|
|
1086
|
+
// from this reason; the coordinator returning the retained apply verdict
|
|
1087
|
+
// (getExecutedPendResult) catches that residual. Self is excluded so a redelivered pend
|
|
1088
|
+
// for this same action stays approvable. An unavailable block carries no `pendings` and
|
|
1089
|
+
// abstains (the rev branch above already fail-closes when a revision claim is at stake).
|
|
1090
|
+
// Reason stays plain prose: it is fed to computeSigningPayload and carried as
|
|
1091
|
+
// Signature.rejectReason, exactly like the stale-revision reason above.
|
|
1092
|
+
for (const blockId of blockIds) {
|
|
1093
|
+
const rivals = (blockResults[blockId]?.state?.pendings ?? []).filter(actionId => actionId !== pendRequest.actionId);
|
|
1094
|
+
if (rivals.length > 0) {
|
|
1095
|
+
log('cluster-member:validation-pending-conflict', {
|
|
1096
|
+
messageHash: record.messageHash,
|
|
1097
|
+
blockId,
|
|
1098
|
+
actionId: pendRequest.actionId,
|
|
1099
|
+
rivals
|
|
1100
|
+
});
|
|
1101
|
+
return { valid: false, reason: `pending conflict: block ${blockId} held by unresolved action(s) ${rivals.join(', ')}` };
|
|
962
1102
|
}
|
|
963
1103
|
}
|
|
1104
|
+
// Re-check the transaction when a validator is configured. The unvalidatable-pend
|
|
1105
|
+
// policy and the throwing-validator catch live in the shared `checkPendValidation`,
|
|
1106
|
+
// which the storage tier runs too, so a member cannot vote approve on a shape its own
|
|
1107
|
+
// storage would refuse at apply. Its reasons are fed to computeSigningPayload and
|
|
1108
|
+
// carried as Signature.rejectReason, exactly like the stale-revision reason above, so
|
|
1109
|
+
// a fail-closed refusal here is signed evidence rather than a lost vote.
|
|
1110
|
+
const validator = this.validator;
|
|
1111
|
+
const validation = await checkPendValidation(pendRequest, validator && (({ transaction, operationsHash }) => validator.validate(transaction, operationsHash)), this.unvalidatablePendPolicy, event => event.kind === 'unvalidatable'
|
|
1112
|
+
// An operator can grep this line to see how much traffic goes unchecked.
|
|
1113
|
+
? log('cluster-member:pend-unvalidatable', {
|
|
1114
|
+
messageHash: record.messageHash,
|
|
1115
|
+
actionId: pendRequest.actionId,
|
|
1116
|
+
policy: event.policy
|
|
1117
|
+
})
|
|
1118
|
+
: log('cluster-member:validator-fault', {
|
|
1119
|
+
messageHash: record.messageHash,
|
|
1120
|
+
error: event.error
|
|
1121
|
+
}));
|
|
1122
|
+
if (!validation.valid) {
|
|
1123
|
+
return { valid: false, reason: validation.reason };
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return { valid: true };
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Promise-round check that a commit record's requested revision is not already committed HERE
|
|
1131
|
+
* under a different action. This is the member-side arm that keeps a DEAD rival's re-broadcast
|
|
1132
|
+
* commit from assembling consensus: after a race winner commits and members clear its record
|
|
1133
|
+
* from the reservation table, a loser's re-driven commit meets no conflict votes — without this
|
|
1134
|
+
* check every caught-up member would abstain (the content-digest check below abstains for
|
|
1135
|
+
* update-only transforms whose base moved) and the loser could reach commit-consensus for a
|
|
1136
|
+
* write no member will ever durably store (its apply is refused stale and tolerated as 'ahead'
|
|
1137
|
+
* divergence — see `applyConsensusOperation`).
|
|
1138
|
+
*
|
|
1139
|
+
* Same "must run on the promise round" rule as {@link validateCommitOperations}: the
|
|
1140
|
+
* commit-round vote is deliberately blind, so promise votes are the only ones that carry
|
|
1141
|
+
* "I checked this". The four-way rule, per committed block:
|
|
1142
|
+
* - no local `latest`, or `latest.rev < commit.rev` → abstain (approve). Preserves the
|
|
1143
|
+
* lagging-member tolerance (`coordinator-repo-commit-divergence.spec.ts`): a member behind
|
|
1144
|
+
* the commit cannot judge it.
|
|
1145
|
+
* - `latest.rev === commit.rev` with the SAME action → abstain (approve). Idempotent
|
|
1146
|
+
* redelivery of an already-durable commit; rejecting would make the writer rebase and
|
|
1147
|
+
* re-append an action that already landed — a duplicate entry. (Storage's `alreadyDone`
|
|
1148
|
+
* partition returns success for this shape at apply.)
|
|
1149
|
+
* - `latest.rev === commit.rev` with a DIFFERENT action → reject: a rival took the revision.
|
|
1150
|
+
* - `latest.rev > commit.rev` → consult the {@link IRevisionActionReader} capability for who
|
|
1151
|
+
* holds `commit.rev`: a different action → reject; the same action → abstain (already
|
|
1152
|
+
* durable, history simply moved on); no record / capability absent / read fault → abstain.
|
|
1153
|
+
*
|
|
1154
|
+
* Never throws out of the vote path: any read fault is an abstain (mirroring the digest check's
|
|
1155
|
+
* preview-error arm), because a member that fails to vote at all is worse than one that
|
|
1156
|
+
* abstains. Reason stays plain prose — it is fed to computeSigningPayload and carried as
|
|
1157
|
+
* Signature.rejectReason, exactly like the stale-revision pend reject.
|
|
1158
|
+
*
|
|
1159
|
+
* Residual (see the commit-tier handoff): a member that signed the winner's commit but has not
|
|
1160
|
+
* yet APPLIED it sits in a window where it holds neither the winner's record (reservation
|
|
1161
|
+
* dropped at commit-sign, `shouldPersist = false`) nor the winner's revision (storage still
|
|
1162
|
+
* behind) — it abstains here. A capability-less member, or one with history truncated below
|
|
1163
|
+
* `latest`, abstains at `latest.rev > commit.rev` too. A rival's commit can therefore still
|
|
1164
|
+
* pass the promise round if EVERY member is simultaneously in one of those states — on a fast
|
|
1165
|
+
* cohort that window is the COMMON case, not the corner. The backstop is downstream of
|
|
1166
|
+
* consensus: every member's apply then refuses the rival as stale, the coordinating node's own
|
|
1167
|
+
* member retains that refusal (`getExecutedCommitResult`), and `CoordinatorRepo.commit`
|
|
1168
|
+
* confirms the rival against local storage and answers the writer with a retryable conflict
|
|
1169
|
+
* instead of a fabricated success. `ConflictRaceLostError` conversion and classified
|
|
1170
|
+
* rejections close the re-drive route the same way.
|
|
1171
|
+
*/
|
|
1172
|
+
async validateCommitRevisions(record) {
|
|
1173
|
+
for (const operation of record.message.operations) {
|
|
1174
|
+
if (!('commit' in operation)) {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const commit = operation.commit;
|
|
1178
|
+
let blockResults;
|
|
1179
|
+
try {
|
|
1180
|
+
// The member's raw storage repo (no cluster recursion) — the same seam
|
|
1181
|
+
// validatePendOperations reads on every pend vote.
|
|
1182
|
+
blockResults = await this.storageRepo.get({ blockIds: commit.blockIds });
|
|
1183
|
+
}
|
|
1184
|
+
catch (err) {
|
|
1185
|
+
log('cluster-member:commit-staleness-read-error', {
|
|
1186
|
+
messageHash: record.messageHash,
|
|
1187
|
+
actionId: commit.actionId,
|
|
1188
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1189
|
+
});
|
|
1190
|
+
continue; // a local read fault is an abstain, never an escape out of the vote path
|
|
1191
|
+
}
|
|
1192
|
+
for (const blockId of commit.blockIds) {
|
|
1193
|
+
const latest = blockResults[blockId]?.state?.latest;
|
|
1194
|
+
if (!latest || latest.rev < commit.rev) {
|
|
1195
|
+
continue; // behind (or block never seen): cannot judge — abstain
|
|
1196
|
+
}
|
|
1197
|
+
if (isOwnRevision(latest, commit.rev, commit.actionId)) {
|
|
1198
|
+
continue; // idempotent redelivery of an already-durable commit — MUST NOT reject
|
|
1199
|
+
}
|
|
1200
|
+
if (latest.rev === commit.rev) {
|
|
1201
|
+
log('cluster-member:validation-stale-commit', {
|
|
1202
|
+
messageHash: record.messageHash,
|
|
1203
|
+
blockId,
|
|
1204
|
+
actionId: commit.actionId,
|
|
1205
|
+
rev: commit.rev,
|
|
1206
|
+
committedBy: latest.actionId
|
|
1207
|
+
});
|
|
1208
|
+
return { valid: false, reason: `stale commit: block ${blockId} rev ${commit.rev} committed by a different action` };
|
|
1209
|
+
}
|
|
1210
|
+
// latest.rev > commit.rev: latest can no longer name who took commit.rev — ask the
|
|
1211
|
+
// revision index. Structural probe, same pattern as previewCommitDigest below: a repo
|
|
1212
|
+
// without the capability abstains.
|
|
1213
|
+
const reader = this.storageRepo;
|
|
1214
|
+
if (typeof reader.getRevisionAction !== 'function') {
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
let takenBy;
|
|
1218
|
+
try {
|
|
1219
|
+
takenBy = await reader.getRevisionAction(blockId, commit.rev);
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
log('cluster-member:commit-staleness-revision-read-error', {
|
|
1223
|
+
messageHash: record.messageHash,
|
|
1224
|
+
blockId,
|
|
1225
|
+
rev: commit.rev,
|
|
1226
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1227
|
+
});
|
|
1228
|
+
continue; // read fault → abstain
|
|
1229
|
+
}
|
|
1230
|
+
if (takenBy !== undefined && takenBy !== commit.actionId) {
|
|
1231
|
+
log('cluster-member:validation-stale-commit', {
|
|
1232
|
+
messageHash: record.messageHash,
|
|
1233
|
+
blockId,
|
|
1234
|
+
actionId: commit.actionId,
|
|
1235
|
+
rev: commit.rev,
|
|
1236
|
+
committedBy: takenBy,
|
|
1237
|
+
latestRev: latest.rev
|
|
1238
|
+
});
|
|
1239
|
+
return { valid: false, reason: `stale commit: block ${blockId} rev ${commit.rev} committed by a different action` };
|
|
1240
|
+
}
|
|
1241
|
+
// takenBy === commit.actionId (already durable, history moved on) or undefined
|
|
1242
|
+
// (truncated history — unknown): abstain either way.
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
return { valid: true };
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Promise-round check of a commit record's declared content digests
|
|
1249
|
+
* (`CommitRequest.blockDigests`) against what this member's OWN pended copy of each transform
|
|
1250
|
+
* would materialize (`StorageRepo.previewCommitDigest`). This is what makes a promise approval on
|
|
1251
|
+
* a commit record MEAN something about content: before this hook, the promise round validated
|
|
1252
|
+
* nothing for commits (`validatePendOperations` only inspects pend operations).
|
|
1253
|
+
*
|
|
1254
|
+
* MUST run on the promise round, not the commit round: the commit-round vote is cast deliberately
|
|
1255
|
+
* blind — `getTransactionPhase` signs the commit whenever promise approvals reach super-majority,
|
|
1256
|
+
* regardless of this member's own promise vote — so promise approvals are the only votes that
|
|
1257
|
+
* carry "I checked this". Do not move it.
|
|
1258
|
+
*
|
|
1259
|
+
* Checkable/abstain rule, keyed on the member's OWN pended transform (the payload the client
|
|
1260
|
+
* authored, delivered at pend — a hostile declarer cannot force or dodge a check by mis-declaring
|
|
1261
|
+
* `baseRev`):
|
|
1262
|
+
* - transform carries an `insert` → base-independent, ALWAYS check (declared `baseRev` ignored);
|
|
1263
|
+
* - `updates` only → check iff this member's local base rev equals the declared `baseRev`
|
|
1264
|
+
* (StorageRepo.commit accepts any `latest.rev < request.rev`, so a lagging member applying an
|
|
1265
|
+
* update-only transform to an older base legitimately materializes different bytes);
|
|
1266
|
+
* - `delete` only / no base / unmaterializable base → materializes nothing to compare, abstain;
|
|
1267
|
+
* - no pending transform for the action (this member never saw the pend) → abstain.
|
|
1268
|
+
* "Abstain" = contribute no content attestation: approve exactly as before this check existed.
|
|
1269
|
+
*
|
|
1270
|
+
* Residual: a false digest survives only when the declarer lies AND enough of the cohort is
|
|
1271
|
+
* simultaneously unable to check (lagging on update-only blocks, missed pends) that no honest
|
|
1272
|
+
* checker is left — any single caught-up honest member rejects. Strictly stronger than before,
|
|
1273
|
+
* when commit signatures bound no content at all. Verifying/persisting a durable content proof is
|
|
1274
|
+
* later work (persist-block-commit-proof).
|
|
1275
|
+
*/
|
|
1276
|
+
async validateCommitOperations(record) {
|
|
1277
|
+
// Capability probe: `storageRepo` is typed IRepo, and only a repo that owns the local
|
|
1278
|
+
// materialization can preview one. A repo without the capability abstains everywhere (also
|
|
1279
|
+
// keeps mock-repo harnesses and non-storage compositions on the legacy approve path).
|
|
1280
|
+
// NOTE: probing structurally means a decorating/caching repo later inserted at this seam
|
|
1281
|
+
// silently disables the whole check with no signal. `ICommitDigestPreviewer` exists so such a
|
|
1282
|
+
// decorator has a named contract to forward; if a non-forwarding wrapper is ever wired here,
|
|
1283
|
+
// promote this to a typed component field rather than widening the probe.
|
|
1284
|
+
const repo = this.storageRepo;
|
|
1285
|
+
if (typeof repo.previewCommitDigest !== 'function') {
|
|
1286
|
+
return { valid: true };
|
|
1287
|
+
}
|
|
1288
|
+
for (const operation of record.message.operations) {
|
|
1289
|
+
if (!('commit' in operation)) {
|
|
1290
|
+
continue;
|
|
1291
|
+
}
|
|
1292
|
+
const commit = operation.commit;
|
|
1293
|
+
// An upgraded member receiving a commit with no declarations abstains everywhere.
|
|
1294
|
+
if (!commit.blockDigests) {
|
|
1295
|
+
continue;
|
|
1296
|
+
}
|
|
1297
|
+
// One Set per commit operation: the surplus-entry filter below is a membership test per
|
|
1298
|
+
// declared id, and `blockIds` is a per-coordinator batch that can be wide.
|
|
1299
|
+
const committedIds = new Set(commit.blockIds);
|
|
1300
|
+
// NOTE: previews run one block at a time, so a commit declaring N blocks adds N sequential
|
|
1301
|
+
// preview round-trips (each 1-3 block-storage reads plus a structuredClone of the base and
|
|
1302
|
+
// the transform) to this member's promise-round latency. Unmeasured, and sequencing buys the
|
|
1303
|
+
// short-circuit on the first mismatch. If wide commits ever show up as promise latency,
|
|
1304
|
+
// fan the previews out with Promise.all and reduce the results, rather than sampling a
|
|
1305
|
+
// subset of the declared ids — a skipped id is an unchecked id.
|
|
1306
|
+
for (const [blockId, declared] of Object.entries(commit.blockDigests)) {
|
|
1307
|
+
// Surplus (or hostile) entry for a block this commit does not even cover: ignore it —
|
|
1308
|
+
// never throw out of the vote path, and never reject on content nobody is committing.
|
|
1309
|
+
if (!committedIds.has(blockId)) {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
// `blockDigests` is untrusted wire data with no ingress schema behind it, so the entry
|
|
1313
|
+
// need not be the shape the type promises. A malformed entry is treated as an omitted
|
|
1314
|
+
// one (abstain) rather than a mismatch: rejecting on it would let a garbled request
|
|
1315
|
+
// look like forged content, and reading through it would throw a TypeError out of the
|
|
1316
|
+
// vote path — this member would then fail to vote at all instead of voting reject.
|
|
1317
|
+
if (typeof declared?.digest !== 'string') {
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
let preview;
|
|
1321
|
+
try {
|
|
1322
|
+
preview = await repo.previewCommitDigest(blockId, commit.actionId, commit.rev);
|
|
1323
|
+
}
|
|
1324
|
+
catch (err) {
|
|
1325
|
+
log('cluster-member:content-digest-preview-error', {
|
|
1326
|
+
messageHash: record.messageHash,
|
|
1327
|
+
blockId,
|
|
1328
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1329
|
+
});
|
|
1330
|
+
continue; // a local preview fault is an abstain, never a content judgement
|
|
1331
|
+
}
|
|
1332
|
+
// No pend seen here, or the transform materializes nothing to compare (tombstone,
|
|
1333
|
+
// updates with no base, unmaterializable base) → abstain.
|
|
1334
|
+
if (preview === undefined || preview.digest === undefined) {
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
// `typeof === 'number'` rather than `!== undefined` for the same untrusted-shape reason
|
|
1338
|
+
// as the digest guard above: a non-numeric declared baseRev can never equal a local
|
|
1339
|
+
// one, so it degrades to an abstain instead of comparing junk.
|
|
1340
|
+
const checkable = preview.baseIndependent
|
|
1341
|
+
|| (typeof declared.baseRev === 'number' && preview.baseRev === declared.baseRev);
|
|
1342
|
+
if (!checkable) {
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
if (preview.digest !== declared.digest) {
|
|
1346
|
+
log('cluster-member:content-digest-mismatch', {
|
|
1347
|
+
messageHash: record.messageHash,
|
|
1348
|
+
blockId,
|
|
1349
|
+
actionId: commit.actionId,
|
|
1350
|
+
rev: commit.rev,
|
|
1351
|
+
declaredDigest: declared.digest,
|
|
1352
|
+
declaredBaseRev: declared.baseRev,
|
|
1353
|
+
previewDigest: preview.digest,
|
|
1354
|
+
previewBaseRev: preview.baseRev,
|
|
1355
|
+
baseIndependent: preview.baseIndependent
|
|
1356
|
+
});
|
|
1357
|
+
// One vote per record: a single mismatching block rejects the whole record.
|
|
1358
|
+
return { valid: false, reason: CONTENT_DIGEST_MISMATCH };
|
|
1359
|
+
}
|
|
964
1360
|
}
|
|
965
1361
|
}
|
|
966
1362
|
return { valid: true };
|
|
@@ -1033,7 +1429,11 @@ export class ClusterMember {
|
|
|
1033
1429
|
// the real cause. The durable marker was never written (it lands only after
|
|
1034
1430
|
// apply succeeds, below), so there is nothing to roll back. Recoverable local
|
|
1035
1431
|
// divergence is absorbed inside applyConsensusOperation and never reaches here.
|
|
1432
|
+
// A retained pend verdict rolls back with the marker: it belongs to an apply
|
|
1433
|
+
// that is now considered not-executed, and a re-run will retain a fresh one.
|
|
1036
1434
|
this.executedTransactions.delete(record.messageHash);
|
|
1435
|
+
this.executedPendResults.delete(record.messageHash);
|
|
1436
|
+
this.executedCommitResults.delete(record.messageHash);
|
|
1037
1437
|
throw err;
|
|
1038
1438
|
}
|
|
1039
1439
|
// Persist the durable marker only now that apply has actually succeeded. Writing it
|
|
@@ -1103,6 +1503,13 @@ export class ClusterMember {
|
|
|
1103
1503
|
}
|
|
1104
1504
|
if ('pend' in operation) {
|
|
1105
1505
|
const result = await this.storageRepo.pend(operation.pend);
|
|
1506
|
+
// Retain the verdict either way so the coordinator can hand the writer storage's real
|
|
1507
|
+
// answer (see getExecutedPendResult). A refusal here is NOT local divergence the way a
|
|
1508
|
+
// commit refusal is: pend-consensus confers no durability — a refusal carrying `pending`
|
|
1509
|
+
// (a rival's unresolved action holds the blocks) or `missing` (the requested revision is
|
|
1510
|
+
// already committed) is the optimistic-concurrency verdict, and swallowing it acknowledged
|
|
1511
|
+
// writes that no member stored.
|
|
1512
|
+
this.executedPendResults.set(messageHash, result);
|
|
1106
1513
|
if (!result.success) {
|
|
1107
1514
|
log('cluster-member:consensus-pend-diverged', {
|
|
1108
1515
|
messageHash,
|
|
@@ -1133,9 +1540,23 @@ export class ClusterMember {
|
|
|
1133
1540
|
const commitSignedPayload = clusterVoteSigningPayload(await this.computeCommitHash(record), 'approve');
|
|
1134
1541
|
this.captureCommitCert(record, commit.actionId, commitSignedPayload);
|
|
1135
1542
|
}
|
|
1543
|
+
// Project the consensus record into a durable BlockCommitProof and hand it down the commit
|
|
1544
|
+
// path — StorageRepo persists it only where the local materialization matches the declared
|
|
1545
|
+
// digest (see persistProofIfContentMatches). A cheap projection (no hashing/signing).
|
|
1546
|
+
// `undefined` for a v1 / unversioned record: its hashes bind no peer set, so it is never
|
|
1547
|
+
// certifiable — logged so an operator can see why a cohort retains no proofs. The cast is the
|
|
1548
|
+
// named ICommitProofPersister contract; a plain IRepo mock ignores the extra argument.
|
|
1549
|
+
const proof = buildBlockCommitProof(record);
|
|
1550
|
+
if (proof === undefined) {
|
|
1551
|
+
log('cluster-member:commit-proof-skipped', {
|
|
1552
|
+
messageHash,
|
|
1553
|
+
actionId: commit.actionId,
|
|
1554
|
+
membershipVersion: record.membershipVersion
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1136
1557
|
let result;
|
|
1137
1558
|
try {
|
|
1138
|
-
result = await this.storageRepo.commit(commit);
|
|
1559
|
+
result = await this.storageRepo.commit(commit, undefined, proof);
|
|
1139
1560
|
}
|
|
1140
1561
|
catch (err) {
|
|
1141
1562
|
// `StorageRepo.commit` throws (rather than returning success:false) when
|
|
@@ -1156,12 +1577,34 @@ export class ClusterMember {
|
|
|
1156
1577
|
}
|
|
1157
1578
|
throw err;
|
|
1158
1579
|
}
|
|
1580
|
+
// Retain the verdict either way (see getExecutedCommitResult): a success confirms local
|
|
1581
|
+
// durability, and an ahead-shaped refusal is the only evidence the coordinator has that
|
|
1582
|
+
// the tolerance below swallowed a rival's win at the requested revision. The
|
|
1583
|
+
// missing-pending throw path above retains nothing — no CommitResult exists there, and
|
|
1584
|
+
// the coordinator's fabricated-success fallback plus cohort reconcile is the right shape
|
|
1585
|
+
// for a member that is genuinely behind.
|
|
1586
|
+
this.executedCommitResults.set(messageHash, result);
|
|
1159
1587
|
if (!result.success) {
|
|
1160
1588
|
// success:false is a StaleFailure. `missing` ⇒ ahead/stale divergence
|
|
1161
1589
|
// (we already hold ≥ this rev): tolerate, do NOT reconcile downward. A
|
|
1162
1590
|
// missing-base reason ⇒ behind divergence, reconcile (below). Any other bare
|
|
1163
1591
|
// `reason` with no `missing` ⇒ a genuine internalCommit fault: propagate so
|
|
1164
1592
|
// handleConsensus rolls back the executed marker and rethrows.
|
|
1593
|
+
//
|
|
1594
|
+
// NOTE: this 'ahead' tolerance is what turns a rival's commit that somehow reaches
|
|
1595
|
+
// consensus into a reported success no member durably stored (consensus without
|
|
1596
|
+
// durability — the commit-tier acknowledgement hole). It must stay: a member
|
|
1597
|
+
// genuinely ahead of a redelivered/lagging commit is the common, correct case. The
|
|
1598
|
+
// guards live UPSTREAM: `validateCommitRevisions` rejects the rival at the promise
|
|
1599
|
+
// round, `CoordinatorRepo.commit` returns lost races as retryable conflicts instead
|
|
1600
|
+
// of re-driving them, and the verdict retained just above (getExecutedCommitResult)
|
|
1601
|
+
// lets the coordinating node convert its OWN member's rival-confirmed refusal into a
|
|
1602
|
+
// conflict answer — that last guard is what closes the signed-but-not-yet-applied
|
|
1603
|
+
// window, where two commits for one revision both assemble consensus because signing
|
|
1604
|
+
// drops each member's reservation before applying advances its storage. If
|
|
1605
|
+
// consensus-without-durability is ever observed again, look at those guards' abstain
|
|
1606
|
+
// residuals (non-coordinating members' verdicts are not threaded back; capability-less
|
|
1607
|
+
// or history-truncated storage abstains), not at this branch.
|
|
1165
1608
|
if (result.missing?.length) {
|
|
1166
1609
|
log('cluster-member:consensus-commit-diverged', {
|
|
1167
1610
|
messageHash,
|
|
@@ -1496,7 +1939,7 @@ export class ClusterMember {
|
|
|
1496
1939
|
}
|
|
1497
1940
|
/**
|
|
1498
1941
|
* Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
|
|
1499
|
-
* The multi-collection path carries it on `pend.transaction.priority`; the single-collection
|
|
1942
|
+
* The multi-collection path carries it on `pend.validation.transaction.priority`; the single-collection
|
|
1500
1943
|
* (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
|
|
1501
1944
|
* legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
|
|
1502
1945
|
* (backward compatible: such transactions simply never age). Both carriers live inside the signed
|
|
@@ -1512,7 +1955,11 @@ export class ClusterMember {
|
|
|
1512
1955
|
recordPriority(record) {
|
|
1513
1956
|
for (const op of record.message.operations) {
|
|
1514
1957
|
if ('pend' in op) {
|
|
1515
|
-
|
|
1958
|
+
// Every hop optional: `validation` arrives off the wire inside a signed message whose
|
|
1959
|
+
// hash binds its bytes, not its shape, so a malformed pair must yield priority 0 (what
|
|
1960
|
+
// clampPriority already does for a missing or Byzantine number) rather than throw out
|
|
1961
|
+
// of the vote path — the lost vote this fail-closed pass exists to prevent.
|
|
1962
|
+
return clampPriority(op.pend.validation?.transaction?.priority ?? op.pend.priority);
|
|
1516
1963
|
}
|
|
1517
1964
|
}
|
|
1518
1965
|
return 0;
|
|
@@ -1553,6 +2000,13 @@ export class ClusterMember {
|
|
|
1553
2000
|
}
|
|
1554
2001
|
return undefined;
|
|
1555
2002
|
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Every block id the message's own operations name. Two consumers, deliberately sharing one
|
|
2005
|
+
* definition: conflict detection (which writes must serialize against each other) and the membership
|
|
2006
|
+
* admission gate's binding check (the set a legitimate `coordinatingBlockIds[0]` must come from —
|
|
2007
|
+
* {@link ClusterMember.deriveExpectedClusterView}). If the two ever disagreed, a coordinator could
|
|
2008
|
+
* name a block the record is not judged to touch.
|
|
2009
|
+
*/
|
|
1556
2010
|
getAffectedBlockIds(operations) {
|
|
1557
2011
|
const blockIds = new Set();
|
|
1558
2012
|
for (const operation of operations) {
|
|
@@ -1636,6 +2090,8 @@ export class ClusterMember {
|
|
|
1636
2090
|
for (const [messageHash, executedAt] of Array.from(this.executedTransactions.entries())) {
|
|
1637
2091
|
if (executedAt < expirationThreshold) {
|
|
1638
2092
|
this.executedTransactions.delete(messageHash);
|
|
2093
|
+
this.executedPendResults.delete(messageHash);
|
|
2094
|
+
this.executedCommitResults.delete(messageHash);
|
|
1639
2095
|
}
|
|
1640
2096
|
}
|
|
1641
2097
|
// Prune old applied-invalidation dedup markers on the same TTL.
|