@optimystic/db-p2p 1.1.0 → 1.2.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/cluster-repo.d.ts +35 -12
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +47 -15
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +92 -36
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +266 -198
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +5 -4
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +24 -6
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/package.json +2 -2
- package/src/cluster/cluster-repo.ts +48 -15
- package/src/repo/cluster-coordinator.ts +1409 -1328
- package/src/repo/coordinator-repo.ts +5 -4
- package/src/storage/storage-repo.ts +1781 -1762
|
@@ -34,6 +34,29 @@ function mergeApplyOutcomes(record, collected) {
|
|
|
34
34
|
return;
|
|
35
35
|
record.applyOutcomes = { ...record.applyOutcomes, ...collected };
|
|
36
36
|
}
|
|
37
|
+
/** Fold the commit signatures of every answered response into a record in place. */
|
|
38
|
+
function mergeCommits(record, responses) {
|
|
39
|
+
for (const { response } of responses) {
|
|
40
|
+
if (response)
|
|
41
|
+
record.commits = { ...record.commits, ...response.commits };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The members of `deliveries` that still need the consensus record: one whose delivery failed, one
|
|
46
|
+
* whose response does not report having run the consensus apply (`MemberApplyOutcome.executed`,
|
|
47
|
+
* which a member on an older build never sets), and one that reports a refused commit —
|
|
48
|
+
* sending it the record again gives a behind member another reconcile once the coordinating member
|
|
49
|
+
* holds the revision (`ClusterMember.handleAlreadyExecuted`). Each member is judged by its own entry
|
|
50
|
+
* in its own response, as {@link collectApplyOutcomes} takes it.
|
|
51
|
+
*/
|
|
52
|
+
function membersAwaitingConsensus(deliveries) {
|
|
53
|
+
return deliveries
|
|
54
|
+
.filter(({ peerId, response }) => {
|
|
55
|
+
const own = response?.applyOutcomes?.[peerId];
|
|
56
|
+
return own?.executed !== true || own.commit?.success === false;
|
|
57
|
+
})
|
|
58
|
+
.map(({ peerId }) => peerId);
|
|
59
|
+
}
|
|
37
60
|
/**
|
|
38
61
|
* Consensus refused a transaction: enough members voted reject that super-majority became
|
|
39
62
|
* impossible. A typed error (rather than a bare `Error`) so the repo layer above can distinguish
|
|
@@ -256,8 +279,9 @@ export class ClusterCoordinator {
|
|
|
256
279
|
}
|
|
257
280
|
/**
|
|
258
281
|
* A node never runs a cluster transaction for a cohort it is not in. Behind members reconcile from the
|
|
259
|
-
* coordinator's own proof-carrying copy (its member applies before the
|
|
260
|
-
*
|
|
282
|
+
* coordinator's own proof-carrying copy (its member applies before the consensus broadcast, and a
|
|
283
|
+
* member that applied earlier, on receipt of the commit round, is sent the record again once it has),
|
|
284
|
+
* and a coordinator outside `record.peers` is not a reconcile target — so a cohort with no holder would stay
|
|
261
285
|
* behind and the commit durability gate would refuse, having first put this node's vote and storage
|
|
262
286
|
* where the cohort does not look. The invariant is held here, at the one place a record's `peers` is
|
|
263
287
|
* chosen, rather than left to the routing convention.
|
|
@@ -753,12 +777,105 @@ export class ClusterCoordinator {
|
|
|
753
777
|
return { record };
|
|
754
778
|
}
|
|
755
779
|
/**
|
|
756
|
-
*
|
|
780
|
+
* The commit round, then the consensus delivery. Runs once the promise round reached super-majority.
|
|
781
|
+
*
|
|
782
|
+
* **This node's own member votes to commit first, in process, and its signature rides on the commit
|
|
783
|
+
* round** ({@link presignLocalCommit}). A remote member receiving that record adds its own commit,
|
|
784
|
+
* and in a cohort of two (2 of 2) or three (2 of 3) that is already the strict majority its phase
|
|
785
|
+
* loop needs for consensus, so it applies in the same delivery and answers with its apply report
|
|
786
|
+
* stamped on. What a member accepts does not change: it reaches consensus only on commit signatures
|
|
787
|
+
* it verified, and it signed its own commit only after seeing a super-majority of approved promises.
|
|
788
|
+
* It is the same kind of record the consensus broadcast carries, arriving one round earlier. In a
|
|
789
|
+
* cohort of four or more the coordinator's commit plus one member's is short of a majority, so
|
|
790
|
+
* nobody applies on receipt and the broadcast below works as it always did.
|
|
791
|
+
*
|
|
792
|
+
* Once the merged commits reach the majority, {@link broadcastMergedRecord} delivers the record to
|
|
793
|
+
* this node's member and then only to the remote members still needing it
|
|
794
|
+
* ({@link membersAwaitingConsensus}). With every remote member healthy in a small cohort that list is
|
|
795
|
+
* empty, so a consensus operation costs each remote member two calls (promise, commit) instead of
|
|
796
|
+
* three. When the pre-sign is unavailable the round runs as it did before — every member in
|
|
797
|
+
* parallel, this node's included — and the broadcast then reaches every member.
|
|
757
798
|
*/
|
|
758
799
|
async commitTransaction(record) {
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
const
|
|
800
|
+
const selfId = this.localCluster?.peerId.toString();
|
|
801
|
+
const presigned = await this.presignLocalCommit(record);
|
|
802
|
+
const roundPeers = Object.keys(record.peers).filter(id => !presigned || id !== selfId);
|
|
803
|
+
const deliveries = await this.collectCommits(record, roundPeers);
|
|
804
|
+
// A member can reach consensus during THIS round (see above), so its apply report arrives on
|
|
805
|
+
// these responses. The broadcast's copy wins on overlap, being the later of the two.
|
|
806
|
+
mergeApplyOutcomes(record, collectApplyOutcomes(deliveries));
|
|
807
|
+
mergeCommits(record, deliveries);
|
|
808
|
+
log('cluster-tx:commit-merge', {
|
|
809
|
+
messageHash: record.messageHash,
|
|
810
|
+
presigned,
|
|
811
|
+
mergedCommits: Object.keys(record.commits)
|
|
812
|
+
});
|
|
813
|
+
this.updateTransactionRecord(record, 'after-commit');
|
|
814
|
+
if (!this.hasCommitMajority(record)) {
|
|
815
|
+
this.scheduleOrClearRetry(record, deliveries.filter(d => !d.success).map(d => d.peerId));
|
|
816
|
+
return record;
|
|
817
|
+
}
|
|
818
|
+
log('cluster-tx:commit-majority-reached', {
|
|
819
|
+
messageHash: record.messageHash,
|
|
820
|
+
commitCount: Object.keys(record.commits).length,
|
|
821
|
+
peerCount: Object.keys(record.peers).length,
|
|
822
|
+
threshold: this.cfg.simpleMajorityThreshold
|
|
823
|
+
});
|
|
824
|
+
// This node's member is not in the list: the broadcast decides its delivery itself.
|
|
825
|
+
const awaiting = membersAwaitingConsensus(deliveries.filter(d => d.peerId !== selfId));
|
|
826
|
+
const { failures, applyOutcomes } = await this.broadcastMergedRecord(record, awaiting);
|
|
827
|
+
mergeApplyOutcomes(record, applyOutcomes);
|
|
828
|
+
// The scheduled retry works from the stored copy, and reads its apply outcomes to decide on the
|
|
829
|
+
// coordinating member's second reconcile, so it needs the broadcast's too.
|
|
830
|
+
this.updateTransactionRecord(record, 'after-broadcast');
|
|
831
|
+
this.scheduleOrClearRetry(record, failures);
|
|
832
|
+
return record;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Have this node's own member vote to commit on the promise-complete record, in process, before the
|
|
836
|
+
* commit round goes out, and merge its signature into `record`. True when the member answered: the
|
|
837
|
+
* round then leaves it out, and the consensus broadcast delivers it the merged record (should it
|
|
838
|
+
* have answered without a commit — its phase was not `OurCommitNeeded` — it is no worse off than
|
|
839
|
+
* in the round, where it would have answered the same). False when there is no local member in the
|
|
840
|
+
* cohort (some test wiring) or the member threw (an expired message, or `validateRecord` refused):
|
|
841
|
+
* the round then runs with it included, as it always did.
|
|
842
|
+
*
|
|
843
|
+
* The member cannot reach consensus here: the record carries no commit yet, and its own is a
|
|
844
|
+
* majority only in a cohort of one, which `CoordinatorRepo`'s solo path keeps away from this class.
|
|
845
|
+
* Were one to arrive anyway, the member would apply here and the broadcast would skip it as
|
|
846
|
+
* already executed.
|
|
847
|
+
*/
|
|
848
|
+
async presignLocalCommit(record) {
|
|
849
|
+
const selfId = this.localCluster?.peerId.toString();
|
|
850
|
+
if (selfId === undefined || !(selfId in record.peers)) {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
const response = await this.localCluster.update({ ...record });
|
|
855
|
+
// Its promise too, not only its commit. A member whose promise round delivery failed (possible
|
|
856
|
+
// only in a cohort of four or more, where super-majority can be reached without it) adds its
|
|
857
|
+
// promise here and signs its commit over a commit hash covering it; a round that carried the
|
|
858
|
+
// commit without the promise would fail every remote member's signature check.
|
|
859
|
+
record.promises = { ...record.promises, ...response.promises };
|
|
860
|
+
mergeCommits(record, [{ response }]);
|
|
861
|
+
log('cluster-tx:commit-presign', { messageHash: record.messageHash, signed: response.commits[selfId] !== undefined });
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
catch (err) {
|
|
865
|
+
log('cluster-tx:commit-presign-error', {
|
|
866
|
+
messageHash: record.messageHash,
|
|
867
|
+
error: err instanceof Error ? err.message : String(err)
|
|
868
|
+
});
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Send `record` to each of `peerIds` in parallel for its commit vote. No per-peer immediate retry:
|
|
874
|
+
* a failure here is recovered by the consensus broadcast's in-line retry and the scheduled
|
|
875
|
+
* commit-retry timer. (The promise round has no such backstop, which is why `collectPromises` gets
|
|
876
|
+
* the immediate retry instead.)
|
|
877
|
+
*/
|
|
878
|
+
async collectCommits(record, peerIds) {
|
|
762
879
|
if (verbose) {
|
|
763
880
|
const peerDetail = peerIds.map(id => ({
|
|
764
881
|
id: id.substring(0, 12),
|
|
@@ -766,190 +883,132 @@ export class ClusterCoordinator {
|
|
|
766
883
|
}));
|
|
767
884
|
log('cluster-tx:commit-peers', { messageHash: record.messageHash, peers: peerDetail });
|
|
768
885
|
}
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
log('cluster-tx:commit-request', { messageHash: record.messageHash, peerId: peerIdStr, isLocal });
|
|
781
|
-
const promise = isLocal
|
|
782
|
-
? this.localCluster.update(commitPayload)
|
|
783
|
-
: this.createClusterClient(peerIdFromString(peerIdStr)).update(commitPayload);
|
|
784
|
-
return new Pending(promise);
|
|
785
|
-
});
|
|
786
|
-
// Wait for all commits to complete
|
|
787
|
-
const results = await Promise.all(commitRequests.map((p, idx) => p.result().then(res => {
|
|
788
|
-
const peerIdStr = peerIds[idx];
|
|
789
|
-
log('cluster-tx:commit-response', { messageHash: record.messageHash, peerId: peerIdStr, success: true });
|
|
790
|
-
summary.push({ peerId: peerIdStr, success: true });
|
|
791
|
-
return res;
|
|
792
|
-
}).catch(err => {
|
|
793
|
-
const peerIdStr = peerIds[idx];
|
|
794
|
-
log('cluster-tx:commit-response', { messageHash: record.messageHash, peerId: peerIdStr, success: false, error: err });
|
|
795
|
-
summary.push({ peerId: peerIdStr, success: false, error: err instanceof Error ? err.message : String(err) });
|
|
796
|
-
this.reputation?.reportPeer(peerIdStr, PenaltyReason.ConsensusTimeout, `commit:${record.messageHash}`);
|
|
797
|
-
return null;
|
|
798
|
-
})));
|
|
799
|
-
const commitSuccesses = summary.filter(entry => entry.success).map(entry => entry.peerId);
|
|
800
|
-
const commitFailures = summary.filter(entry => !entry.success);
|
|
801
|
-
log('cluster-tx:commit-summary', {
|
|
802
|
-
messageHash: record.messageHash,
|
|
803
|
-
successes: commitSuccesses,
|
|
804
|
-
failures: commitFailures
|
|
805
|
-
});
|
|
806
|
-
log('cluster-tx:commit-merge-begin', {
|
|
807
|
-
messageHash: record.messageHash,
|
|
808
|
-
initialCommits: Object.keys(record.commits ?? {}),
|
|
809
|
-
transactionsEntry: this.transactions.get(record.messageHash)
|
|
810
|
-
});
|
|
811
|
-
// A member can reach consensus during THIS round rather than during the broadcast below (a
|
|
812
|
-
// record that already carries commits — a retried delivery), so its apply verdicts arrive on
|
|
813
|
-
// these responses. Collect both; the broadcast's copy wins on overlap, being the later of the two.
|
|
814
|
-
mergeApplyOutcomes(record, collectApplyOutcomes(results.map((response, idx) => ({ peerId: peerIds[idx], response }))));
|
|
815
|
-
// Merge all commits into the record
|
|
816
|
-
for (const result of results.filter(Boolean)) {
|
|
817
|
-
log('cluster-tx:commit-merge-input', {
|
|
818
|
-
messageHash: record.messageHash,
|
|
819
|
-
resultFrom: Object.keys(result.commits ?? {}),
|
|
820
|
-
recordBefore: Object.keys(record.commits ?? {})
|
|
821
|
-
});
|
|
822
|
-
log('cluster-tx:commit-merge-result', {
|
|
823
|
-
messageHash: record.messageHash,
|
|
824
|
-
peerCommits: Object.keys(result.commits ?? {})
|
|
825
|
-
});
|
|
826
|
-
record.commits = { ...record.commits, ...result.commits };
|
|
827
|
-
log('cluster-tx:commit-merge-after', {
|
|
828
|
-
messageHash: record.messageHash,
|
|
829
|
-
mergedCommits: Object.keys(record.commits ?? {})
|
|
830
|
-
});
|
|
886
|
+
// A snapshot: the members answer from the record as sent, and `record` is merged into only after
|
|
887
|
+
// every answer is in.
|
|
888
|
+
const payload = { ...record };
|
|
889
|
+
const selfId = this.localCluster?.peerId.toString();
|
|
890
|
+
const deliveries = await Promise.all(peerIds.map(peerId => {
|
|
891
|
+
log('cluster-tx:commit-request', { messageHash: record.messageHash, peerId, isLocal: peerId === selfId });
|
|
892
|
+
return this.deliver(payload, peerId, 0, 'commit');
|
|
893
|
+
}));
|
|
894
|
+
for (const { peerId, success } of deliveries) {
|
|
895
|
+
if (!success)
|
|
896
|
+
this.reputation?.reportPeer(peerId, PenaltyReason.ConsensusTimeout, `commit:${record.messageHash}`);
|
|
831
897
|
}
|
|
832
|
-
log('cluster-tx:commit-
|
|
833
|
-
messageHash: record.messageHash,
|
|
834
|
-
mergedCommits: Object.keys(record.commits ?? {})
|
|
835
|
-
});
|
|
836
|
-
log('cluster-tx:commit-merge-end', {
|
|
898
|
+
log('cluster-tx:commit-summary', {
|
|
837
899
|
messageHash: record.messageHash,
|
|
838
|
-
|
|
839
|
-
|
|
900
|
+
successes: deliveries.filter(d => d.success).map(d => d.peerId),
|
|
901
|
+
failures: deliveries.filter(d => !d.success).map(({ peerId, error }) => ({ peerId, error }))
|
|
840
902
|
});
|
|
841
|
-
|
|
842
|
-
|
|
903
|
+
return deliveries;
|
|
904
|
+
}
|
|
905
|
+
/** Whether `record`'s commit signatures reach the simple majority (>50%) that proves the commit. */
|
|
906
|
+
hasCommitMajority(record) {
|
|
843
907
|
const peerCount = Object.keys(record.peers).length;
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
simpleMajority,
|
|
851
|
-
peerCount,
|
|
852
|
-
threshold: this.cfg.simpleMajorityThreshold
|
|
853
|
-
});
|
|
854
|
-
// Broadcast the merged record (with all commit signatures) to ALL peers
|
|
855
|
-
// so each peer can independently reach consensus and execute the operations.
|
|
856
|
-
// Without this, only the coordinator's local cluster executes — remote peers
|
|
857
|
-
// never see enough commits to reach consensus on their own.
|
|
858
|
-
const { failures: broadcastFailures, applyOutcomes } = await this.broadcastMergedRecord(record, peerIds);
|
|
859
|
-
mergeApplyOutcomes(record, applyOutcomes);
|
|
860
|
-
if (broadcastFailures.length > 0) {
|
|
861
|
-
this.scheduleCommitRetry(record.messageHash, record, broadcastFailures);
|
|
862
|
-
}
|
|
863
|
-
else {
|
|
864
|
-
this.clearRetry(record.messageHash);
|
|
865
|
-
}
|
|
908
|
+
return Object.keys(record.commits).length >= Math.floor(peerCount * this.cfg.simpleMajorityThreshold) + 1;
|
|
909
|
+
}
|
|
910
|
+
/** Schedule a commit retry for `missingPeers`, or clear any pending one when nobody is missing. */
|
|
911
|
+
scheduleOrClearRetry(record, missingPeers) {
|
|
912
|
+
if (missingPeers.length > 0) {
|
|
913
|
+
this.scheduleCommitRetry(record.messageHash, record, missingPeers);
|
|
866
914
|
}
|
|
867
915
|
else {
|
|
868
|
-
|
|
869
|
-
if (missingPeers.length > 0) {
|
|
870
|
-
this.scheduleCommitRetry(record.messageHash, record, missingPeers);
|
|
871
|
-
}
|
|
872
|
-
else {
|
|
873
|
-
this.clearRetry(record.messageHash);
|
|
874
|
-
}
|
|
916
|
+
this.clearRetry(record.messageHash);
|
|
875
917
|
}
|
|
876
|
-
return record;
|
|
877
918
|
}
|
|
878
919
|
/**
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
920
|
+
* One {@link updateMember} call whose failure is logged and returned rather than thrown, so a
|
|
921
|
+
* parallel round can read every member's answer.
|
|
922
|
+
*/
|
|
923
|
+
async deliver(record, peerId, immediateRetries, phase) {
|
|
924
|
+
try {
|
|
925
|
+
return { peerId, success: true, response: await this.updateMember(peerId, record, immediateRetries, phase) };
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
929
|
+
log('cluster-tx:member-delivery-error', { messageHash: record.messageHash, peerId, phase, error });
|
|
930
|
+
return { peerId, success: false, error };
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Deliver the consensus record — carrying a majority of commit signatures — to the members that
|
|
935
|
+
* still have to apply it: this node's own member first, awaited, unless it already applied
|
|
936
|
+
* ({@link deliverToLocalMember}); then `remoteTargets` in parallel. Each remote delivery gets
|
|
937
|
+
* `commitBroadcastImmediateRetries` in-line re-attempts before it counts as failed: the connection
|
|
938
|
+
* the commit round used is usually still warm, so an immediate retry recovers most transient stream
|
|
939
|
+
* errors without falling back to the scheduled retry timer. This node's member is invoked exactly
|
|
940
|
+
* once — a local failure is a real fault, not a transient one.
|
|
884
941
|
*
|
|
885
|
-
* **Delivery order is load-bearing: this node's own member first,
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
* (
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
* first broadcast unless it was itself among the failed deliveries, which is the retry residual
|
|
899
|
-
* documented on `executeClusterTransaction`. A coordinator outside `record.peers` is not a
|
|
900
|
-
* reconcile target and gains nothing from this ordering; the durability gate in
|
|
901
|
-
* `CoordinatorRepo.commit` is what makes that shape refuse rather than acknowledge.
|
|
942
|
+
* **Delivery order is load-bearing: this node's own member first, then the remote members.** A
|
|
943
|
+
* member that is behind (it never saw the pend, or holds no base for the block) reconciles the
|
|
944
|
+
* committed revision from `record.peers` during its apply. Once the coordinating member has applied
|
|
945
|
+
* it holds the revision, and its copy carries the cohort's commit proof (`buildBlockCommitProof`),
|
|
946
|
+
* which `createReconcileBlock` accepts from a single holder, so a whole cohort of behind members can
|
|
947
|
+
* heal from it. This is also why `remoteTargets` includes members that have ALREADY applied but
|
|
948
|
+
* report a refused commit: in a cohort of three or fewer a remote member applies on receipt of the
|
|
949
|
+
* commit round ({@link commitTransaction}), before this node's member, so a behind one reconciled
|
|
950
|
+
* while nobody held the revision. Sending it the record again now gives it another reconcile
|
|
951
|
+
* (`ClusterMember.handleAlreadyExecuted`), and its answer carries the refreshed verdict. A
|
|
952
|
+
* coordinator outside `record.peers` is not a reconcile target and gains nothing from this order;
|
|
953
|
+
* the durability gate in `CoordinatorRepo.commit` is what makes that shape refuse rather than
|
|
954
|
+
* acknowledge.
|
|
902
955
|
*
|
|
903
|
-
* The mirror case — the coordinating member is ITSELF behind
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
956
|
+
* The mirror case — the coordinating member is ITSELF behind — mostly heals on its own: in a small
|
|
957
|
+
* cohort a remote member applied during the commit round, so this node's member finds a holder on
|
|
958
|
+
* its first reconcile. Where no remote member has applied yet (a cohort of four or more, where
|
|
959
|
+
* nobody applies on receipt), that first reconcile runs before anyone holds the revision and
|
|
960
|
+
* retains a refusal. So once a remote member reports holding the revision, this node's member gets
|
|
961
|
+
* one more reconcile (`reconcileRefusedCommit`). The member skips it unless its retained refusal has
|
|
962
|
+
* the behind shape, so only a behind coordinator pays the extra fetch. It finishes before this
|
|
963
|
+
* method returns, so `executeClusterTransaction` reads the refreshed verdict.
|
|
911
964
|
*/
|
|
912
|
-
async broadcastMergedRecord(record,
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
messageHash: record.messageHash,
|
|
921
|
-
peerId: peerIdStr,
|
|
922
|
-
error: err instanceof Error ? err.message : String(err)
|
|
923
|
-
});
|
|
924
|
-
return { peerId: peerIdStr, success: false, response: undefined };
|
|
925
|
-
}
|
|
926
|
-
};
|
|
927
|
-
const selfId = this.localCluster?.peerId.toString();
|
|
928
|
-
const localFirst = peerIds.filter(id => id === selfId);
|
|
929
|
-
const remote = peerIds.filter(id => id !== selfId);
|
|
930
|
-
const localResults = await Promise.all(localFirst.map(deliver));
|
|
931
|
-
const remoteResults = await Promise.all(remote.map(deliver));
|
|
932
|
-
const results = [...localResults, ...remoteResults];
|
|
933
|
-
const failures = results.filter(r => !r.success).map(r => r.peerId);
|
|
934
|
-
// This broadcast is where members actually apply the operations, so their responses carry the
|
|
935
|
-
// only report the coordinator ever gets of what each member's OWN storage said. Collecting it
|
|
936
|
-
// here is what lets a pend refused by a non-coordinating member reach the writer as a conflict
|
|
937
|
-
// instead of the fabricated success that used to fork the block.
|
|
965
|
+
async broadcastMergedRecord(record, remoteTargets) {
|
|
966
|
+
const local = await this.deliverToLocalMember(record);
|
|
967
|
+
const remote = await Promise.all(remoteTargets.map(peerId => this.deliver(record, peerId, this.commitBroadcastImmediateRetries, 'commit-broadcast')));
|
|
968
|
+
const deliveries = local === undefined ? remote : [local, ...remote];
|
|
969
|
+
// This delivery is where most members apply the operations, so their responses carry the only
|
|
970
|
+
// report the coordinator ever gets of what each member's OWN storage said. Collecting it here is
|
|
971
|
+
// what lets a pend refused by a non-coordinating member reach the writer as a conflict instead of
|
|
972
|
+
// the fabricated success that used to fork the block.
|
|
938
973
|
//
|
|
939
|
-
// Each peer's entry is taken from that peer's OWN response and re-keyed under the peer we
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
const applyOutcomes = collectApplyOutcomes(
|
|
974
|
+
// Each peer's entry is taken from that peer's OWN response and re-keyed under the peer we asked,
|
|
975
|
+
// so a member cannot report an outcome on another member's behalf by echoing a record full of
|
|
976
|
+
// entries. Unsigned and advisory either way — see ClusterRecord.applyOutcomes.
|
|
977
|
+
const applyOutcomes = collectApplyOutcomes(deliveries);
|
|
943
978
|
// NOTE: after a healing second reconcile, `applyOutcomes[selfId].commit` still carries the
|
|
944
979
|
// pre-reconcile refusal. Nothing reads the self entry today (the gate reads
|
|
945
980
|
// `localCommitResult`); if anything starts to, re-stamp it from `getExecutedCommitResult` here.
|
|
946
981
|
// NOTE: in a 3+ cohort this also runs when the remote holders already form a majority without
|
|
947
982
|
// this member — one extra fetch that heals its copy; gate on the remote count if it ever shows up.
|
|
948
|
-
|
|
949
|
-
if (remoteHolds && localResults.some(r => r.success)) {
|
|
983
|
+
if (this.localMemberHasApplied(record, local) && this.remoteMemberHolds(record, applyOutcomes)) {
|
|
950
984
|
await this.reconcileLocalMemberAgain(record);
|
|
951
985
|
}
|
|
952
|
-
return {
|
|
986
|
+
return {
|
|
987
|
+
failures: deliveries.filter(d => !d.success).map(d => d.peerId),
|
|
988
|
+
...(applyOutcomes === undefined ? {} : { applyOutcomes })
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Deliver `record` to this node's own member, awaited. `undefined` — nothing sent — when there is
|
|
993
|
+
* no local member in the cohort, or it has already applied the record.
|
|
994
|
+
*/
|
|
995
|
+
async deliverToLocalMember(record) {
|
|
996
|
+
const selfId = this.localCluster?.peerId.toString();
|
|
997
|
+
if (selfId === undefined || !(selfId in record.peers) || this.localCluster.wasTransactionExecuted?.(record.messageHash) === true) {
|
|
998
|
+
return undefined;
|
|
999
|
+
}
|
|
1000
|
+
return await this.deliver(record, selfId, 0, 'commit-broadcast');
|
|
1001
|
+
}
|
|
1002
|
+
/** This node's member is in the cohort and has applied the record: just now (`local`), or before. */
|
|
1003
|
+
localMemberHasApplied(record, local) {
|
|
1004
|
+
const selfId = this.localCluster?.peerId.toString();
|
|
1005
|
+
return selfId !== undefined && selfId in record.peers && (local?.success ?? true);
|
|
1006
|
+
}
|
|
1007
|
+
/** Whether any remote member reports holding the commit, on this delivery or an earlier one. */
|
|
1008
|
+
remoteMemberHolds(record, latest) {
|
|
1009
|
+
const selfId = this.localCluster?.peerId.toString();
|
|
1010
|
+
const outcomes = { ...record.applyOutcomes, ...latest };
|
|
1011
|
+
return Object.keys(record.peers).some(id => id !== selfId && outcomes[id]?.commit?.success === true);
|
|
953
1012
|
}
|
|
954
1013
|
/**
|
|
955
1014
|
* Give this node's own member its second reconcile (see {@link broadcastMergedRecord}). The
|
|
@@ -1054,36 +1113,45 @@ export class ClusterCoordinator {
|
|
|
1054
1113
|
this.clearRetry(messageHash);
|
|
1055
1114
|
return;
|
|
1056
1115
|
}
|
|
1057
|
-
const peerIds = Array.from(pendingPeers);
|
|
1058
1116
|
const record = state.record;
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
peerId: peerIdStr,
|
|
1076
|
-
success: false,
|
|
1077
|
-
error: err instanceof Error ? err.message : String(err)
|
|
1078
|
-
};
|
|
1079
|
-
}
|
|
1080
|
-
}));
|
|
1081
|
-
const successes = results.filter(r => r.success).map(r => r.peerId);
|
|
1082
|
-
const failures = results.filter(r => !r.success);
|
|
1083
|
-
for (const peerId of successes) {
|
|
1084
|
-
pendingPeers.delete(peerId);
|
|
1117
|
+
const selfId = this.localCluster?.peerId.toString();
|
|
1118
|
+
log('cluster-tx:retry-start', { messageHash, attempt, peerIds: Array.from(pendingPeers) });
|
|
1119
|
+
// Each pending member gets the record as it stands: it adds its commit, and applies once the
|
|
1120
|
+
// record then carries a majority, which in a small cohort this very delivery can complete. This
|
|
1121
|
+
// node's member is left to the consensus broadcast below once the record already carries a
|
|
1122
|
+
// majority; before that (the commit round failed on it too) it is asked for its commit like the rest.
|
|
1123
|
+
const payload = { ...record };
|
|
1124
|
+
const selfToBroadcast = this.hasCommitMajority(record);
|
|
1125
|
+
const deliveries = await Promise.all(Array.from(pendingPeers)
|
|
1126
|
+
.filter(peerId => !selfToBroadcast || peerId !== selfId)
|
|
1127
|
+
.map(peerId => this.deliver(payload, peerId, 0, 'commit-retry')));
|
|
1128
|
+
mergeCommits(record, deliveries);
|
|
1129
|
+
mergeApplyOutcomes(record, collectApplyOutcomes(deliveries));
|
|
1130
|
+
for (const { peerId, success } of deliveries) {
|
|
1131
|
+
if (success)
|
|
1132
|
+
pendingPeers.delete(peerId);
|
|
1085
1133
|
}
|
|
1086
|
-
|
|
1134
|
+
if (this.hasCommitMajority(record)) {
|
|
1135
|
+
// The retry may itself have assembled the majority (a two-member cohort whose remote member
|
|
1136
|
+
// missed the commit round), and then this node's member has not applied; a remote member that
|
|
1137
|
+
// applied on receipt before this node's member did may hold a behind refusal; and in a cohort
|
|
1138
|
+
// of four or more the members that answered here have not applied at all. The consensus
|
|
1139
|
+
// broadcast covers all three, in its usual order, and delivers this node's member unless it
|
|
1140
|
+
// already applied.
|
|
1141
|
+
const { failures, applyOutcomes } = await this.broadcastMergedRecord(record, membersAwaitingConsensus(deliveries.filter(d => d.success && d.peerId !== selfId)));
|
|
1142
|
+
mergeApplyOutcomes(record, applyOutcomes);
|
|
1143
|
+
if (selfId !== undefined)
|
|
1144
|
+
pendingPeers.delete(selfId);
|
|
1145
|
+
for (const peerId of failures)
|
|
1146
|
+
pendingPeers.add(peerId);
|
|
1147
|
+
}
|
|
1148
|
+
log('cluster-tx:retry-complete', {
|
|
1149
|
+
messageHash,
|
|
1150
|
+
attempt,
|
|
1151
|
+
successes: deliveries.filter(d => d.success).map(d => d.peerId),
|
|
1152
|
+
failures: deliveries.filter(d => !d.success).map(({ peerId, error }) => ({ peerId, error })),
|
|
1153
|
+
stillPending: Array.from(pendingPeers)
|
|
1154
|
+
});
|
|
1087
1155
|
if (pendingPeers.size === 0) {
|
|
1088
1156
|
log('cluster-tx:retry-finished', { messageHash });
|
|
1089
1157
|
this.clearRetry(messageHash);
|