@optimystic/db-core 0.28.0 → 0.29.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 (37) hide show
  1. package/dist/src/collection/collection.d.ts.map +1 -1
  2. package/dist/src/collection/collection.js +78 -6
  3. package/dist/src/collection/collection.js.map +1 -1
  4. package/dist/src/collection/struct.d.ts +53 -0
  5. package/dist/src/collection/struct.d.ts.map +1 -1
  6. package/dist/src/collection/struct.js +40 -0
  7. package/dist/src/collection/struct.js.map +1 -1
  8. package/dist/src/network/stale-failure.d.ts +4 -1
  9. package/dist/src/network/stale-failure.d.ts.map +1 -1
  10. package/dist/src/network/stale-failure.js +4 -1
  11. package/dist/src/network/stale-failure.js.map +1 -1
  12. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  13. package/dist/src/testing/test-transactor.js +32 -2
  14. package/dist/src/testing/test-transactor.js.map +1 -1
  15. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  16. package/dist/src/transaction/coordinator.js +5 -1
  17. package/dist/src/transaction/coordinator.js.map +1 -1
  18. package/dist/src/transactor/network-transactor.d.ts +109 -1
  19. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  20. package/dist/src/transactor/network-transactor.js +230 -17
  21. package/dist/src/transactor/network-transactor.js.map +1 -1
  22. package/dist/src/transactor/transactor-source.d.ts +12 -0
  23. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  24. package/dist/src/transactor/transactor-source.js +41 -2
  25. package/dist/src/transactor/transactor-source.js.map +1 -1
  26. package/dist/src/transactor/transactor.d.ts +5 -0
  27. package/dist/src/transactor/transactor.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/collection/collection.ts +81 -6
  30. package/src/collection/struct.ts +157 -98
  31. package/src/logger.ts +10 -10
  32. package/src/network/stale-failure.ts +4 -1
  33. package/src/testing/test-transactor.ts +34 -3
  34. package/src/transaction/coordinator.ts +5 -1
  35. package/src/transactor/network-transactor.ts +237 -37
  36. package/src/transactor/transactor-source.ts +39 -2
  37. package/src/transactor/transactor.ts +49 -44
@@ -14,6 +14,7 @@ import { groupBy } from "../utility/groupby.js";
14
14
  import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
15
15
  import { isRecordEmpty } from "../utility/is-record-empty.js";
16
16
  import { type CoordinatorBatch, makeBatchesByPeer, incompleteBatches, everyBatch, allBatches, mergeBlocks, processBatches, createBatchesForPayload } from "../utility/batch-coordinator.js";
17
+ import { abortableDelay, jitteredBackoffMs } from "../utility/backoff.js";
17
18
  import { createLogger, verbose } from "../logger.js";
18
19
 
19
20
  const log = createLogger('network-transactor');
