@optimystic/db-p2p 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/src/cluster/cluster-repo.d.ts +35 -12
  2. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  3. package/dist/src/cluster/cluster-repo.js +47 -15
  4. package/dist/src/cluster/cluster-repo.js.map +1 -1
  5. package/dist/src/index.d.ts +1 -0
  6. package/dist/src/index.d.ts.map +1 -1
  7. package/dist/src/index.js +1 -0
  8. package/dist/src/index.js.map +1 -1
  9. package/dist/src/libp2p-node-base.d.ts +17 -0
  10. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  11. package/dist/src/libp2p-node-base.js +1 -1
  12. package/dist/src/libp2p-node-base.js.map +1 -1
  13. package/dist/src/noise-crypto.d.ts +3 -0
  14. package/dist/src/noise-crypto.d.ts.map +1 -0
  15. package/dist/src/noise-crypto.js +2 -0
  16. package/dist/src/noise-crypto.js.map +1 -0
  17. package/dist/src/repo/cluster-coordinator.d.ts +92 -36
  18. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  19. package/dist/src/repo/cluster-coordinator.js +266 -198
  20. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  21. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  22. package/dist/src/repo/coordinator-repo.js +5 -4
  23. package/dist/src/repo/coordinator-repo.js.map +1 -1
  24. package/dist/src/rn.d.ts +1 -0
  25. package/dist/src/rn.d.ts.map +1 -1
  26. package/dist/src/rn.js +1 -0
  27. package/dist/src/rn.js.map +1 -1
  28. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  29. package/dist/src/storage/storage-repo.js +24 -6
  30. package/dist/src/storage/storage-repo.js.map +1 -1
  31. package/package.json +2 -2
  32. package/readme.md +15 -0
  33. package/src/cluster/cluster-repo.ts +48 -15
  34. package/src/index.ts +1 -0
  35. package/src/libp2p-node-base.ts +19 -2
  36. package/src/noise-crypto.ts +4 -0
  37. package/src/repo/cluster-coordinator.ts +1409 -1328
  38. package/src/repo/coordinator-repo.ts +5 -4
  39. package/src/rn.ts +1 -0
  40. 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 merged record fans out), and a
260
- * coordinator outside `record.peers` is not a reconcile target so a cohort with no holder would stay
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
- * Commits the transaction to all peers in the cluster
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
- // For each peer, create a client and send the commit
760
- const peerIds = Object.keys(record.peers);
761
- const summary = [];
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
- // Send the record with promises to all peers
770
- // Each peer will add its own commit signature
771
- const commitPayload = {
772
- ...record
773
- };
774
- // No per-peer immediate retry here: a commit-collection failure is recovered
775
- // downstream by broadcastMergedRecord's in-line retry and the scheduled
776
- // commit-retry timer. (The promise phase has no such backstop, which is why
777
- // collectPromises gets the immediate retry instead.)
778
- const commitRequests = peerIds.map(peerIdStr => {
779
- const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
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-merge', {
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
- finalCommits: Object.keys(record.commits ?? {}),
839
- transactionsEntry: this.transactions.get(record.messageHash)
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
- this.updateTransactionRecord(record, 'after-commit');
842
- // Check for simple majority (>50%) - this proves commitment
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
- const simpleMajority = Math.floor(peerCount * this.cfg.simpleMajorityThreshold) + 1;
845
- const commitCount = Object.keys(record.commits).length;
846
- if (commitCount >= simpleMajority) {
847
- log('cluster-tx:commit-majority-reached', {
848
- messageHash: record.messageHash,
849
- commitCount,
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
- const missingPeers = commitFailures.map(entry => entry.peerId);
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
- * Broadcast the merged commit record to every peer, with `commitBroadcastImmediateRetries`
880
- * in-line re-attempts per peer before giving up. The libp2p connection used during
881
- * the prior commit phase is typically still warm, so a single immediate retry recovers
882
- * most transient stream errors without falling back to the scheduled retry timer.
883
- * Local cluster is invoked exactly once — local failures are fatal, not transient.
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, awaited, then the remote
886
- * members in parallel.** This broadcast is where members apply the commit, and a member that is
887
- * behind (it never saw the pend, or holds no base for the block) reconciles the committed
888
- * revision from `record.peers` DURING its apply. The coordinator's own member is the one peer
889
- * guaranteed to hold the revision by then provided it has actually applied, which a single
890
- * `Promise.all` over every peer did not guarantee: the remote members' reconciles raced the
891
- * local apply and found no holder. Its copy also carries the cohort's commit proof
892
- * (`buildBlockCommitProof`), which `createReconcileBlock` accepts from a single holder, so a
893
- * whole cohort of behind members can heal from it. The cost is one in-process apply before the
894
- * network fan-out; no extra round trip. The commit round in `commitTransaction` may stay
895
- * parallel: on the first pass the record it carries has no commit signatures yet, so no member
896
- * can reach consensus (and apply) there. The scheduled retry (`retryCommits`) does re-send a
897
- * record that already carries them, in parallel — but by then this node's member applied in the
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 (no pend, or no base for the block)
904
- * is the price of that order: its reconcile runs before any remote member has applied, finds
905
- * no holder, and retains a refusal. So once the remote members have answered, and at least one
906
- * reported holding the revision, this node's own member gets one more reconcile
907
- * (`reconcileRefusedCommit`). The member skips it unless its retained refusal has the behind
908
- * shape, so only a behind coordinator pays the extra fetch. It finishes before this method
909
- * returns, so `executeClusterTransaction` reads the refreshed verdict, and a two-member cohort
910
- * whose members both end up holding the commit is no longer refused as not durable.
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, peerIds) {
913
- const deliver = async (peerIdStr) => {
914
- try {
915
- const response = await this.updateMember(peerIdStr, record, this.commitBroadcastImmediateRetries, 'commit-broadcast');
916
- return { peerId: peerIdStr, success: true, response };
917
- }
918
- catch (err) {
919
- log('cluster-tx:consensus-broadcast-error', {
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
- // asked, so a member cannot report an outcome on another member's behalf by echoing a record
941
- // full of entries. Unsigned and advisory either way — see ClusterRecord.applyOutcomes.
942
- const applyOutcomes = collectApplyOutcomes(results);
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
- const remoteHolds = remote.some(id => applyOutcomes?.[id]?.commit?.success === true);
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 { failures, ...(applyOutcomes === undefined ? {} : { applyOutcomes }) };
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
- log('cluster-tx:retry-start', { messageHash, attempt, peerIds });
1060
- const results = await Promise.all(peerIds.map(async (peerIdStr) => {
1061
- const isLocal = this.localCluster && peerIdStr === this.localCluster.peerId.toString();
1062
- const payload = {
1063
- ...record,
1064
- commits: record.commits
1065
- };
1066
- try {
1067
- const res = isLocal
1068
- ? await this.localCluster.update(payload)
1069
- : await this.createClusterClient(peerIdFromString(peerIdStr)).update(payload);
1070
- state.record.commits = { ...state.record.commits, ...res.commits };
1071
- return { peerId: peerIdStr, success: true };
1072
- }
1073
- catch (err) {
1074
- return {
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
- log('cluster-tx:retry-complete', { messageHash, attempt, successes, failures });
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);