@optimystic/db-p2p 0.25.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cluster/certified-claims.d.ts +17 -3
- package/dist/src/cluster/certified-claims.d.ts.map +1 -1
- package/dist/src/cluster/certified-claims.js +5 -3
- package/dist/src/cluster/certified-claims.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +57 -92
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +149 -201
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/commit-proof.d.ts +16 -0
- package/dist/src/cluster/commit-proof.d.ts.map +1 -1
- package/dist/src/cluster/commit-proof.js +32 -1
- package/dist/src/cluster/commit-proof.js.map +1 -1
- package/dist/src/cluster/quorum-restore.d.ts +81 -28
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
- package/dist/src/cluster/quorum-restore.js +148 -51
- package/dist/src/cluster/quorum-restore.js.map +1 -1
- package/dist/src/cluster/race-resolution.d.ts +89 -0
- package/dist/src/cluster/race-resolution.d.ts.map +1 -0
- package/dist/src/cluster/race-resolution.js +140 -0
- package/dist/src/cluster/race-resolution.js.map +1 -0
- package/dist/src/cluster/reconcile-block.d.ts +9 -4
- package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
- package/dist/src/cluster/reconcile-block.js +28 -11
- package/dist/src/cluster/reconcile-block.js.map +1 -1
- package/dist/src/cluster/record-operations.d.ts +25 -0
- package/dist/src/cluster/record-operations.d.ts.map +1 -0
- package/dist/src/cluster/record-operations.js +56 -0
- package/dist/src/cluster/record-operations.js.map +1 -0
- package/dist/src/cohort-topic/host.d.ts +133 -51
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +290 -83
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +13 -4
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +5 -5
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +9 -0
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +13 -2
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +34 -2
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +57 -3
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/block-latch.d.ts +5 -4
- package/dist/src/storage/block-latch.d.ts.map +1 -1
- package/dist/src/storage/block-latch.js +5 -4
- package/dist/src/storage/block-latch.js.map +1 -1
- package/dist/src/storage/block-storage.d.ts +1 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +11 -3
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/cached-raw-storage.d.ts +17 -1
- package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/cached-raw-storage.js +8 -1
- package/dist/src/storage/cached-raw-storage.js.map +1 -1
- package/dist/src/storage/cached-store-driver.d.ts +7 -0
- package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
- package/dist/src/storage/cached-store-driver.js +20 -0
- package/dist/src/storage/cached-store-driver.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +45 -2
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-block-storage.js +29 -0
- package/dist/src/storage/i-block-storage.js.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +16 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +5 -1
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.js +8 -1
- package/dist/src/storage/kv-raw-storage.js.map +1 -1
- package/dist/src/storage/raw-store-driver.d.ts +15 -0
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.d.ts +5 -0
- package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
- package/dist/src/storage/shared-cache-pool.js +6 -1
- package/dist/src/storage/shared-cache-pool.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +141 -99
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/storage/with-read-cache.d.ts +9 -5
- package/dist/src/storage/with-read-cache.d.ts.map +1 -1
- package/dist/src/storage/with-read-cache.js +16 -6
- package/dist/src/storage/with-read-cache.js.map +1 -1
- package/package.json +2 -2
- package/{README.md → readme.md} +2 -2
- package/src/cluster/certified-claims.ts +22 -9
- package/src/cluster/cluster-repo.ts +199 -212
- package/src/cluster/commit-proof.ts +38 -2
- package/src/cluster/quorum-restore.ts +183 -56
- package/src/cluster/race-resolution.ts +158 -0
- package/src/cluster/reconcile-block.ts +34 -11
- package/src/cluster/record-operations.ts +59 -0
- package/src/cohort-topic/host.ts +423 -118
- package/src/libp2p-node-base.ts +18 -9
- package/src/repo/cluster-coordinator.ts +1039 -1027
- package/src/repo/coordinator-repo.ts +1937 -1855
- package/src/storage/block-latch.ts +5 -4
- package/src/storage/block-storage.ts +11 -3
- package/src/storage/cached-raw-storage.ts +21 -1
- package/src/storage/cached-store-driver.ts +23 -0
- package/src/storage/i-block-storage.ts +47 -2
- package/src/storage/i-raw-storage.ts +17 -0
- package/src/storage/kv-raw-storage.ts +8 -1
- package/src/storage/raw-store-driver.ts +26 -0
- package/src/storage/shared-cache-pool.ts +6 -1
- package/src/storage/storage-repo.ts +143 -103
- package/src/storage/with-read-cache.ts +16 -6
|
@@ -2,7 +2,7 @@ import type { IRepo, ClusterRecord, ClusterPeers, Signature, RepoMessage, ITrans
|
|
|
2
2
|
import type { ICluster } from "@optimystic/db-core";
|
|
3
3
|
import type { IPeerNetwork } from "@optimystic/db-core";
|
|
4
4
|
import { blockIdsForTransforms, isOwnRevision, DEFAULT_SUPER_MAJORITY_THRESHOLD } from "@optimystic/db-core";
|
|
5
|
-
import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest,
|
|
5
|
+
import { computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash, membershipDigest, recordMembershipDigest, clusterVoteSigningPayload, clusterVoteVerificationPayload } from "@optimystic/db-core";
|
|
6
6
|
import { verifyInvalidationCertificate, type ArbitratorSetRecompute } from "../dispute/invalidation.js";
|
|
7
7
|
import { buildCommitCert, invalidationActionId } from "./commit-cert.js";
|
|
8
8
|
import { ClusterClient } from "./client.js";
|
|
@@ -19,7 +19,9 @@ import { PenaltyReason } from "../reputation/types.js";
|
|
|
19
19
|
import type { ITransactionStateStore } from "./i-transaction-state-store.js";
|
|
20
20
|
import { isMissingBaseRevisionFailure, type CommitDigestPreview, type ICommitDigestPreviewer, type ICommitProofPersister, type IRevisionActionReader } from "../storage/storage-repo.js";
|
|
21
21
|
import { checkPendValidation } from "../pend-validation.js";
|
|
22
|
-
import {
|
|
22
|
+
import { getAffectedBlockIds } from "./record-operations.js";
|
|
23
|
+
import { operationsConflict, resolveRace } from "./race-resolution.js";
|
|
24
|
+
import { buildBlockCommitProof, mintSoloCommitProof, type BlockCommitProof } from "./commit-proof.js";
|
|
23
25
|
import { RECONCILE_TIMEOUT_MS } from "./reconcile-block.js";
|
|
24
26
|
|
|
25
27
|
const log = createLogger('cluster-member')
|
|
@@ -132,13 +134,40 @@ export type ExpectedClusterView = {
|
|
|
132
134
|
* Independently derive this member's own view of a block's responsible cluster. Injected so
|
|
133
135
|
* {@link ClusterMember} stays transport-agnostic — the composition root supplies it from
|
|
134
136
|
* `IKeyNetwork.findCluster` + FRET (mirroring how the coordinator derives the cluster). Absent on nodes
|
|
135
|
-
* that cannot derive a view (no FRET, unit tests): with no
|
|
137
|
+
* that cannot derive a view (no FRET, unit tests): with no capability AND no asserted
|
|
136
138
|
* {@link ClusterConsensusConfig.assumedClusterSize} the gate preserves legacy approve behavior, but an
|
|
137
|
-
* asserted size still lets the gate fail closed on an unjustified downsize.
|
|
138
|
-
*
|
|
139
|
+
* asserted size still lets the gate fail closed on an unjustified downsize.
|
|
140
|
+
*
|
|
141
|
+
* Wiring this capability also arms the record-shape refusals: a member that CAN derive refuses outright
|
|
142
|
+
* (rather than falling back) when the record names no coordinating block, or names one the record's own
|
|
143
|
+
* operations never touch — those are the sender's free choice, not this member's inability. See
|
|
144
|
+
* {@link ClusterViewDerivation} and {@link ClusterMember} admission gate.
|
|
139
145
|
*/
|
|
140
146
|
export type DeriveExpectedClusterCallback = (blockId: BlockId) => Promise<ExpectedClusterView>;
|
|
141
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Why a member does or does not have its own view of a record's cohort. The whole point of the union is
|
|
150
|
+
* that the caller MUST distinguish a fault of the RECEIVER (nothing to check against — stay lenient)
|
|
151
|
+
* from a fault of the SENDER (a record no current coordinator would build — refuse), a distinction a
|
|
152
|
+
* bare `undefined` erased and let a coordinator exploit: by choosing how it filled `coordinatingBlockIds`
|
|
153
|
+
* the coordinator chose which check every member ran.
|
|
154
|
+
*
|
|
155
|
+
* Module-internal on purpose — nothing outside this file consumes it.
|
|
156
|
+
*/
|
|
157
|
+
type ClusterViewDerivation =
|
|
158
|
+
/** The member resolved a view. Confidence / emptiness is judged by the caller, not here. */
|
|
159
|
+
| { kind: 'view'; view: ExpectedClusterView }
|
|
160
|
+
/** No {@link DeriveExpectedClusterCallback} wired (no FRET, unit tests): nothing to check against. */
|
|
161
|
+
| { kind: 'no-capability' }
|
|
162
|
+
/**
|
|
163
|
+
* A usable block was named but the lookup itself failed. Receiver fault. Carries no payload: the
|
|
164
|
+
* error is logged where it is caught, and the gate's response does not depend on which error it was.
|
|
165
|
+
*/
|
|
166
|
+
| { kind: 'underivable' }
|
|
167
|
+
/** The record names no block this member can legitimately derive from. Sender fault. */
|
|
168
|
+
| { kind: 'unusable-record'; variant: 'no-coordinating-block' }
|
|
169
|
+
| { kind: 'unusable-record'; variant: 'unbound-coordinating-block'; blockId: string; affected: number };
|
|
170
|
+
|
|
142
171
|
/** Stable reject reason a member emits when a declared peer set fails the membership admission gate. */
|
|
143
172
|
export const MEMBERSHIP_NOT_ADMITTED = 'membership-not-admitted';
|
|
144
173
|
|
|
@@ -182,6 +211,19 @@ interface ClusterMemberComponents {
|
|
|
182
211
|
recomputeArbitratorSet?: RecomputeArbitratorSetCapability;
|
|
183
212
|
/** Member-side cluster derivation for the membership admission gate; see {@link DeriveExpectedClusterCallback}. */
|
|
184
213
|
deriveExpectedCluster?: DeriveExpectedClusterCallback;
|
|
214
|
+
/**
|
|
215
|
+
* Wall clock in unix milliseconds; defaults to `Date.now`. Injectable so a test can age a held
|
|
216
|
+
* reservation past {@link CONFLICT_STALE_THRESHOLD_MS} without sleeping. It governs BOTH sides of
|
|
217
|
+
* the reservation's `lastUpdate` — the stamp and the comparison — so the two can never end up on
|
|
218
|
+
* different time bases.
|
|
219
|
+
*
|
|
220
|
+
* NOTE: partial injection, by design. This clock reaches ONLY `lastUpdate`; `message.expiration`,
|
|
221
|
+
* the promise/resolution timeouts, the periodic expiry sweep and the executed-transaction TTL all
|
|
222
|
+
* still read the real `Date.now`. So an injected clock must share an epoch with real time (seed it
|
|
223
|
+
* from `Date.now()`, then advance) — one starting near zero makes every record look long expired
|
|
224
|
+
* via the un-injected expiration check. Widen the injection if a test needs to drive expiry too.
|
|
225
|
+
*/
|
|
226
|
+
now?: () => number;
|
|
185
227
|
}
|
|
186
228
|
|
|
187
229
|
export function clusterMember(components: ClusterMemberComponents): ClusterMember {
|
|
@@ -201,13 +243,22 @@ export function clusterMember(components: ClusterMemberComponents): ClusterMembe
|
|
|
201
243
|
components.onCommitCertificate,
|
|
202
244
|
components.onInvalidate,
|
|
203
245
|
components.recomputeArbitratorSet,
|
|
204
|
-
components.deriveExpectedCluster
|
|
246
|
+
components.deriveExpectedCluster,
|
|
247
|
+
components.now
|
|
205
248
|
);
|
|
206
249
|
}
|
|
207
250
|
|
|
208
251
|
// How long to keep executed transaction records (10 minutes)
|
|
209
252
|
const ExecutedTransactionTtlMs = 10 * 60 * 1000;
|
|
210
253
|
|
|
254
|
+
/**
|
|
255
|
+
* How long a held reservation may go untouched before the conflict scan ({@link ClusterMember.findConflict})
|
|
256
|
+
* sweeps it. Generous relative to a round-trip: the scan frees an ABANDONED coordinator's blocks, so
|
|
257
|
+
* sweeping too eagerly would drop a live transaction whose next delivery is merely in flight.
|
|
258
|
+
* Exported so a test can advance an injected clock past it without restating the number.
|
|
259
|
+
*/
|
|
260
|
+
export const CONFLICT_STALE_THRESHOLD_MS = 2000;
|
|
261
|
+
|
|
211
262
|
// Upper bound on an awaited active reconciliation of a divergent commit. Bounds the
|
|
212
263
|
// consensus path so a slow/unreachable cohort peer can't stall the cluster stream;
|
|
213
264
|
// a timeout is logged and tolerated (never thrown — that would reset the stream).
|
|
@@ -284,6 +335,8 @@ export class ClusterMember implements ICluster {
|
|
|
284
335
|
/** What a validator-configured member does with a pend carrying no `validation` payload — see
|
|
285
336
|
* {@link ClusterConsensusConfig.unvalidatablePendPolicy}. Read once, like the gate parameters. */
|
|
286
337
|
private readonly unvalidatablePendPolicy: UnvalidatablePendPolicy;
|
|
338
|
+
/** Clock behind the reservation table's `lastUpdate` — see {@link ClusterMemberComponents.now}. */
|
|
339
|
+
private readonly now: () => number;
|
|
287
340
|
|
|
288
341
|
constructor(
|
|
289
342
|
private readonly storageRepo: IRepo,
|
|
@@ -302,8 +355,10 @@ export class ClusterMember implements ICluster {
|
|
|
302
355
|
private readonly onCommitCertificate?: CommitCertificateSink,
|
|
303
356
|
private readonly onInvalidate?: InvalidationApplySink,
|
|
304
357
|
private readonly recomputeArbitratorSet?: RecomputeArbitratorSetCapability,
|
|
305
|
-
private readonly deriveExpectedCluster?: DeriveExpectedClusterCallback
|
|
358
|
+
private readonly deriveExpectedCluster?: DeriveExpectedClusterCallback,
|
|
359
|
+
now?: () => number
|
|
306
360
|
) {
|
|
361
|
+
this.now = now ?? ((): number => Date.now());
|
|
307
362
|
this.superMajorityThreshold = consensusConfig?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD;
|
|
308
363
|
this.minAbsoluteClusterSize = consensusConfig?.minAbsoluteClusterSize ?? 3;
|
|
309
364
|
this.clusterSizeTolerance = consensusConfig?.clusterSizeTolerance ?? 0.5;
|
|
@@ -390,6 +445,17 @@ export class ClusterMember implements ICluster {
|
|
|
390
445
|
return this.executedCommitResults.get(messageHash);
|
|
391
446
|
}
|
|
392
447
|
|
|
448
|
+
/**
|
|
449
|
+
* Self-sign a one-peer {@link BlockCommitProof} over `message` — a thin delegate to
|
|
450
|
+
* {@link mintSoloCommitProof} with this member's own id and key. Lives here because this class is
|
|
451
|
+
* the key holder: `CoordinatorRepo` (the caller, on its solo-cohort commit short-circuit) knows
|
|
452
|
+
* the local peer id but never sees the private key. Nothing else moves — no record, no consensus
|
|
453
|
+
* state; the mint is a pure signing operation over the message the caller built.
|
|
454
|
+
*/
|
|
455
|
+
async mintSoloCommitProof(message: RepoMessage): Promise<BlockCommitProof> {
|
|
456
|
+
return mintSoloCommitProof(this.peerId.toString(), this.privateKey, message);
|
|
457
|
+
}
|
|
458
|
+
|
|
393
459
|
/**
|
|
394
460
|
* Handles an incoming cluster update, managing the two-phase commit process
|
|
395
461
|
* and coordinating with the local storage repo
|
|
@@ -574,7 +640,7 @@ export class ClusterMember implements ICluster {
|
|
|
574
640
|
const timeouts = this.setupTimeouts(currentRecord);
|
|
575
641
|
this.activeTransactions.set(record.messageHash, {
|
|
576
642
|
record: currentRecord,
|
|
577
|
-
lastUpdate:
|
|
643
|
+
lastUpdate: this.now(),
|
|
578
644
|
promiseTimeout: timeouts.promiseTimeout,
|
|
579
645
|
resolutionTimeout: timeouts.resolutionTimeout
|
|
580
646
|
});
|
|
@@ -1047,8 +1113,16 @@ export class ClusterMember implements ICluster {
|
|
|
1047
1113
|
* 3. **Consistency with the derived view** — `|D △ E|` within `clusterSizeTolerance·|E|`; honest churn
|
|
1048
1114
|
* of a peer or two is absorbed, a wholesale-disjoint or half-size set is not.
|
|
1049
1115
|
*
|
|
1050
|
-
* **
|
|
1051
|
-
*
|
|
1116
|
+
* **Inadmissible records come first.** Before any of that, a member that CAN derive refuses outright a
|
|
1117
|
+
* record whose coordinating block is the sender's free choice rather than a fact about the record: one
|
|
1118
|
+
* that names no coordinating block at all, or names a block the record's own operations never touch.
|
|
1119
|
+
* Those are defects of the SENDER, and no current coordinator produces them; treating them as "cannot
|
|
1120
|
+
* derive" would hand a dishonest coordinator the choice of which check every member ran. A member with
|
|
1121
|
+
* no derivation capability never reaches this refusal — it has nothing to check against.
|
|
1122
|
+
*
|
|
1123
|
+
* **Fail-closed posture.** When the member cannot confidently derive `E` — no capability, a bound
|
|
1124
|
+
* block whose lookup failed or returned an empty/low-confidence view (low FRET confidence is exactly
|
|
1125
|
+
* what a partition induces) — it must refuse any *downsizing* decision — but it
|
|
1052
1126
|
* needs a size reference to judge "downsize" against, and it may NOT borrow `clusterSize` for that:
|
|
1053
1127
|
* `clusterSize` is the replication factor (what a cohort should aim for), not a claim about how many
|
|
1054
1128
|
* peers exist, so a small deployment configured with the default 10 would refuse every write. The
|
|
@@ -1073,12 +1147,59 @@ export class ClusterMember implements ICluster {
|
|
|
1073
1147
|
}
|
|
1074
1148
|
|
|
1075
1149
|
// Explicit opt-in: knowingly transact below the safe floor (single-node / local dev). Skips the
|
|
1076
|
-
// size/consistency gates but not self-membership above
|
|
1150
|
+
// size/consistency gates AND the record-shape refusals below, but not self-membership above: it
|
|
1151
|
+
// already bypasses the far stronger confident predicates, so making a weaker check the one thing it
|
|
1152
|
+
// cannot bypass would be incoherent.
|
|
1077
1153
|
if (this.allowUnvalidatedSmallCluster) {
|
|
1078
1154
|
return { admit: true };
|
|
1079
1155
|
}
|
|
1080
1156
|
|
|
1081
|
-
const
|
|
1157
|
+
const derivation = await this.deriveExpectedClusterView(record);
|
|
1158
|
+
|
|
1159
|
+
// Split the sender's faults from this member's own. A record that names no coordinating block, or
|
|
1160
|
+
// names one its own operations never touch, is one no current coordinator builds (every production
|
|
1161
|
+
// sender goes through `ClusterCoordinator.executeClusterTransaction`, which stamps a bound id) — so
|
|
1162
|
+
// it is inadmissible, not merely underived. Collapsing these into the lenient fallback let a
|
|
1163
|
+
// dishonest coordinator choose which check every member ran, just by how it filled one field.
|
|
1164
|
+
// Exhaustive switch on purpose: a future `kind` must not silently join the lenient bucket.
|
|
1165
|
+
switch (derivation.kind) {
|
|
1166
|
+
case 'unusable-record': {
|
|
1167
|
+
log('cluster-member:admission-reject', {
|
|
1168
|
+
messageHash: record.messageHash,
|
|
1169
|
+
reason: derivation.variant,
|
|
1170
|
+
declaredSize: declared.length
|
|
1171
|
+
});
|
|
1172
|
+
// The count of affected block ids, not the list: this string is signed into the vote and lands
|
|
1173
|
+
// in dispute records, so a wide multi-block pend must not produce an unbounded reason.
|
|
1174
|
+
// NOTE: `blockId` itself is copied verbatim from the (untrusted) record and nothing upstream
|
|
1175
|
+
// bounds its length — fine while block ids are short content hashes; if a record ever carries
|
|
1176
|
+
// a pathological id, truncate it here rather than signing an arbitrarily large reason string.
|
|
1177
|
+
// A fresh record carries no signatures, so `validateRecord` does not authenticate the sender
|
|
1178
|
+
// before this point; what keeps it harmless is that the reason is bounded by the record the
|
|
1179
|
+
// sender already transmitted (no amplification), not that the path is authenticated.
|
|
1180
|
+
return {
|
|
1181
|
+
admit: false,
|
|
1182
|
+
reason: derivation.variant === 'no-coordinating-block'
|
|
1183
|
+
? `${MEMBERSHIP_NOT_ADMITTED}:no-coordinating-block`
|
|
1184
|
+
: `${MEMBERSHIP_NOT_ADMITTED}:unbound-coordinating-block (blockId=${derivation.blockId}, affected=${derivation.affected})`
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
// Receiver-side outcomes: a resolved view, no capability at all, or a bound block whose lookup
|
|
1188
|
+
// failed. All three keep today's behaviour, judged below.
|
|
1189
|
+
case 'view':
|
|
1190
|
+
case 'no-capability':
|
|
1191
|
+
case 'underivable':
|
|
1192
|
+
break;
|
|
1193
|
+
default: {
|
|
1194
|
+
// `never` is the compile-time half of the guard: adding a `kind` without deciding its side
|
|
1195
|
+
// of the sender/receiver split fails the build here. The runtime half returns a real
|
|
1196
|
+
// verdict — fail closed — rather than the derivation object, which is not one.
|
|
1197
|
+
const exhaustive: never = derivation;
|
|
1198
|
+
return { admit: false, reason: `${MEMBERSHIP_NOT_ADMITTED}:${(exhaustive as { kind: string }).kind}` };
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const derived = derivation.kind === 'view' ? derivation.view : undefined;
|
|
1082
1203
|
// An empty derived view (kEst === 0) carries no usable reference set: measured against it every
|
|
1083
1204
|
// non-empty declared set is wholly "inconsistent" (maxDiff = ceil(tol·0) = 0), which would spuriously
|
|
1084
1205
|
// reject a legitimate full cluster — a stricter, worse outcome than an absent view. Treat empty as
|
|
@@ -1180,23 +1301,43 @@ export class ClusterMember implements ICluster {
|
|
|
1180
1301
|
}
|
|
1181
1302
|
|
|
1182
1303
|
/**
|
|
1183
|
-
* Derive this member's own view of the record's block cluster via the injected capability,
|
|
1184
|
-
*
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1304
|
+
* Derive this member's own view of the record's block cluster via the injected capability, reporting
|
|
1305
|
+
* *why* when it cannot — see {@link ClusterViewDerivation}. Four outcomes, deliberately not collapsed
|
|
1306
|
+
* into one `undefined`: the member has no capability; the lookup failed; the record named no
|
|
1307
|
+
* coordinating block; the record named a block its own operations never touch. The last two are the
|
|
1308
|
+
* sender's choices and {@link admitMembership} refuses them; the first two are this member's own
|
|
1309
|
+
* limitation and stay lenient. Derived from the record's coordinating block, the same key the
|
|
1310
|
+
* coordinator used to select the cluster.
|
|
1188
1311
|
*
|
|
1189
1312
|
* Read off `record.message`, NOT a top-level record field: `messageHash` covers the message only, so
|
|
1190
1313
|
* only the in-message copy is tamper-evident to a relaying peer. (There is no top-level copy any more —
|
|
1191
1314
|
* see {@link ClusterRecord.message}.)
|
|
1192
1315
|
*/
|
|
1193
|
-
private async deriveExpectedClusterView(record: ClusterRecord): Promise<
|
|
1316
|
+
private async deriveExpectedClusterView(record: ClusterRecord): Promise<ClusterViewDerivation> {
|
|
1317
|
+
// Capability check FIRST, before the record's field is even read: a member with nothing to derive
|
|
1318
|
+
// against must never report a sender fault — it has no standing to judge the record's shape.
|
|
1194
1319
|
if (!this.deriveExpectedCluster) {
|
|
1195
|
-
return
|
|
1320
|
+
return { kind: 'no-capability' };
|
|
1196
1321
|
}
|
|
1322
|
+
// NOTE: only `coordinatingBlockIds[0]` is read. A pend may declare the whole consolidated batch
|
|
1323
|
+
// here; the gate needs one block to derive a cohort from, and the coordinator's choke point puts
|
|
1324
|
+
// the cohort key it actually selected against at index 0.
|
|
1197
1325
|
const blockId = record.message.coordinatingBlockIds?.[0];
|
|
1198
1326
|
if (blockId === undefined) {
|
|
1199
|
-
|
|
1327
|
+
// Covers both an absent field and a present-but-empty array — distinct wire shapes, same
|
|
1328
|
+
// defect: the record names nothing to derive from. Every production sender routes through
|
|
1329
|
+
// `ClusterCoordinator.executeClusterTransaction`, which stamps a bound id when the message has
|
|
1330
|
+
// none, so no honest record reaches here.
|
|
1331
|
+
//
|
|
1332
|
+
// No rolling-upgrade gate guards this refusal, and `membershipVersion` is NOT that gate:
|
|
1333
|
+
// `validateRecord` accepts v1 and unversioned records, and the choke point that stamps the
|
|
1334
|
+
// field onto commit/cancel records (`commit-and-cancel-records-omit-the-coordinating-block`)
|
|
1335
|
+
// landed without bumping the version — so a peer on a build older than that one sends
|
|
1336
|
+
// commit/cancel records this refuses. The decision is that cluster consensus deploys as one
|
|
1337
|
+
// unit and this project makes no cross-build compatibility promise; if that ever changes,
|
|
1338
|
+
// the gate belongs here (admit an unversioned record on the lenient path), not in the caller.
|
|
1339
|
+
log('cluster-member:coordinating-block-absent', { messageHash: record.messageHash });
|
|
1340
|
+
return { kind: 'unusable-record', variant: 'no-coordinating-block' };
|
|
1200
1341
|
}
|
|
1201
1342
|
// Hashing the field makes it tamper-evident to RELAYS, but the coordinator is the party this gate
|
|
1202
1343
|
// exists to check and it picks the field before it computes the hash. Unbound, a Byzantine
|
|
@@ -1206,14 +1347,21 @@ export class ClusterMember implements ICluster {
|
|
|
1206
1347
|
// record's OWN operations touch removes that free choice. `getAffectedBlockIds` is the same block
|
|
1207
1348
|
// extraction conflict detection already runs on this message — one definition, so the set a
|
|
1208
1349
|
// coordinating id must come from cannot drift from the set the record is judged to touch.
|
|
1209
|
-
|
|
1350
|
+
const affected = getAffectedBlockIds(record.message.operations);
|
|
1351
|
+
if (!affected.includes(blockId)) {
|
|
1210
1352
|
log('cluster-member:coordinating-block-unbound', {
|
|
1211
1353
|
messageHash: record.messageHash,
|
|
1212
1354
|
coordinatingBlockId: blockId
|
|
1213
1355
|
});
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
|
|
1356
|
+
// Refuse, do not fall back: the fallback floor is the posture for a fault of THIS member, and
|
|
1357
|
+
// letting a sender-chosen defect land there let the coordinator pick which check ran. Reported
|
|
1358
|
+
// as a reject vote rather than a throw so the member emits a signed `reject` and dispute
|
|
1359
|
+
// accounting keeps working — this stays out of `validateRecord`'s failure surface.
|
|
1360
|
+
//
|
|
1361
|
+
// `affected` is empty for a record with no operations, so any named block is unbound and the
|
|
1362
|
+
// record is refused — correct (a record with no operations is malformed), and why `affected=0`
|
|
1363
|
+
// can legitimately appear in the reason string.
|
|
1364
|
+
return { kind: 'unusable-record', variant: 'unbound-coordinating-block', blockId, affected: affected.length };
|
|
1217
1365
|
}
|
|
1218
1366
|
// Which block a member derived its cohort view from is the single most useful fact when an
|
|
1219
1367
|
// admission decision has to be explained after the fact — and the only externally visible sign
|
|
@@ -1223,10 +1371,13 @@ export class ClusterMember implements ICluster {
|
|
|
1223
1371
|
// NOTE: derives (findCluster) once per inbound record on the promise path — one routing lookup
|
|
1224
1372
|
// per vote. If this shows up as hot, cache the derived view per (blockId, short TTL): it is a
|
|
1225
1373
|
// pure read of current topology, so a few-seconds-stale view is safe for admission.
|
|
1226
|
-
return await this.deriveExpectedCluster(blockId as BlockId);
|
|
1374
|
+
return { kind: 'view', view: await this.deriveExpectedCluster(blockId as BlockId) };
|
|
1227
1375
|
} catch (err) {
|
|
1228
1376
|
log('cluster-member:derive-expected-cluster-error', { messageHash: record.messageHash, error: (err as Error).message });
|
|
1229
|
-
|
|
1377
|
+
// Receiver fault: a bound block whose lookup threw. Stays lenient (the `assumedClusterSize`
|
|
1378
|
+
// fallback) — that is the partition posture, and refusing here would make a transient routing
|
|
1379
|
+
// hiccup refuse every write.
|
|
1380
|
+
return { kind: 'underivable' };
|
|
1230
1381
|
}
|
|
1231
1382
|
}
|
|
1232
1383
|
|
|
@@ -2074,10 +2225,11 @@ export class ClusterMember implements ICluster {
|
|
|
2074
2225
|
* entries are swept, and a held transaction that LOSES the race to `record` is cleared.
|
|
2075
2226
|
*/
|
|
2076
2227
|
private findConflict(record: ClusterRecord): { blockedBy: string } | undefined {
|
|
2077
|
-
|
|
2078
|
-
|
|
2228
|
+
// Same clock as the `lastUpdate` stamp (the `shouldPersist` set and `persistParticipantState`),
|
|
2229
|
+
// so an injected test clock ages entries instead of putting stamp and comparison on different bases.
|
|
2230
|
+
const now = this.now();
|
|
2079
2231
|
|
|
2080
|
-
const incomingBlockIds =
|
|
2232
|
+
const incomingBlockIds = getAffectedBlockIds(record.message.operations);
|
|
2081
2233
|
log('cluster-member:findConflict-check', {
|
|
2082
2234
|
messageHash: record.messageHash,
|
|
2083
2235
|
activeCount: this.activeTransactions.size,
|
|
@@ -2085,11 +2237,18 @@ export class ClusterMember implements ICluster {
|
|
|
2085
2237
|
});
|
|
2086
2238
|
|
|
2087
2239
|
for (const [existingHash, state] of Array.from(this.activeTransactions.entries())) {
|
|
2240
|
+
// Defensive only — no caller can reach it today. `getTransactionPhase` calls this scan solely
|
|
2241
|
+
// when `!record.promises[ourId]`, and every write into `activeTransactions` already carries our
|
|
2242
|
+
// vote (the `shouldPersist` set happens after the phase loop recorded it; `recoverTransactions`
|
|
2243
|
+
// restores what that same branch persisted; `handleExpiration` re-sets with our reject added).
|
|
2244
|
+
// A redelivery at a known hash is merged with the held record first (`mergeRecords`, first-seen
|
|
2245
|
+
// wins), so our vote is present by the time the phase is computed. Kept because the scan is on
|
|
2246
|
+
// the vote path, where self-blocking would be silent and permanent.
|
|
2088
2247
|
if (existingHash === record.messageHash) {
|
|
2089
2248
|
continue;
|
|
2090
2249
|
}
|
|
2091
2250
|
|
|
2092
|
-
const existingBlockIds =
|
|
2251
|
+
const existingBlockIds = getAffectedBlockIds(state.record.message.operations);
|
|
2093
2252
|
log('cluster-member:findConflict-compare', {
|
|
2094
2253
|
existing: existingHash,
|
|
2095
2254
|
incoming: record.messageHash,
|
|
@@ -2097,8 +2256,9 @@ export class ClusterMember implements ICluster {
|
|
|
2097
2256
|
incomingBlockIds
|
|
2098
2257
|
});
|
|
2099
2258
|
|
|
2100
|
-
//
|
|
2101
|
-
|
|
2259
|
+
// Sweep abandoned reservations BEFORE the race is decided: an entry nobody is driving any
|
|
2260
|
+
// more must not win a contest it should not be in and block a live rival for its whole life.
|
|
2261
|
+
if (now - state.lastUpdate > CONFLICT_STALE_THRESHOLD_MS) {
|
|
2102
2262
|
log('cluster-member:stale-cleanup', {
|
|
2103
2263
|
messageHash: existingHash,
|
|
2104
2264
|
age: now - state.lastUpdate
|
|
@@ -2107,9 +2267,9 @@ export class ClusterMember implements ICluster {
|
|
|
2107
2267
|
continue;
|
|
2108
2268
|
}
|
|
2109
2269
|
|
|
2110
|
-
if (
|
|
2270
|
+
if (operationsConflict(state.record.message.operations, record.message.operations)) {
|
|
2111
2271
|
// Use race resolution to determine winner
|
|
2112
|
-
const resolution =
|
|
2272
|
+
const resolution = resolveRace(state.record, record);
|
|
2113
2273
|
|
|
2114
2274
|
if (resolution === 'keep-existing') {
|
|
2115
2275
|
log('cluster-member:race-keep-existing', {
|
|
@@ -2124,7 +2284,10 @@ export class ClusterMember implements ICluster {
|
|
|
2124
2284
|
incoming: record.messageHash
|
|
2125
2285
|
});
|
|
2126
2286
|
this.clearTransaction(existingHash);
|
|
2127
|
-
continue
|
|
2287
|
+
// `continue`, not `break`: the incoming transaction may overlap several held
|
|
2288
|
+
// reservations, and beating one says nothing about the rest. Stopping here would let it
|
|
2289
|
+
// walk past a second, still-live rival it actually loses to.
|
|
2290
|
+
continue;
|
|
2128
2291
|
}
|
|
2129
2292
|
}
|
|
2130
2293
|
}
|
|
@@ -2132,182 +2295,6 @@ export class ClusterMember implements ICluster {
|
|
|
2132
2295
|
return undefined; // No blocking conflicts
|
|
2133
2296
|
}
|
|
2134
2297
|
|
|
2135
|
-
/** Number of *approve* promise votes on a record — the count the commit rule uses. */
|
|
2136
|
-
private static approvalCount(record: ClusterRecord): number {
|
|
2137
|
-
return Object.values(record.promises).filter(s => s.type === 'approve').length;
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
/**
|
|
2141
|
-
* Resolve a race between two conflicting transactions. Total and deterministic, so every honest
|
|
2142
|
-
* member computes the identical winner (the Theorem 1 Case-2 premise). Order:
|
|
2143
|
-
* 1. more *approve* promise signatures wins (progress monotonicity — see safety note below);
|
|
2144
|
-
* 2. equal approval counts → higher aged priority wins (fairness — see {@link recordPriority});
|
|
2145
|
-
* 3. still tied → higher message hash wins.
|
|
2146
|
-
*
|
|
2147
|
-
* The count is APPROVALS, not `promises` keys. `promises` is the vote map — a reject occupies a key
|
|
2148
|
-
* there exactly as an approve does — so counting keys would treat a rejection as progress, letting a
|
|
2149
|
-
* record that can never commit outrank (and therefore block, via {@link findConflict}) a fresh rival
|
|
2150
|
-
* for the whole staleness window. Approvals is also the count the invariant below actually needs:
|
|
2151
|
-
* the commit rule is `approvedPromises >= superMajority`, which never looks at rejections.
|
|
2152
|
-
*
|
|
2153
|
-
* Approval count is FIRST so this comparison never displaces a transaction that is further along.
|
|
2154
|
-
* That restores the pre-priority safety invariant: a member commits purely on promise supermajority
|
|
2155
|
-
* (`handleCommitNeeded` signs whenever `approvedPromises >= superMajority`; the commit path has NO
|
|
2156
|
-
* conflict re-check), so `resolveRace` is the ONLY arbiter among concurrently-pending conflicts.
|
|
2157
|
-
* With approvals-first, once transaction X holds a promise supermajority every conflicting rival Y has
|
|
2158
|
-
* strictly fewer approvals — Y can only match X's count by getting the intersecting quorum member to
|
|
2159
|
-
* approve it, but that member already holds X at supermajority and `resolveRace(X, Y)` returns
|
|
2160
|
-
* `keep-existing` on X's higher count, so it never does. By quorum intersection any Y-supermajority
|
|
2161
|
-
* overlaps X's in ≥1 honest member, and that member rejects Y. One winner (docs/correctness.md
|
|
2162
|
-
* Theorem 9). Priority-first would break this: it could displace an already-quorum-reached X for a
|
|
2163
|
-
* higher-priority Y with fewer approvals, letting BOTH commit (split brain) — the regression fixed by
|
|
2164
|
-
* ticket occ-priority-first-breaks-promise-monotonicity.
|
|
2165
|
-
*
|
|
2166
|
-
* Priority is now a tie-break that runs only at EQUAL approval counts, which is exactly the
|
|
2167
|
-
* concurrent-starvation case aging targets (two fresh rivals, 0 promises each, otherwise coin-flipping
|
|
2168
|
-
* on the hash). Priority still breaks those ties deterministically, so aging still solves the stated
|
|
2169
|
-
* fairness problem in its common case. It only orders two *concurrently-pending* conflicts; it does NOT
|
|
2170
|
-
* defer a fresh pend for an absent aged transaction (that residual — sequential sub-window starvation —
|
|
2171
|
-
* is the deferred feat-occ-priority-reservation).
|
|
2172
|
-
*
|
|
2173
|
-
* NOTE: residual-fairness tripwire. Under approvals-first an aged transaction can still lose to a fresh
|
|
2174
|
-
* rival that has *legitimately* gathered even one more approval — that is not the pure-coin-flip
|
|
2175
|
-
* starvation aging targets (equal counts, priority wins), it is the monotonicity behaviour we WANT (a
|
|
2176
|
-
* more-progressed rival is never displaced). If deeper fairness against a genuinely-more-progressed
|
|
2177
|
-
* rival is ever needed, it belongs to feat-occ-priority-reservation (reserve/defer at pend time), NOT
|
|
2178
|
-
* to this race tie-break.
|
|
2179
|
-
*
|
|
2180
|
-
* NOTE: Byzantine self-assert is a fairness DoS, not a safety hole. A coordinator can stamp
|
|
2181
|
-
* priority == MaxPriority on every transaction; recordPriority clamps to the cap so it cannot
|
|
2182
|
-
* exceed it, and priority never influences validity/operationsHash/stale-read checks — and now sits
|
|
2183
|
-
* below the approval count, so it can only break equal-count ties it might have ~50% won anyway,
|
|
2184
|
-
* degrading to at-worst-status-quo fairness (the same graceful-degradation class as spam under
|
|
2185
|
-
* honest-majority). Binding priority to provable age is out of scope (feat-occ-priority-reservation).
|
|
2186
|
-
*
|
|
2187
|
-
* NOTE: keep priority a self-contained additive message field + this one comparison key so it
|
|
2188
|
-
* composes with — does not block — a future HLC/crdt-sync redesign of this same path
|
|
2189
|
-
* (design-hot-log-tail-sharding-guidance).
|
|
2190
|
-
*/
|
|
2191
|
-
private resolveRace(existing: ClusterRecord, incoming: ClusterRecord): 'keep-existing' | 'accept-incoming' {
|
|
2192
|
-
// 1. Transaction with more APPROVALS wins — never displace a more-progressed rival (safety, see
|
|
2193
|
-
// above). Counting `promises` keys instead would count reject votes as progress: a record holding
|
|
2194
|
-
// one rejection would outrank an untouched rival and reserve its blocks for the whole staleness
|
|
2195
|
-
// window, and the commit rule this ordering protects (`approvedPromises >= superMajority`) never
|
|
2196
|
-
// looks at rejections anyway.
|
|
2197
|
-
const existingCount = ClusterMember.approvalCount(existing);
|
|
2198
|
-
const incomingCount = ClusterMember.approvalCount(incoming);
|
|
2199
|
-
if (existingCount !== incomingCount) {
|
|
2200
|
-
return existingCount > incomingCount ? 'keep-existing' : 'accept-incoming';
|
|
2201
|
-
}
|
|
2202
|
-
|
|
2203
|
-
// 2. Equal approval counts → higher aged priority wins (fairness tie-break).
|
|
2204
|
-
const existingPriority = this.recordPriority(existing);
|
|
2205
|
-
const incomingPriority = this.recordPriority(incoming);
|
|
2206
|
-
if (existingPriority !== incomingPriority) {
|
|
2207
|
-
return existingPriority > incomingPriority ? 'keep-existing' : 'accept-incoming';
|
|
2208
|
-
}
|
|
2209
|
-
|
|
2210
|
-
// 3. Tie-breaker: higher message hash wins (deterministic).
|
|
2211
|
-
return existing.messageHash > incoming.messageHash ? 'keep-existing' : 'accept-incoming';
|
|
2212
|
-
}
|
|
2213
|
-
|
|
2214
|
-
/**
|
|
2215
|
-
* Aged advisory priority carried by a record's pend operation, clamped to [0, MaxPriority].
|
|
2216
|
-
* The multi-collection path carries it on `pend.validation.transaction.priority`; the single-collection
|
|
2217
|
-
* (`Collection.sync`) path carries it as top-level `pend.priority`; a record with neither — a
|
|
2218
|
-
* legacy/unversioned coordinator's transaction, or a non-pend operation — is priority 0
|
|
2219
|
-
* (backward compatible: such transactions simply never age). Both carriers live inside the signed
|
|
2220
|
-
* `message`, so priority is integrity-protected in transit; clamping here bounds a self-asserted
|
|
2221
|
-
* out-of-range value to the cap.
|
|
2222
|
-
*
|
|
2223
|
-
* NOTE: `message` is fixed for a transaction's whole lifecycle (promises/commits accrue in the
|
|
2224
|
-
* separate `promises`/`commits` maps, never in `message`), so a transaction keeps its rank through
|
|
2225
|
-
* the commit phase — there is no "priority drops to 0 at commit" asymmetry. resolveRace is only
|
|
2226
|
-
* consulted at the promise decision (findConflict), i.e. between two still-open conflicting
|
|
2227
|
-
* transactions, which is exactly the concurrent-contention case priority is meant to order.
|
|
2228
|
-
*/
|
|
2229
|
-
private recordPriority(record: ClusterRecord): number {
|
|
2230
|
-
for (const op of record.message.operations) {
|
|
2231
|
-
if ('pend' in op) {
|
|
2232
|
-
// Every hop optional: `validation` arrives off the wire inside a signed message whose
|
|
2233
|
-
// hash binds its bytes, not its shape, so a malformed pair must yield priority 0 (what
|
|
2234
|
-
// clampPriority already does for a missing or Byzantine number) rather than throw out
|
|
2235
|
-
// of the vote path — the lost vote this fail-closed pass exists to prevent.
|
|
2236
|
-
return clampPriority(op.pend.validation?.transaction?.priority ?? op.pend.priority);
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
return 0;
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
private operationsConflict(ops1: RepoMessage['operations'], ops2: RepoMessage['operations']): boolean {
|
|
2243
|
-
// Check if one is a commit for the same action as a pend - these don't conflict
|
|
2244
|
-
const actionId1 = this.getActionId(ops1);
|
|
2245
|
-
const actionId2 = this.getActionId(ops2);
|
|
2246
|
-
if (actionId1 && actionId2 && actionId1 === actionId2) {
|
|
2247
|
-
// Same action - commit is resolving the pend, not conflicting
|
|
2248
|
-
return false;
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
const blocks1 = new Set(this.getAffectedBlockIds(ops1));
|
|
2252
|
-
const blocks2 = new Set(this.getAffectedBlockIds(ops2));
|
|
2253
|
-
|
|
2254
|
-
for (const block of Array.from(blocks1)) {
|
|
2255
|
-
if (blocks2.has(block)) {
|
|
2256
|
-
log('cluster-member:conflict-detected', {
|
|
2257
|
-
blocks1: Array.from(blocks1),
|
|
2258
|
-
blocks2: Array.from(blocks2),
|
|
2259
|
-
conflictingBlock: block
|
|
2260
|
-
});
|
|
2261
|
-
return true;
|
|
2262
|
-
}
|
|
2263
|
-
}
|
|
2264
|
-
|
|
2265
|
-
return false;
|
|
2266
|
-
}
|
|
2267
|
-
|
|
2268
|
-
private getActionId(operations: RepoMessage['operations']): string | undefined {
|
|
2269
|
-
for (const operation of operations) {
|
|
2270
|
-
if ('pend' in operation) {
|
|
2271
|
-
return operation.pend.actionId;
|
|
2272
|
-
} else if ('commit' in operation) {
|
|
2273
|
-
return operation.commit.actionId;
|
|
2274
|
-
} else if ('cancel' in operation) {
|
|
2275
|
-
return operation.cancel.actionRef.actionId;
|
|
2276
|
-
}
|
|
2277
|
-
}
|
|
2278
|
-
return undefined;
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
/**
|
|
2282
|
-
* Every block id the message's own operations name. Two consumers, deliberately sharing one
|
|
2283
|
-
* definition: conflict detection (which writes must serialize against each other) and the membership
|
|
2284
|
-
* admission gate's binding check (the set a legitimate `coordinatingBlockIds[0]` must come from —
|
|
2285
|
-
* {@link ClusterMember.deriveExpectedClusterView}). If the two ever disagreed, a coordinator could
|
|
2286
|
-
* name a block the record is not judged to touch.
|
|
2287
|
-
*/
|
|
2288
|
-
private getAffectedBlockIds(operations: RepoMessage['operations']): string[] {
|
|
2289
|
-
const blockIds = new Set<string>();
|
|
2290
|
-
|
|
2291
|
-
for (const operation of operations) {
|
|
2292
|
-
if ('get' in operation) {
|
|
2293
|
-
operation.get.blockIds.forEach(id => blockIds.add(id));
|
|
2294
|
-
} else if ('pend' in operation) {
|
|
2295
|
-
// Use blockIdsForTransforms to correctly extract block IDs from Transforms structure
|
|
2296
|
-
blockIdsForTransforms(operation.pend.transforms).forEach(id => blockIds.add(id));
|
|
2297
|
-
} else if ('commit' in operation) {
|
|
2298
|
-
operation.commit.blockIds.forEach(id => blockIds.add(id));
|
|
2299
|
-
} else if ('cancel' in operation) {
|
|
2300
|
-
operation.cancel.actionRef.blockIds.forEach(id => blockIds.add(id));
|
|
2301
|
-
} else if ('invalidate' in operation) {
|
|
2302
|
-
// The invalidation writes compensating revisions to these blocks; surfacing them lets
|
|
2303
|
-
// conflict detection serialize a concurrent commit racing the invalidation on a block.
|
|
2304
|
-
operation.invalidate.blockIds.forEach(id => blockIds.add(id));
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
|
|
2308
|
-
return Array.from(blockIds);
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
2298
|
private async propagateIfNeeded(record: ClusterRecord): Promise<void> {
|
|
2312
2299
|
const promises = [];
|
|
2313
2300
|
for (const peerId of Object.keys(record.peers)) {
|
|
@@ -2439,7 +2426,7 @@ export class ClusterMember implements ICluster {
|
|
|
2439
2426
|
this.stateStore.saveParticipantState(messageHash, {
|
|
2440
2427
|
messageHash,
|
|
2441
2428
|
record,
|
|
2442
|
-
lastUpdate:
|
|
2429
|
+
lastUpdate: this.now()
|
|
2443
2430
|
}).catch(err => log('cluster-member:persist-error', { messageHash, error: (err as Error).message }));
|
|
2444
2431
|
}
|
|
2445
2432
|
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { ActionId, BlockId, ClusterRecord, IBlock, RepoMessage, Signature, CommitRequest } from "@optimystic/db-core";
|
|
2
2
|
import {
|
|
3
|
-
canonicalBlockHash,
|
|
4
|
-
computeClusterMessageHash, computeClusterPromiseHash,
|
|
3
|
+
canonicalBlockHash, clusterVoteSigningPayload, clusterVoteVerificationPayload,
|
|
4
|
+
computeClusterCommitHash, computeClusterMessageHash, computeClusterPromiseHash,
|
|
5
|
+
membershipDigestFromIds
|
|
5
6
|
} from "@optimystic/db-core";
|
|
7
|
+
import type { PrivateKey } from "@libp2p/interface";
|
|
6
8
|
import { peerIdFromString } from "@libp2p/peer-id";
|
|
7
9
|
import { publicKeyFromRaw } from "@libp2p/crypto/keys";
|
|
8
10
|
import { fromString as uint8ArrayFromString } from "uint8arrays/from-string";
|
|
11
|
+
import { toString as uint8ArrayToString } from "uint8arrays/to-string";
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* A durable, self-contained proof that a cluster cohort agreed on a commit — everything an offline
|
|
@@ -91,6 +94,39 @@ export function buildBlockCommitProof(record: ClusterRecord): BlockCommitProof |
|
|
|
91
94
|
};
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Mint a fully-signed one-peer {@link BlockCommitProof} — the producing sibling of
|
|
99
|
+
* {@link verifyBlockCommitProofClaim}, kept in this file so the hash recipe the two must agree on
|
|
100
|
+
* lives in one place. Used by the solo-cohort commit path (`CoordinatorRepo.commit`'s
|
|
101
|
+
* `peerCount <= 1` short-circuit), where consensus never runs and there is no {@link ClusterRecord}
|
|
102
|
+
* to project: the lone member IS the whole cohort, so it signs both rounds itself over a one-peer
|
|
103
|
+
* membership. The artifact stays honest — "one peer, which was the whole cohort at the time,
|
|
104
|
+
* committed these bytes at this revision" — and verifies offline from the peer id alone, exactly
|
|
105
|
+
* like a consensus-produced proof: `ceil(0.75 × 1) = 1` approve satisfies the promise round and
|
|
106
|
+
* `1 > 1 × 0.5` the commit round under the production thresholds.
|
|
107
|
+
*
|
|
108
|
+
* The promise round is signed FIRST: the commit hash's preimage includes the promises map
|
|
109
|
+
* (`computeClusterCommitHash`), so the order is load-bearing, not stylistic.
|
|
110
|
+
*/
|
|
111
|
+
export async function mintSoloCommitProof(
|
|
112
|
+
peerId: string, privateKey: PrivateKey, message: RepoMessage
|
|
113
|
+
): Promise<BlockCommitProof> {
|
|
114
|
+
const membershipDigest = await membershipDigestFromIds([peerId]);
|
|
115
|
+
const messageHash = await computeClusterMessageHash(message, membershipDigest);
|
|
116
|
+
const signApprove = async (hash: string): Promise<Signature> => ({
|
|
117
|
+
type: 'approve',
|
|
118
|
+
signature: uint8ArrayToString(await privateKey.sign(clusterVoteSigningPayload(hash, 'approve')), 'base64url')
|
|
119
|
+
});
|
|
120
|
+
const promiseHash = await computeClusterPromiseHash(messageHash, message, membershipDigest);
|
|
121
|
+
const promises: Record<string, Signature> = { [peerId]: await signApprove(promiseHash) };
|
|
122
|
+
const commitHash = await computeClusterCommitHash(messageHash, message, promises, membershipDigest);
|
|
123
|
+
const commits: Record<string, Signature> = { [peerId]: await signApprove(commitHash) };
|
|
124
|
+
return {
|
|
125
|
+
v: 1, messageHash, message, promises, commits,
|
|
126
|
+
membershipVersion: 2, membershipDigest, peerIds: [peerId]
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
/**
|
|
95
131
|
* Verify that `proof` certifies the CLAIM — `claim.blockId` at `claim.rev` under `claim.actionId` —
|
|
96
132
|
* without needing the block bytes. Pure and total on hostile input: never throws; every failure is a
|