@@ -280,7 +281,6 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
280
281
  if (missingIds.length > 0) {
281
282
  log('get:missing blockIds=%o', missingIds);
282
283
  const details = this.formatBatchStatuses(batches,
283
- b => (b.request?.isResponse as boolean) ?? false,
284
284
  b => {
285
285
  const status = b.request == null ? 'no-response' : (b.request.isResponse ? 'response' : 'in-flight')
286
286
  const errMsg = b.request?.isError ? ` cause=${errorMessage(b.request.error)}` : ''
@@ -568,7 +568,6 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
568
568
 
569
569
  if (!everyBatch(batches, b => b.request?.isResponse as boolean && b.request!.response!.success)) {
570
570
  const details = this.formatBatchStatuses(batches,
571
- b => (b.request?.isResponse as boolean && (b.request as any).response?.success) ?? false,
572
571
  b => {
573
572
  const status = b.request == null ? 'no-response' : (b.request.isResponse ? 'non-success' : 'in-flight')
574
573
  const errMsg = b.request?.isError ? ` cause=${errorMessage(b.request.error)}` : ''
@@ -584,9 +583,23 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
584
583
  error = aggregate;
585
584
  }
586
585
 
587
- if (error) { // If any failures, cancel all pending actions as background microtask
586
+ if (error) { // If any failures, discharge every pending record this pend left behind
588
587
  log('pend:cancel actionId=%s', blockAction.actionId);
589
- void Promise.resolve().then(() => this.cancelBatch(batches, { blockIds, actionId: blockAction.actionId })).catch(e => log('WARN: cancel after pend failure rejected: %o', e));
588
+ // AWAITED, not fired off as a background microtask. Returning before the cancel lands means
589
+ // a caller that retries immediately meets its own still-standing pending record and burns a
590
+ // whole attempt out of its retry budget on it (measured: the attempt after a lost pend reply
591
+ // was rejected by its own record, and only the one after that landed).
592
+ // The tradeoff, stated: a losing pend now pays a cancel round-trip before it returns its
593
+ // StaleFailure, where before it returned at once and cleaned up behind itself. That is the
594
+ // right trade — the old shape spent one of the CALLER's retries instead of one round-trip.
595
+ // The catch matters now that `cancelBatch` is checked and throwable: a cancel that could not
596
+ // discharge must be reported, but must not replace the pend verdict below (the StaleFailure
597
+ // the caller reads via `isConflictFailure` to decide to rebase, or the original error).
598
+ try {
599
+ await this.cancelBatch(batches, { blockIds, actionId: blockAction.actionId });
600
+ } catch (cancelError) {
601
+ log('WARN: cancel after pend failure did not discharge: %o', cancelError);
602
+ }
590
603
  const stale = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
591
604
  if (stale.length > 0) { // Any active stale failures should preempt reporting connection or other potential transient errors (we have information)
592
605
  log('pend:stale actionId=%s staleCount=%d', blockAction.actionId, stale.length);
@@ -653,23 +666,20 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
653
666
  };
654
667
  }
655
668
 
669
+ /**
670
+ * Discharges every pending record `actionRef` left behind, and THROWS if it could not.
671
+ *
672
+ * Cancel is the only removal path a client controls (see docs/repository.md, "a pending record's
673
+ * lifetime is bounded by its writer"), so a cancel that silently did nothing wedges the block
674
+ * against every later writer. `processBatches` never rethrows — it records each batch's outcome
675
+ * and swallows the rejection — so a cancel in which every peer's RPC failed used to return
676
+ * normally, and each of this method's callers read "returned" as "discharged". It now checks
677
+ * completeness and rides out a transient fault, exactly as {@link pend} and {@link commitBlocks}
678
+ * already do for their own rounds. See {@link dischargeCancel} for the loop and its bounds.
679
+ */
656
680
  async cancel(actionRef: ActionBlocks): Promise<void> {
657
681
  log('cancel actionId=%s blockIds=%d', actionRef.actionId, actionRef.blockIds.length);
658
- const batches = await this.batchesForPayload<BlockId[], void>(
659
- actionRef.blockIds,
660
- actionRef.blockIds,
661
- mergeBlocks,
662
- []
663
- );
664
- const expiration = Date.now() + this.abortOrCancelTimeoutMs;
665
- await processBatches(
666
- batches,
667
- (batch) => this.getRepo(batch.peerId).cancel({ actionId: actionRef.actionId, blockIds: batch.payload }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
668
- batch => batch.payload,
669
- mergeBlocks,
670
- expiration,
671
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
672
- );
682
+ await this.dischargeCancel(actionRef);
673
683
  }
674
684
 
675
685
  async queryClusterNominees(blockId: BlockId): Promise<ClusterNomineesResult> {
@@ -743,10 +753,12 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
743
753
  // tickets/fix/refresh-must-always-know-its-own-in-flight-action, and to be fixed there
744
754
  // rather than by tolerating the failure here.
745
755
  //
746
- // Transport-shaped failures (throws, no returned refusal) keep the tolerance: the commit
747
- // consensus for these blocks exists, so lagging peers converge via reconciliation paths
748
- // (e.g. reads with context).
749
- try { log('WARN: non-tail commit had errors; proceeding after tail commit: %s', error.message); } catch { /* ignore */ }
756
+ // Transport-shaped failures (throws, no returned refusal) keep the tolerance for the
757
+ // RESULT the tail committed durably, and reporting failure now would disown an
758
+ // acknowledged write but NOT for the state the sweep abandoned, which
759
+ // `cancelAbandonedSweepBlocks` below repairs before this returns.
760
+ try { log('WARN: non-tail commit had errors; cancelling unconfirmed blocks, proceeding after tail commit: %s', error.message); } catch { /* ignore */ }
761
+ await this.cancelAbandonedSweepBlocks(request.actionId, remainingBlocks, batches);
750
762
  }
751
763
  }
752
764
 
@@ -754,6 +766,62 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
754
766
  return { success: true };
755
767
  }
756
768
 
769
+ /**
770
+ * Cancels every sweep block whose commit batch never confirmed success, so an acknowledged
771
+ * commit never walks away from a pending record. Best-effort: a failed cancel is logged, never
772
+ * thrown, because the tail is already durable and the caller has nothing left to retry.
773
+ *
774
+ * **Why cancelling is required.** A thrown sweep does not imply the sweep's commit consensus
775
+ * exists anywhere: it also covers a sweep whose cluster transaction reached NOBODY, where no
776
+ * consensus record exists, no reconciliation will ever run, and every cohort member still holds
777
+ * the pending record its pend wrote. Nothing else removes such a record (its only removers are a
778
+ * client cancel, a divergence-shaped commit refusal, and a forward write of the SAME action — see
779
+ * docs/repository.md "a pending record's lifetime is bounded by its writer"), and while it stands
780
+ * the members reject every later write to the block from any writer.
781
+ *
782
+ * **Why cancelling is safe** in all three timings:
783
+ * - the sweep's commit DID land on a member (a lost response) ⇒ that member already promoted the
784
+ * record, so the cancel is a no-op there;
785
+ * - it did not land ⇒ the cancel is exactly the repair;
786
+ * - its consensus is still in flight and lands AFTER the cancel ⇒ the member meets a missing
787
+ * pend, which `ClusterMember.applyConsensusOperation` already treats as "behind" divergence and
788
+ * cures by reconciling the block from a cohort peer.
789
+ *
790
+ * Confirmed blocks are excluded only to skip a pointless consensus round — cancelling one would
791
+ * also be a no-op — so an over-broad `confirmed` set costs latency, never correctness.
792
+ *
793
+ * Residuals: the cancel is retried and checked ({@link dischargeCancel}) but still bounded, so a
794
+ * fault outlasting its budget leaves the record wedged until a node-side backstop sweep exists (backlog:
795
+ * `debt-unpromotable-pending-records-need-a-sweep`); and this covers only the sweep's abandonment
796
+ * — `StorageRepo.commit`'s genuine-fault arm deliberately KEEPS a failed batch's pendings for a
797
+ * retry, so it is a second producer of the same durable state whenever that retry never comes.
798
+ */
799
+ private async cancelAbandonedSweepBlocks(actionId: ActionId, sweptBlocks: BlockId[], batches: CoordinatorBatch<BlockId[], CommitResult>[]): Promise<void> {
800
+ // NOTE: `confirmed` is only ever non-empty when the sweep spans MORE THAN ONE batch, and it
801
+ // does so only when no single peer covers every swept block — `consolidateCoordinators`'
802
+ // greedy set cover collapses the pend onto one coordinator otherwise, and commit reuses that
803
+ // resolution. So on a mesh whose nodes are all responsible for all blocks — every in-process
804
+ // test mesh today — this filter is structurally unreachable, and
805
+ // packages/db-p2p/test/torn-commit-cancels-abandoned-blocks.spec.ts cannot cover it. Pinning
806
+ // a partial sweep needs a mesh whose responsibility sets are disjoint enough to force two
807
+ // pend batches; a new failure injection alone will not reach it. That fixture is tracked in
808
+ // backlog `debt-no-mesh-fixture-forces-two-coordinator-batches`, which collects this site and
809
+ // the two others with the same gap.
810
+ const confirmed = new Set<BlockId>();
811
+ for (const b of allBatches(batches, bb => bb.request?.isResponse === true && bb.request!.response!.success)) {
812
+ for (const bid of b.payload) confirmed.add(bid);
813
+ }
814
+ const abandoned = sweptBlocks.filter(bid => !confirmed.has(bid));
815
+ if (abandoned.length === 0) {
816
+ return;
817
+ }
818
+ try {
819
+ await this.cancel({ actionId, blockIds: abandoned });
820
+ } catch (cancelError) {
821
+ try { log('WARN: cancel of abandoned sweep blocks failed — pending records may wedge until the node-side sweep lands: %o', cancelError); } catch { /* ignore */ }
822
+ }
823
+ }
824
+
757
825
  private async commitBlock(blockId: BlockId, actionId: ActionId, rev: number, tailId?: BlockId, blockDigests?: BlockContentDigests): Promise<CommitResult> {
758
826
  const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId, blockDigests });
759
827
  if (tailError) {
@@ -826,7 +894,6 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
826
894
 
827
895
  if (!everyBatch(batches, b => b.request?.isResponse as boolean && b.request!.response!.success)) {
828
896
  const details = this.formatBatchStatuses(batches,
829
- b => (b.request?.isResponse as boolean && (b.request as any).response?.success) ?? false,
830
897
  b => {
831
898
  const status = b.request == null ? 'no-response' : (b.request.isResponse ? 'non-success' : 'in-flight')
832
899
  const resp: any = (b.request as any)?.response;
@@ -918,31 +985,150 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
918
985
  return created.coordinators;
919
986
  }
920
987
 
921
- /** Cancels a pending transaction by canceling all blocks associated with the transaction, including failed peers */
988
+ /**
989
+ * Cancels a pending transaction by canceling all blocks associated with the transaction,
990
+ * including failed peers. Seeds the first round from the peers the failed operation actually
991
+ * talked to (a coordinator that answered the pend is the one most likely to be holding the
992
+ * record); every later round re-resolves live. Throws on failure to discharge — see
993
+ * {@link dischargeCancel}.
994
+ *
995
+ * The seed uses each batch's `coordinatingBlockIds` — the full set `consolidateCoordinators`
996
+ * assigned to that peer — not its anchor `blockId` alone. Anchor-only was silently lossy: when
997
+ * consolidation collapsed several blocks onto one coordinator, the non-anchor blocks got no
998
+ * cancel at all. `processBatches`' retry batches carry no `coordinatingBlockIds` and fall back to
999
+ * their anchor, so a retried multi-block batch can still under-cover the seed round;
1000
+ * `dischargeCancel`'s per-block outstanding set notices and the next round resolves those live.
1001
+ * That residual costs a round, never correctness, and is unreachable on a mesh where every node
1002
+ * covers every block — see backlog `debt-no-mesh-fixture-forces-two-coordinator-batches`.
1003
+ */
922
1004
  private async cancelBatch<TPayload, TResponse>(
923
1005
  batches: CoordinatorBatch<TPayload, TResponse>[],
924
1006
  actionRef: ActionBlocks,
925
1007
  ) {
926
- const expiration = Date.now() + this.abortOrCancelTimeoutMs;
927
- const operationBatches = makeBatchesByPeer(
928
- Array.from(allBatches(batches)).map(b => [b.blockId, b.peerId] as const),
1008
+ const operationBatches = makeBatchesByPeer<BlockId[], void>(
1009
+ Array.from(allBatches(batches)).flatMap(b => (b.coordinatingBlockIds ?? [b.blockId]).map(bid => [bid, b.peerId] as const)),
929
1010
  actionRef.blockIds,
930
1011
  mergeBlocks,
931
1012
  []
932
1013
  );
933
- await processBatches(
934
- operationBatches,
935
- (batch) => this.getRepo(batch.peerId).cancel({ actionId: actionRef.actionId, blockIds: batch.payload }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
936
- batch => batch.payload,
937
- mergeBlocks,
938
- expiration,
939
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
940
- );
1014
+ await this.dischargeCancel(actionRef, operationBatches);
1015
+ }
1016
+
1017
+ /**
1018
+ * Rounds {@link dischargeCancel} will run before giving up, in addition to the
1019
+ * `abortOrCancelTimeoutMs` deadline; whichever bound trips first ends the loop.
1020
+ *
1021
+ * NOTE: accepted tradeoff — the round cap can end the loop well before the deadline. Six rounds
1022
+ * means five backoffs, whose pre-jitter values with `baseMs` 50 / `capMs` 500 are 50, 100, 200,
1023
+ * 400, 500 ms, each drawn from `(0.5·exp, exp]` — so 0.63–1.25 s of waiting, plus each round's
1024
+ * own RPC time (measured end to end: 1054 ms for the dead-transport arm of
1025
+ * packages/db-p2p/test/reset-does-not-strand-its-own-pend.spec.ts). That clears a stream reset
1026
+ * and reconnect, and against a genuinely dead transport it hands the caller a legible failure in
1027
+ * about a second instead of spending its whole (typically 5 s) abort budget issuing RPCs nobody
1028
+ * will answer. Raise it if faults longer than that but shorter than the abort budget turn out to
1029
+ * strand records in practice.
1030
+ */
1031
+ private static readonly MAX_CANCEL_ROUNDS = 6;
1032
+
1033
+ /**
1034
+ * Runs cancel rounds until every block of `actionRef` has had a cancel ANSWERED by some peer, or
1035
+ * the budget runs out — and then throws an aggregate naming the action and the blocks whose
1036
+ * pending records are still standing.
1037
+ *
1038
+ * **Why a completeness check.** `processBatches` deliberately never rethrows, so "it returned"
1039
+ * says nothing about whether any peer was reached. A block is only discharged once some batch
1040
+ * carrying it got a response; anything else leaves its pending record standing, and while it
1041
+ * stands `ClusterMember.validatePendOperations` votes reject on every later pend touching the
1042
+ * block, from any writer. An application retry loop opens a NEW transaction and so mints a new
1043
+ * action id, which is none of the three things that remove a record (client cancel,
1044
+ * divergence-shaped commit refusal, forward write of the SAME action id — docs/repository.md),
1045
+ * so the write collides with its own predecessor permanently.
1046
+ *
1047
+ * **Why a TIME-based retry on top of `processBatches`' own.** That one is a PEER retry: it
1048
+ * re-homes a failed block onto an alternate coordinator immediately, with no delay. A stream
1049
+ * reset is time-shaped, not peer-shaped — every peer is equally unreachable for the length of
1050
+ * the fault — so only a delayed re-attempt clears it. Each round rebuilds its batches from
1051
+ * scratch, so a re-resolved coordinator is picked up.
1052
+ *
1053
+ * **Why retrying is safe.** Cancel is idempotent, and the three-timings argument written out in
1054
+ * {@link cancelAbandonedSweepBlocks} covers a cancel that races a landing commit. Extra rounds
1055
+ * cost latency only. Each round narrows to the blocks still outstanding, so a partially
1056
+ * successful round does not re-issue the cancels that already landed.
1057
+ *
1058
+ * NOTE: a fault that outlasts these bounds still strands the record, and nothing node-side
1059
+ * reclaims it. That residual is the backstop tracked in backlog
1060
+ * `debt-unpromotable-pending-records-need-a-sweep`.
1061
+ *
1062
+ * @param seedBatches Batches to use for round 0 only (see {@link cancelBatch}). Omitted → round
1063
+ * 0 resolves coordinators live like every later round.
1064
+ */
1065
+ private async dischargeCancel(
1066
+ actionRef: ActionBlocks,
1067
+ seedBatches?: CoordinatorBatch<BlockId[], void>[]
1068
+ ): Promise<void> {
1069
+ let outstanding = Array.from(new Set(actionRef.blockIds));
1070
+ if (outstanding.length === 0) {
1071
+ return;
1072
+ }
1073
+ const deadline = Date.now() + this.abortOrCancelTimeoutMs;
1074
+ let roundBatches: CoordinatorBatch<BlockId[], void>[] = [];
1075
+ let lastError: Error | undefined;
1076
+ for (let round = 0; ; ++round) {
1077
+ roundBatches = [];
1078
+ try {
1079
+ // Batch construction can throw (coordinator lookup); `processBatches` cannot.
1080
+ roundBatches = round === 0 && seedBatches
1081
+ ? seedBatches
1082
+ : await this.batchesForPayload<BlockId[], void>(outstanding, outstanding, mergeBlocks, []);
1083
+ await processBatches(
1084
+ roundBatches,
1085
+ (batch) => this.getRepo(batch.peerId).cancel({ actionId: actionRef.actionId, blockIds: batch.payload }, { expiration: deadline, dialTimeoutMs: this.dialTimeoutMs }),
1086
+ batch => batch.payload,
1087
+ mergeBlocks,
1088
+ deadline,
1089
+ async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
1090
+ );
1091
+ } catch (e) {
1092
+ lastError = asError(e);
1093
+ }
1094
+
1095
+ const discharged = dischargedBlocks(roundBatches);
1096
+ outstanding = outstanding.filter(bid => !discharged.has(bid));
1097
+ if (outstanding.length === 0) {
1098
+ if (round > 0) {
1099
+ log('cancel:discharged actionId=%s rounds=%d', actionRef.actionId, round + 1);
1100
+ }
1101
+ return;
1102
+ }
1103
+
1104
+ const remainingMs = deadline - Date.now();
1105
+ if (round + 1 >= NetworkTransactor.MAX_CANCEL_ROUNDS || remainingMs <= 0) {
1106
+ break;
1107
+ }
1108
+ log('cancel:retry actionId=%s round=%d outstanding=%d', actionRef.actionId, round, outstanding.length);
1109
+ await abortableDelay(Math.min(jitteredBackoffMs(round, { baseMs: 50, capMs: 500 }), remainingMs));
1110
+ }
1111
+
1112
+ const details = this.formatBatchStatuses(roundBatches,
1113
+ b => {
1114
+ const status = b.request == null ? 'no-response' : (b.request.isResponse ? 'responded' : 'in-flight');
1115
+ const errMsg = b.request?.isError ? ` cause=${errorMessage(b.request.error)}` : '';
1116
+ return `${b.peerId.toString()}[block:${b.blockId}](${status})${errMsg}`;
1117
+ });
1118
+ const rootCause = firstBatchError(roundBatches) ?? lastError;
1119
+ const aggregate = new Error(`Cancel of action ${actionRef.actionId} did not discharge ${outstanding.length} block(s): ${outstanding.join(', ')}`
1120
+ + (details ? `; peers: ${details}` : '')
1121
+ + (rootCause ? `; root: ${rootCause.message}` : ''));
1122
+ (aggregate as any).cause = rootCause;
1123
+ (aggregate as AggregateError).errors = rootCause ? [rootCause] : [];
1124
+ throw aggregate;
941
1125
  }
942
1126
 
1127
+ /** Renders the batches worth naming in an aggregate error: the ones that never completed, or —
1128
+ * when every batch completed and the aggregate is about a non-success RESPONSE rather than a
1129
+ * transport fault — all of them. */
943
1130
  private formatBatchStatuses<TPayload, TResponse>(
944
1131
  batches: CoordinatorBatch<TPayload, TResponse>[],
945
- _isSuccess: (b: CoordinatorBatch<TPayload, TResponse>) => boolean,
946
1132
  formatter: (b: CoordinatorBatch<TPayload, TResponse>) => string
947
1133
  ): string {
948
1134
  const incompletes = Array.from(incompleteBatches(batches))
@@ -955,6 +1141,20 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
955
1141
  }
956
1142
 
957
1143
 
1144
+ /**
1145
+ * The block ids some batch in the tree got an ANSWER for. A cancel batch that errored, or never
1146
+ * responded, discharged nothing — its blocks' pending records are still standing. Batch payloads
1147
+ * are block-id lists (built with {@link mergeBlocks}), including the retry batches
1148
+ * `processBatches` re-homes, so the union across the whole tree is the discharged set.
1149
+ */
1150
+ function dischargedBlocks(batches: CoordinatorBatch<BlockId[], void>[]): Set<BlockId> {
1151
+ const discharged = new Set<BlockId>();
1152
+ for (const b of allBatches(batches, bb => bb.request?.isResponse === true)) {
1153
+ for (const bid of b.payload) discharged.add(bid);
1154
+ }
1155
+ return discharged;
1156
+ }
1157
+
958
1158
  /** The subset of `all` whose ids appear in `batchBlockIds`, wrapped (via {@link blockDigestsField})
959
1159
  * so it spreads to nothing when the batch declares no digests. Called at SEND time, once per attempt,
960
1160
  * because `processBatches` re-batches failed blocks onto different coordinators — a subset computed
@@ -5,6 +5,9 @@ import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struc
5
5
  import type { ReadDependency } from "../transaction/transaction.js";
6
6
  import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
7
7
  import { blockDigestsField } from "../transform/digest.js";
8
+ import { createLogger } from "../logger.js";
9
+
10
+ const log = createLogger('transactor-source');
8
11
 
9
12
  export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
10
13
  /** Shared with this collection's CacheSource so cache hits also record dependencies.
@@ -158,13 +161,47 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
158
161
  ...blockDigestsField(blockDigests)
159
162
  });
160
163
  if (!commitResult.success) {
161
- await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
164
+ // A confirmed conflict has to be RETURNED as the StaleFailure, because `Collection.sync`
165
+ // and the multi-collection pend phase read it via `isConflictFailure` to decide to
166
+ // rebase — letting the cancel's own failure throw over it would turn a routine,
167
+ // recoverable race into a hard failure. So the cancel fault is logged, not raised.
168
+ await this.dischargePend(actionId, pendResult.blockIds);
162
169
  return commitResult;
163
170
  }
164
171
  } catch (e) {
165
- await this.transactor.cancel({ actionId, blockIds: pendResult.blockIds });
172
+ // `e` is the real cause — a transport fault, the thing the caller needs to see. A cancel
173
+ // that also fails must not silently take its place, but it must not be lost either: the
174
+ // pend was left undischarged and that is what wedges the block against the caller's own
175
+ // retry. Attach it to `e` so one report names both.
176
+ const cancelError = await this.dischargePend(actionId, pendResult.blockIds);
177
+ if (cancelError !== undefined && e !== null && typeof e === 'object') {
178
+ // A frozen or sealed error would make this assignment throw, and a throw here would
179
+ // replace the cause the caller actually needs. The log above already named the cancel.
180
+ try { (e as { cancelError?: unknown }).cancelError = cancelError; } catch { /* ignore */ }
181
+ }
166
182
  throw e;
167
183
  }
168
184
  }
185
+
186
+ /**
187
+ * Discharges the pending records this attempt left behind, on both of `transact`'s abort paths.
188
+ *
189
+ * `ITransactor.cancel` returns only when the records are gone and throws otherwise
190
+ * (`NetworkTransactor.cancel` retries and then verifies that some peer actually answered), so a
191
+ * throw here means the block stays wedged against every later writer. That has to be reported —
192
+ * but never by displacing the verdict the cancel is cleaning up after, so it comes back as a
193
+ * value rather than propagating.
194
+ *
195
+ * @returns the cancel's own failure, or `undefined` when it discharged.
196
+ */
197
+ private async dischargePend(actionId: ActionId, blockIds: BlockId[]): Promise<unknown> {
198
+ try {
199
+ await this.transactor.cancel({ actionId, blockIds });
200
+ return undefined;
201
+ } catch (cancelError) {
202
+ log('WARN: cancel after failed commit did not discharge actionId=%s blocks=%o: %o', actionId, blockIds, cancelError);
203
+ return cancelError;
204
+ }
205
+ }
169
206
  }
170
207
 
@@ -1,44 +1,49 @@
1
- import type { GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, CommitRequest, BlockGets, BlockId } from "../index.js";
2
- import type { PeerId } from "../network/types.js";
3
-
4
- export type ClusterNomineesResult = {
5
- /** Peer IDs of the cluster members who can participate in consensus */
6
- nominees: PeerId[];
7
- };
8
-
9
- export type ITransactor = {
10
- /** Get blocks by their IDs and versions or a specific action
11
- - Does not update the version of the block, but the action is available for explicit reading, and for committing
12
- - If the action targets the correct version, the call succeeds, unless failIfPending and there are any pending actions - the caller may choose to wait for pending actions to clear rather than risk racing with them
13
- - If the action targets an older version, the call fails, and the caller must resync using the missing actions
14
- */
15
- get(blockGets: BlockGets): Promise<GetBlockResults>;
16
-
17
- /** Get statuses of block actions */
18
- getStatus(actionRefs: ActionBlocks[]): Promise<BlockActionStatus[]>;
19
-
20
- /** Post an action for a set of blocks
21
- - Does not update the version of the block, but the action is available for explicit reading, and for committing
22
- - If the action targets the correct version, the call succeeds, unless pending = 'fail' and there are any pending actions - the caller may choose to wait for pending actions to clear rather than risk racing with them
23
- - If the action targets an older version, the call fails, and the caller must resync using the missing actions
24
- */
25
- pend(blockAction: PendRequest): Promise<PendResult>;
26
-
27
- /** Cancel a pending action
28
- - If the given action ID is pending, it is canceled
29
- */
30
- cancel(actionRef: ActionBlocks): Promise<void>;
31
-
32
- /** Commit a pending action
33
- - If the action references the current version, the pending action is committed
34
- - If the returned fails, the transforms necessary to update all overlapping blocks are returned
35
- - If the action mentions other collections, those are assumed conditions - returned conditions only list inherited conditions
36
- */
37
- commit(request: CommitRequest): Promise<CommitResult>;
38
-
39
- /** Query cluster nominees for a critical block (used in GATHER phase for multi-collection transactions)
40
- - Returns the peer IDs of cluster members who can participate in consensus for the given block
41
- - Used to build the supercluster for multi-collection transaction consensus
42
- */
43
- queryClusterNominees?(blockId: BlockId): Promise<ClusterNomineesResult>;
44
- }
1
+ import type { GetBlockResults, ActionBlocks, BlockActionStatus, PendResult, CommitResult, PendRequest, CommitRequest, BlockGets, BlockId } from "../index.js";
2
+ import type { PeerId } from "../network/types.js";
3
+
4
+ export type ClusterNomineesResult = {
5
+ /** Peer IDs of the cluster members who can participate in consensus */
6
+ nominees: PeerId[];
7
+ };
8
+
9
+ export type ITransactor = {
10
+ /** Get blocks by their IDs and versions or a specific action
11
+ - Does not update the version of the block, but the action is available for explicit reading, and for committing
12
+ - If the action targets the correct version, the call succeeds, unless failIfPending and there are any pending actions - the caller may choose to wait for pending actions to clear rather than risk racing with them
13
+ - If the action targets an older version, the call fails, and the caller must resync using the missing actions
14
+ */
15
+ get(blockGets: BlockGets): Promise<GetBlockResults>;
16
+
17
+ /** Get statuses of block actions */
18
+ getStatus(actionRefs: ActionBlocks[]): Promise<BlockActionStatus[]>;
19
+
20
+ /** Post an action for a set of blocks
21
+ - Does not update the version of the block, but the action is available for explicit reading, and for committing
22
+ - If the action targets the correct version, the call succeeds, unless pending = 'fail' and there are any pending actions - the caller may choose to wait for pending actions to clear rather than risk racing with them
23
+ - If the action targets an older version, the call fails, and the caller must resync using the missing actions
24
+ */
25
+ pend(blockAction: PendRequest): Promise<PendResult>;
26
+
27
+ /** Cancel a pending action
28
+ - If the given action ID is pending, it is canceled
29
+ - Returning means DISCHARGED: an implementation that could not remove the pending records
30
+ must throw rather than return, because nothing else will ever remove them (a record's only
31
+ removers are this cancel, a divergence-shaped commit refusal, and a forward write of the
32
+ same action id see docs/repository.md), and while one stands every later write to the
33
+ block is refused. A caller that swallows the throw must still say so in its log.
34
+ */
35
+ cancel(actionRef: ActionBlocks): Promise<void>;
36
+
37
+ /** Commit a pending action
38
+ - If the action references the current version, the pending action is committed
39
+ - If the returned fails, the transforms necessary to update all overlapping blocks are returned
40
+ - If the action mentions other collections, those are assumed conditions - returned conditions only list inherited conditions
41
+ */
42
+ commit(request: CommitRequest): Promise<CommitResult>;
43
+
44
+ /** Query cluster nominees for a critical block (used in GATHER phase for multi-collection transactions)
45
+ - Returns the peer IDs of cluster members who can participate in consensus for the given block
46
+ - Used to build the supercluster for multi-collection transaction consensus
47
+ */
48
+ queryClusterNominees?(blockId: BlockId): Promise<ClusterNomineesResult>;
49
+ }