@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.
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +78 -6
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +53 -0
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +40 -0
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/network/stale-failure.d.ts +4 -1
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +4 -1
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +32 -2
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +5 -1
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +109 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +230 -17
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +12 -0
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +41 -2
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transactor/transactor.d.ts +5 -0
- package/dist/src/transactor/transactor.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/collection/collection.ts +81 -6
- package/src/collection/struct.ts +157 -98
- package/src/logger.ts +10 -10
- package/src/network/stale-failure.ts +4 -1
- package/src/testing/test-transactor.ts +34 -3
- package/src/transaction/coordinator.ts +5 -1
- package/src/transactor/network-transactor.ts +237 -37
- package/src/transactor/transactor-source.ts +39 -2
- 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,
|
|
586
|
+
if (error) { // If any failures, discharge every pending record this pend left behind
|
|
588
587
|
log('pend:cancel actionId=%s', blockAction.actionId);
|
|
589
|
-
|
|
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
|
-
|
|
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
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
|
|
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
|
-
/**
|
|
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
|
|
927
|
-
|
|
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
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
}
|