@optimystic/db-core 0.14.0 → 0.16.2
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/blocks/block-store.d.ts +18 -1
- package/dist/src/blocks/block-store.d.ts.map +1 -1
- package/dist/src/blocks/helpers.d.ts +2 -2
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +2 -2
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/btree/btree.d.ts +27 -6
- package/dist/src/btree/btree.d.ts.map +1 -1
- package/dist/src/btree/btree.js +104 -61
- package/dist/src/btree/btree.js.map +1 -1
- package/dist/src/chain/chain.d.ts.map +1 -1
- package/dist/src/chain/chain.js +11 -1
- package/dist/src/chain/chain.js.map +1 -1
- package/dist/src/cluster/index.d.ts +1 -0
- package/dist/src/cluster/index.d.ts.map +1 -1
- package/dist/src/cluster/index.js +1 -0
- package/dist/src/cluster/index.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +49 -0
- package/dist/src/cluster/membership.d.ts.map +1 -0
- package/dist/src/cluster/membership.js +75 -0
- package/dist/src/cluster/membership.js.map +1 -0
- package/dist/src/cluster/structs.d.ts +50 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +9 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/cohort-topic/addressing.d.ts +8 -2
- package/dist/src/cohort-topic/addressing.d.ts.map +1 -1
- package/dist/src/cohort-topic/addressing.js +11 -3
- package/dist/src/cohort-topic/addressing.js.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts +13 -7
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.js +17 -6
- package/dist/src/cohort-topic/antidos/bootstrap-evidence.js.map +1 -1
- package/dist/src/cohort-topic/antidos/replay-guard.d.ts +17 -0
- package/dist/src/cohort-topic/antidos/replay-guard.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/replay-guard.js +33 -0
- package/dist/src/cohort-topic/antidos/replay-guard.js.map +1 -1
- package/dist/src/cohort-topic/antidos/topic-budget.d.ts +7 -0
- package/dist/src/cohort-topic/antidos/topic-budget.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/topic-budget.js +8 -2
- package/dist/src/cohort-topic/antidos/topic-budget.js.map +1 -1
- package/dist/src/cohort-topic/coldstart.d.ts +34 -14
- package/dist/src/cohort-topic/coldstart.d.ts.map +1 -1
- package/dist/src/cohort-topic/coldstart.js +25 -11
- package/dist/src/cohort-topic/coldstart.js.map +1 -1
- package/dist/src/cohort-topic/gossip/bus.d.ts +12 -1
- package/dist/src/cohort-topic/gossip/bus.d.ts.map +1 -1
- package/dist/src/cohort-topic/gossip/bus.js +27 -1
- package/dist/src/cohort-topic/gossip/bus.js.map +1 -1
- package/dist/src/cohort-topic/gossip/view.d.ts +8 -0
- package/dist/src/cohort-topic/gossip/view.d.ts.map +1 -1
- package/dist/src/cohort-topic/gossip/view.js +17 -1
- package/dist/src/cohort-topic/gossip/view.js.map +1 -1
- package/dist/src/cohort-topic/member-engine.d.ts +10 -3
- package/dist/src/cohort-topic/member-engine.d.ts.map +1 -1
- package/dist/src/cohort-topic/member-engine.js +29 -14
- package/dist/src/cohort-topic/member-engine.js.map +1 -1
- package/dist/src/cohort-topic/membership/publisher.d.ts +14 -7
- package/dist/src/cohort-topic/membership/publisher.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/publisher.js +15 -27
- package/dist/src/cohort-topic/membership/publisher.js.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.d.ts +35 -0
- package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.js +88 -3
- package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
- package/dist/src/cohort-topic/promotion.d.ts +7 -0
- package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
- package/dist/src/cohort-topic/promotion.js +16 -2
- package/dist/src/cohort-topic/promotion.js.map +1 -1
- package/dist/src/cohort-topic/registration/renewal.d.ts +18 -0
- package/dist/src/cohort-topic/registration/renewal.d.ts.map +1 -1
- package/dist/src/cohort-topic/registration/renewal.js +54 -0
- package/dist/src/cohort-topic/registration/renewal.js.map +1 -1
- package/dist/src/cohort-topic/registration/types.d.ts +12 -0
- package/dist/src/cohort-topic/registration/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/registration/types.js +14 -0
- package/dist/src/cohort-topic/registration/types.js.map +1 -1
- package/dist/src/cohort-topic/service.d.ts +4 -3
- package/dist/src/cohort-topic/service.d.ts.map +1 -1
- package/dist/src/cohort-topic/service.js +38 -12
- package/dist/src/cohort-topic/service.js.map +1 -1
- package/dist/src/cohort-topic/sig/payloads.d.ts +23 -7
- package/dist/src/cohort-topic/sig/payloads.d.ts.map +1 -1
- package/dist/src/cohort-topic/sig/payloads.js +22 -6
- package/dist/src/cohort-topic/sig/payloads.js.map +1 -1
- package/dist/src/cohort-topic/traffic.d.ts +6 -0
- package/dist/src/cohort-topic/traffic.d.ts.map +1 -1
- package/dist/src/cohort-topic/traffic.js +16 -1
- package/dist/src/cohort-topic/traffic.js.map +1 -1
- package/dist/src/cohort-topic/walk.d.ts +22 -7
- package/dist/src/cohort-topic/walk.d.ts.map +1 -1
- package/dist/src/cohort-topic/walk.js +78 -18
- package/dist/src/cohort-topic/walk.js.map +1 -1
- package/dist/src/cohort-topic/wire/codec.d.ts +3 -1
- package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/codec.js +7 -1
- package/dist/src/cohort-topic/wire/codec.js.map +1 -1
- package/dist/src/cohort-topic/wire/index.d.ts +1 -1
- package/dist/src/cohort-topic/wire/index.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/index.js +1 -1
- package/dist/src/cohort-topic/wire/index.js.map +1 -1
- package/dist/src/cohort-topic/wire/payloads.d.ts +5 -4
- package/dist/src/cohort-topic/wire/payloads.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/payloads.js +14 -5
- package/dist/src/cohort-topic/wire/payloads.js.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.d.ts +65 -0
- package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -0
- package/dist/src/cohort-topic/wire/primitives.js +173 -0
- package/dist/src/cohort-topic/wire/primitives.js.map +1 -0
- package/dist/src/cohort-topic/wire/types.d.ts +132 -5
- package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.d.ts +15 -5
- package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.js +146 -123
- package/dist/src/cohort-topic/wire/validate.js.map +1 -1
- package/dist/src/collection/collection.d.ts +23 -12
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +111 -35
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +37 -2
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +16 -1
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +18 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +36 -6
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/log/log.d.ts +26 -3
- package/dist/src/log/log.d.ts.map +1 -1
- package/dist/src/log/log.js +34 -7
- package/dist/src/log/log.js.map +1 -1
- package/dist/src/log/struct.d.ts +5 -4
- package/dist/src/log/struct.d.ts.map +1 -1
- package/dist/src/log/struct.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts +2 -1
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +13 -92
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/i-key-network.d.ts +9 -0
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/i-repo.d.ts +8 -0
- package/dist/src/network/i-repo.d.ts.map +1 -1
- package/dist/src/network/struct.d.ts +10 -0
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/reactivity/push-state.d.ts.map +1 -1
- package/dist/src/reactivity/push-state.js +4 -33
- package/dist/src/reactivity/push-state.js.map +1 -1
- package/dist/src/reactivity/wire-validate.d.ts +7 -22
- package/dist/src/reactivity/wire-validate.d.ts.map +1 -1
- package/dist/src/reactivity/wire-validate.js +7 -73
- package/dist/src/reactivity/wire-validate.js.map +1 -1
- package/dist/src/reactivity/wire.js +3 -3
- package/dist/src/reactivity/wire.js.map +1 -1
- package/dist/src/testing/async-wait.d.ts +23 -0
- package/dist/src/testing/async-wait.d.ts.map +1 -0
- package/dist/src/testing/async-wait.js +42 -0
- package/dist/src/testing/async-wait.js.map +1 -0
- package/dist/src/testing/index.d.ts +3 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +3 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/test-transactor.d.ts +44 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -0
- package/dist/src/testing/test-transactor.js +423 -0
- package/dist/src/testing/test-transactor.js.map +1 -0
- package/dist/src/transaction/actions-engine.d.ts +6 -3
- package/dist/src/transaction/actions-engine.d.ts.map +1 -1
- package/dist/src/transaction/actions-engine.js +8 -7
- package/dist/src/transaction/actions-engine.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +43 -22
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +443 -178
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +82 -0
- package/dist/src/transaction/errors.d.ts.map +1 -0
- package/dist/src/transaction/errors.js +93 -0
- package/dist/src/transaction/errors.js.map +1 -0
- package/dist/src/transaction/index.d.ts +6 -3
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js +4 -2
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +119 -0
- package/dist/src/transaction/operations-hash.d.ts.map +1 -0
- package/dist/src/transaction/operations-hash.js +180 -0
- package/dist/src/transaction/operations-hash.js.map +1 -0
- package/dist/src/transaction/read-dependency-collector.d.ts +43 -0
- package/dist/src/transaction/read-dependency-collector.d.ts.map +1 -0
- package/dist/src/transaction/read-dependency-collector.js +69 -0
- package/dist/src/transaction/read-dependency-collector.js.map +1 -0
- package/dist/src/transaction/session.d.ts +12 -3
- package/dist/src/transaction/session.d.ts.map +1 -1
- package/dist/src/transaction/session.js +35 -9
- package/dist/src/transaction/session.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +162 -1
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transaction/transaction.js +85 -3
- package/dist/src/transaction/transaction.js.map +1 -1
- package/dist/src/transaction/validator.d.ts +3 -11
- package/dist/src/transaction/validator.d.ts.map +1 -1
- package/dist/src/transaction/validator.js +69 -28
- package/dist/src/transaction/validator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +35 -0
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +146 -40
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +19 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +43 -11
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/atomic-proxy.d.ts +35 -5
- package/dist/src/transform/atomic-proxy.d.ts.map +1 -1
- package/dist/src/transform/atomic-proxy.js +39 -7
- package/dist/src/transform/atomic-proxy.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +36 -5
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +84 -8
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/helpers.d.ts.map +1 -1
- package/dist/src/transform/helpers.js +8 -2
- package/dist/src/transform/helpers.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +17 -2
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +66 -11
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/backoff.d.ts +48 -0
- package/dist/src/utility/backoff.d.ts.map +1 -0
- package/dist/src/utility/backoff.js +60 -0
- package/dist/src/utility/backoff.js.map +1 -0
- package/dist/src/utility/pending.d.ts +1 -0
- package/dist/src/utility/pending.d.ts.map +1 -1
- package/dist/src/utility/pending.js +3 -1
- package/dist/src/utility/pending.js.map +1 -1
- package/package.json +4 -4
- package/src/blocks/block-store.ts +19 -1
- package/src/blocks/helpers.ts +3 -3
- package/src/btree/btree.ts +108 -63
- package/src/chain/chain.ts +11 -1
- package/src/cluster/index.ts +1 -0
- package/src/cluster/membership.ts +85 -0
- package/src/cluster/structs.ts +51 -1
- package/src/cohort-topic/addressing.ts +12 -4
- package/src/cohort-topic/antidos/bootstrap-evidence.ts +21 -9
- package/src/cohort-topic/antidos/replay-guard.ts +37 -0
- package/src/cohort-topic/antidos/topic-budget.ts +17 -2
- package/src/cohort-topic/coldstart.ts +48 -15
- package/src/cohort-topic/gossip/bus.ts +39 -2
- package/src/cohort-topic/gossip/view.ts +26 -1
- package/src/cohort-topic/member-engine.ts +36 -15
- package/src/cohort-topic/membership/publisher.ts +25 -33
- package/src/cohort-topic/membership/verifier.ts +119 -3
- package/src/cohort-topic/promotion.ts +23 -2
- package/src/cohort-topic/registration/renewal.ts +73 -1
- package/src/cohort-topic/registration/types.ts +15 -0
- package/src/cohort-topic/service.ts +41 -15
- package/src/cohort-topic/sig/payloads.ts +29 -9
- package/src/cohort-topic/traffic.ts +23 -1
- package/src/cohort-topic/walk.ts +95 -24
- package/src/cohort-topic/wire/codec.ts +12 -0
- package/src/cohort-topic/wire/index.ts +2 -0
- package/src/cohort-topic/wire/payloads.ts +14 -5
- package/src/cohort-topic/wire/primitives.ts +188 -0
- package/src/cohort-topic/wire/types.ts +136 -4
- package/src/cohort-topic/wire/validate.ts +168 -134
- package/src/collection/collection.ts +123 -40
- package/src/collection/struct.ts +44 -2
- package/src/collections/tree/tree.ts +42 -5
- package/src/index.ts +1 -0
- package/src/log/log.ts +37 -9
- package/src/log/struct.ts +5 -4
- package/src/matchmaking/wire.ts +25 -102
- package/src/network/i-key-network.ts +10 -0
- package/src/network/i-repo.ts +8 -0
- package/src/network/struct.ts +10 -0
- package/src/reactivity/push-state.ts +4 -37
- package/src/reactivity/wire-validate.ts +7 -81
- package/src/reactivity/wire.ts +3 -3
- package/src/testing/async-wait.ts +65 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/test-transactor.ts +468 -0
- package/src/transaction/actions-engine.ts +8 -6
- package/src/transaction/coordinator.ts +498 -229
- package/src/transaction/errors.ts +91 -0
- package/src/transaction/index.ts +23 -3
- package/src/transaction/operations-hash.ts +196 -0
- package/src/transaction/read-dependency-collector.ts +78 -0
- package/src/transaction/session.ts +37 -9
- package/src/transaction/transaction.ts +211 -4
- package/src/transaction/validator.ts +73 -48
- package/src/transactor/network-transactor.ts +164 -49
- package/src/transactor/transactor-source.ts +47 -12
- package/src/transform/atomic-proxy.ts +92 -49
- package/src/transform/cache-source.ts +87 -9
- package/src/transform/helpers.ts +8 -2
- package/src/transform/tracker.ts +69 -11
- package/src/utility/backoff.ts +95 -0
- package/src/utility/pending.ts +3 -1
- package/src/transaction/context.ts +0 -103
|
@@ -1,8 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { isTransactionExpired, clampPriority } from "./transaction.js";
|
|
2
|
+
import { Log, blockIdsForTransforms } from "../index.js";
|
|
3
|
+
import { collectOperations, hashOperations } from "./operations-hash.js";
|
|
4
|
+
import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
|
|
5
|
+
import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
|
|
4
6
|
import { createLogger } from "../logger.js";
|
|
5
7
|
const log = createLogger('trx:coordinator');
|
|
8
|
+
/** Default max consecutive clean-stale-loss retries before {@link TransactionCoordinator.commit}
|
|
9
|
+
* gives up. Mirrors the single-collection sync default so the two retry loops share one policy. */
|
|
10
|
+
const DefaultMaxAttempts = 10;
|
|
11
|
+
/** Default base backoff (ms) before the first commit retry. */
|
|
12
|
+
const DefaultBaseBackoffMs = 100;
|
|
13
|
+
/** Default ceiling (ms) on a single commit-retry backoff sleep. */
|
|
14
|
+
const DefaultMaxBackoffMs = 5000;
|
|
15
|
+
/**
|
|
16
|
+
* A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision (a
|
|
17
|
+
* committed `missing` action newer than our rev, or a `pending` action on a touched block) that a
|
|
18
|
+
* re-read + re-pend can clear. A bare rejection reason (storage full, policy) is NOT a conflict and
|
|
19
|
+
* is not worth re-driving. Thrown by {@link TransactionCoordinator.pendCollection} so the fan-out in
|
|
20
|
+
* pendPhase can settle it and read the flag off the rejection.
|
|
21
|
+
*/
|
|
22
|
+
class PendRejectedError extends Error {
|
|
23
|
+
conflict;
|
|
24
|
+
constructor(collectionId, conflict, reason) {
|
|
25
|
+
super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`);
|
|
26
|
+
this.conflict = conflict;
|
|
27
|
+
this.name = 'PendRejectedError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
6
30
|
/**
|
|
7
31
|
* Coordinates multi-collection transactions.
|
|
8
32
|
*
|
|
@@ -65,19 +89,102 @@ export class TransactionCoordinator {
|
|
|
65
89
|
}
|
|
66
90
|
}
|
|
67
91
|
/**
|
|
68
|
-
* Commit a transaction
|
|
92
|
+
* Commit a transaction with a bounded, jittered backoff retry around a CLEAN stale loss.
|
|
93
|
+
*
|
|
94
|
+
* The single-attempt work lives in {@link commitOnce}; this wrapper re-drives it when the attempt
|
|
95
|
+
* fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} — nothing
|
|
96
|
+
* durably committed, every tracker restored to its pre-append state). Before each re-attempt it
|
|
97
|
+
* re-reads each collection to fresh revisions (so the retry pends against current state rather
|
|
98
|
+
* than immediately re-failing stale), then backs off with the same jitter policy as
|
|
99
|
+
* {@link Collection.sync}. Retry is bounded by `maxAttempts` and an optional wall-clock
|
|
100
|
+
* `deadlineMs`, and honours an abort `signal`.
|
|
101
|
+
*
|
|
102
|
+
* A {@link CoordinatorPartialCommitError} (a partial landing — some collection durably committed)
|
|
103
|
+
* is NOT retryable and escapes immediately: blindly retrying would re-log already-durable actions.
|
|
104
|
+
* Any other failure (expired transaction, unavailable transactor, unreachable cluster) also
|
|
105
|
+
* propagates without retry — only genuine clean stale losses are re-driven.
|
|
106
|
+
*
|
|
107
|
+
* Defaults are safe out of the box: a caller that passes no options gets bounded, jittered retry.
|
|
108
|
+
*
|
|
109
|
+
* @param transaction - The transaction to commit
|
|
110
|
+
* @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
|
|
111
|
+
*/
|
|
112
|
+
async commit(transaction, options) {
|
|
113
|
+
const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
|
|
114
|
+
const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
|
|
115
|
+
const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
|
|
116
|
+
const deadlineMs = options?.deadlineMs;
|
|
117
|
+
const signal = options?.signal;
|
|
118
|
+
const startedAt = Date.now();
|
|
119
|
+
// Count of consecutive clean stale losses. There is no forward-progress notion here (a
|
|
120
|
+
// single commit either lands or it does not), so this simply bounds how many times we
|
|
121
|
+
// re-drive a losing transaction before surfacing a terminal error.
|
|
122
|
+
let staleLosses = 0;
|
|
123
|
+
let lastLoss;
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (signal?.aborted) {
|
|
126
|
+
throw makeAbortError(signal);
|
|
127
|
+
}
|
|
128
|
+
// Progress-agnostic ceiling: once we've taken at least one loss, give up if the
|
|
129
|
+
// wall-clock deadline passed (independent of the attempt cap).
|
|
130
|
+
if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
|
|
131
|
+
throw lastLoss;
|
|
132
|
+
}
|
|
133
|
+
// Age the transaction's advisory priority by the number of losses taken so far, so a
|
|
134
|
+
// repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
|
|
135
|
+
// resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
|
|
136
|
+
// signature, so bumping it here does not churn identity. Left untouched on the first
|
|
137
|
+
// attempt (staleLosses == 0) so the initial pend serializes exactly as before.
|
|
138
|
+
if (staleLosses > 0) {
|
|
139
|
+
transaction.priority = clampPriority(staleLosses);
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await this.commitOnce(transaction);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
// Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
|
|
147
|
+
// unavailable transactor, etc. all propagate unchanged.
|
|
148
|
+
if (!(err instanceof CoordinatorStaleLossError)) {
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
lastLoss = err;
|
|
152
|
+
staleLosses++;
|
|
153
|
+
if (staleLosses >= maxAttempts) {
|
|
154
|
+
throw err;
|
|
155
|
+
}
|
|
156
|
+
const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
|
|
157
|
+
await abortableDelay(delay, signal);
|
|
158
|
+
// Re-read fresh state before re-attempting so the next commit pends against current
|
|
159
|
+
// revisions (mirrors how Collection.sync calls updateInternal() before retrying).
|
|
160
|
+
// NOTE: refreshes EVERY registered collection, not only the participants of this
|
|
161
|
+
// transaction. Harmless (a non-participant's update() just fetches latest) and the
|
|
162
|
+
// registered set is small today; if a coordinator ever holds many collections and this
|
|
163
|
+
// shows up as retry latency, narrow it to the transaction's participating collections.
|
|
164
|
+
for (const collection of this.collections.values()) {
|
|
165
|
+
await collection.update();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Commit a transaction (single attempt): materialise a log entry from each collection's staged
|
|
69
172
|
* pending actions, then orchestrate the distributed consensus (GATHER/PEND/COMMIT).
|
|
70
173
|
*
|
|
71
|
-
* Called by
|
|
174
|
+
* Called by {@link commit} (which wraps it in the backoff+jitter retry loop). The
|
|
72
175
|
* staged mutations already live in each collection's tracker — applied either via
|
|
73
176
|
* applyActions() (engine-driven path) or directly via Collection.act()/Tree.stage
|
|
74
177
|
* (the vtab's deferred-DML path) — but in BOTH cases without a log entry yet, so
|
|
75
178
|
* this method appends that entry here (see the inline note below) before pending,
|
|
76
179
|
* and folds the committed transforms back into each collection's read cache.
|
|
77
180
|
*
|
|
181
|
+
* On a clean stale loss (nothing durable, every tracker restored) it throws
|
|
182
|
+
* {@link CoordinatorStaleLossError} so the caller can retry; on a partial landing it throws
|
|
183
|
+
* {@link CoordinatorPartialCommitError} (not retryable).
|
|
184
|
+
*
|
|
78
185
|
* @param transaction - The transaction to commit
|
|
79
186
|
*/
|
|
80
|
-
async
|
|
187
|
+
async commitOnce(transaction) {
|
|
81
188
|
if (isTransactionExpired(transaction.stamp)) {
|
|
82
189
|
throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
|
|
83
190
|
}
|
|
@@ -109,25 +216,95 @@ export class TransactionCoordinator {
|
|
|
109
216
|
const allCollectionIds = collectionData.map(({ collectionId }) => collectionId);
|
|
110
217
|
const collectionTransforms = new Map();
|
|
111
218
|
const criticalBlocks = new Map();
|
|
219
|
+
// Snapshot EVERY participating collection's staged state (transforms + pending
|
|
220
|
+
// queue) BEFORE the append loop mutates any tracker. The loop appends log
|
|
221
|
+
// entries sequentially, so a failure on the Nth collection must also undo the
|
|
222
|
+
// 0..N-1 collections that already appended — and coordinateTransaction can fail
|
|
223
|
+
// after ALL of them appended. On any throw below we restore every snapshot, so a
|
|
224
|
+
// failed commit leaves each tracker exactly as it was: a retry re-appends cleanly
|
|
225
|
+
// (no duplicate log entry) and a directly-staged tree's rollback (which no-ops
|
|
226
|
+
// when the stamp was never tracked via applyActions) has nothing poisoned to undo.
|
|
227
|
+
const preCommitSnapshots = new Map();
|
|
112
228
|
for (const { collectionId, collection } of collectionData) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
229
|
+
preCommitSnapshots.set(collectionId, collection.snapshotPending());
|
|
230
|
+
}
|
|
231
|
+
let coordResult;
|
|
232
|
+
try {
|
|
233
|
+
for (const { collectionId, collection } of collectionData) {
|
|
234
|
+
const applyResult = await this.applyActionsToCollection({ collectionId, actions: collection.getPendingActions() }, transaction, allCollectionIds);
|
|
235
|
+
if (!applyResult.success) {
|
|
236
|
+
throw new Error(`Transaction commit failed: ${applyResult.error}`);
|
|
237
|
+
}
|
|
238
|
+
collectionTransforms.set(collectionId, applyResult.transforms);
|
|
239
|
+
criticalBlocks.set(collectionId, applyResult.logTailBlockId);
|
|
116
240
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
241
|
+
// Compute hash of ALL operations across ALL collections (post-log-append).
|
|
242
|
+
// Validators re-execute the transaction and compare their computed hash.
|
|
243
|
+
// The shared operations-hash module canonicalises (sort + canonical JSON) so
|
|
244
|
+
// this order-independent fingerprint matches what a validator recomputes.
|
|
245
|
+
const operationsHash = await hashOperations(collectOperations(collectionTransforms));
|
|
246
|
+
// Execute consensus phases (GATHER, PEND, COMMIT)
|
|
247
|
+
coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
// A throw here means the failure happened BEFORE any collection could
|
|
251
|
+
// durably commit (a log-append failure, or coordinateTransaction rejecting
|
|
252
|
+
// unexpectedly). Nothing landed on the cluster, so roll every tracker back
|
|
253
|
+
// to its pre-append snapshot — a genuinely clean rollback that leaves each
|
|
254
|
+
// tracker pristine for retry (see txn-failed-commit-leaves-staged-log-entry).
|
|
255
|
+
for (const { collectionId, collection } of collectionData) {
|
|
256
|
+
collection.restorePending(preCommitSnapshots.get(collectionId));
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
130
260
|
if (!coordResult.success) {
|
|
261
|
+
const committed = coordResult.committedCollections ?? new Set();
|
|
262
|
+
if (committed.size > 0) {
|
|
263
|
+
// PARTIAL COMMIT: at least one collection durably committed via consensus
|
|
264
|
+
// while another failed permanently. A uniform pre-append restore would
|
|
265
|
+
// corrupt the committed half — re-staging its already-durable actions as
|
|
266
|
+
// still-pending, so tracker memory would disagree with cluster storage.
|
|
267
|
+
// Split the local handling instead:
|
|
268
|
+
for (const { collectionId, collection } of collectionData) {
|
|
269
|
+
if (committed.has(collectionId)) {
|
|
270
|
+
// Committed → the success-path local treatment (see below): fold the
|
|
271
|
+
// committed transforms into the read cache BEFORE resetting the tracker,
|
|
272
|
+
// then drop the now-durable pending actions so a retry cannot re-log them.
|
|
273
|
+
// NOTE: no-double-apply on retry depends on clearPendingActions() running for
|
|
274
|
+
// EVERY committed collection here before any re-drive of commit(). If a committed
|
|
275
|
+
// collection kept its pending queue, a subsequent commit() would re-append and
|
|
276
|
+
// re-log its already-durable actions — a duplicate log entry on the winner. The
|
|
277
|
+
// no-double-apply-on-retry test in transaction.spec.ts locks this.
|
|
278
|
+
const rev = collection.recordCommitted(transaction.id);
|
|
279
|
+
collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
|
|
280
|
+
collection.tracker.reset();
|
|
281
|
+
collection.clearPendingActions();
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
// Failed / never-committed → restore the pre-append snapshot so a retry
|
|
285
|
+
// re-appends cleanly (no duplicate log entry).
|
|
286
|
+
collection.restorePending(preCommitSnapshots.get(collectionId));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// The transaction half-landed, so it is neither cleanly retryable nor
|
|
290
|
+
// cleanly abortable: drop its stamp tracking (the success path does the
|
|
291
|
+
// same at the end) and surface the structured signal for reconciliation.
|
|
292
|
+
this.stampData.delete(transaction.stamp.id);
|
|
293
|
+
throw new CoordinatorPartialCommitError([...committed], [...(coordResult.failedCollections ?? new Set())], coordResult.error);
|
|
294
|
+
}
|
|
295
|
+
// EMPTY committed set: PEND failed, or the whole commit failed cleanly with
|
|
296
|
+
// nothing durable. Restore every tracker so each is pristine for retry.
|
|
297
|
+
for (const { collectionId, collection } of collectionData) {
|
|
298
|
+
collection.restorePending(preCommitSnapshots.get(collectionId));
|
|
299
|
+
}
|
|
300
|
+
// Distinguish a genuine optimistic-concurrency conflict (a stale loss / pending
|
|
301
|
+
// contention — retryable after a re-read) from a hard failure (unavailable transactor,
|
|
302
|
+
// storage rejection, expired). Only the former is worth re-driving; the retry wrapper in
|
|
303
|
+
// commit() catches CoordinatorStaleLossError and re-attempts, while a plain Error escapes
|
|
304
|
+
// immediately (preserving the historical fail-fast behaviour for hard failures).
|
|
305
|
+
if (coordResult.staleLoss) {
|
|
306
|
+
throw new CoordinatorStaleLossError([...(coordResult.failedCollections ?? new Set(allCollectionIds))], coordResult.error);
|
|
307
|
+
}
|
|
131
308
|
throw new Error(`Transaction commit failed: ${coordResult.error}`);
|
|
132
309
|
}
|
|
133
310
|
// Advance actionContext, fold the committed transforms into each
|
|
@@ -138,15 +315,8 @@ export class TransactionCoordinator {
|
|
|
138
315
|
// serves the new revision instead of the stale cached one. Clearing
|
|
139
316
|
// pending keeps a subsequent commit from re-logging these actions.
|
|
140
317
|
for (const { collectionId, collection } of collectionData) {
|
|
141
|
-
const
|
|
142
|
-
collection
|
|
143
|
-
committed: [
|
|
144
|
-
...(collection['source'].actionContext?.committed ?? []),
|
|
145
|
-
{ actionId: transaction.id, rev: newRev }
|
|
146
|
-
],
|
|
147
|
-
rev: newRev,
|
|
148
|
-
};
|
|
149
|
-
collection.applyCommittedToCache(collectionTransforms.get(collectionId));
|
|
318
|
+
const rev = collection.recordCommitted(transaction.id);
|
|
319
|
+
collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
|
|
150
320
|
collection.tracker.reset();
|
|
151
321
|
collection.clearPendingActions();
|
|
152
322
|
}
|
|
@@ -250,51 +420,10 @@ export class TransactionCoordinator {
|
|
|
250
420
|
collection.clearReadDependencies();
|
|
251
421
|
}
|
|
252
422
|
}
|
|
253
|
-
/**
|
|
254
|
-
* Compute hash of all operations in a transaction.
|
|
255
|
-
* This hash is used for validation - validators re-execute the transaction
|
|
256
|
-
* and compare their computed operations hash with this one.
|
|
257
|
-
*/
|
|
258
|
-
async hashOperations(operations) {
|
|
259
|
-
const operationsData = JSON.stringify(operations);
|
|
260
|
-
return `ops:${await hashString(operationsData)}`;
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Commit a transaction context.
|
|
264
|
-
*
|
|
265
|
-
* @deprecated Use TransactionSession instead of TransactionContext
|
|
266
|
-
* This is called by TransactionContext.commit().
|
|
267
|
-
*
|
|
268
|
-
* @param context - The transaction context to commit
|
|
269
|
-
* @returns Execution result with actions and results
|
|
270
|
-
*/
|
|
271
|
-
async commitTransaction(context) {
|
|
272
|
-
const collectionActions = Array.from(context.getCollectionActions().entries()).map(([collectionId, actions]) => ({ collectionId, actions }));
|
|
273
|
-
if (collectionActions.length === 0) {
|
|
274
|
-
return { success: true }; // Nothing to commit
|
|
275
|
-
}
|
|
276
|
-
// Create transaction statements
|
|
277
|
-
const statements = createActionsStatements(collectionActions);
|
|
278
|
-
const reads = context.getReads();
|
|
279
|
-
// Create stamp from context
|
|
280
|
-
const stamp = await createTransactionStamp('local', // TODO: Get from context or coordinator
|
|
281
|
-
Date.now(), '', // TODO: Get from engine
|
|
282
|
-
context.engine);
|
|
283
|
-
const transaction = {
|
|
284
|
-
stamp,
|
|
285
|
-
statements,
|
|
286
|
-
reads,
|
|
287
|
-
id: await createTransactionId(stamp.id, statements, reads)
|
|
288
|
-
};
|
|
289
|
-
const engine = new ActionsEngine(this);
|
|
290
|
-
// Execute through standard path
|
|
291
|
-
return await this.execute(transaction, engine);
|
|
292
|
-
}
|
|
293
423
|
/**
|
|
294
424
|
* Execute a fully-formed transaction.
|
|
295
425
|
*
|
|
296
|
-
* This
|
|
297
|
-
* or indirectly via commitTransaction().
|
|
426
|
+
* This is called with a complete transaction (e.g., from Quereus).
|
|
298
427
|
*
|
|
299
428
|
* @param transaction - The transaction to execute
|
|
300
429
|
* @param engine - The engine to use for executing the transaction
|
|
@@ -319,7 +448,41 @@ export class TransactionCoordinator {
|
|
|
319
448
|
if (!result.actions || result.actions.length === 0) {
|
|
320
449
|
return { success: true }; // Nothing to do
|
|
321
450
|
}
|
|
322
|
-
//
|
|
451
|
+
// 1b. Stage the returned actions into the collection trackers.
|
|
452
|
+
//
|
|
453
|
+
// Reaching here means the engine RETURNED non-empty actions — i.e. the pure-
|
|
454
|
+
// translator model (see the ITransactionEngine contract): it translated the
|
|
455
|
+
// statements but did NOT apply them. So THIS path owns application — we stage the
|
|
456
|
+
// actions here via applyActions() (which also snapshots/tracks the stamp for
|
|
457
|
+
// rollback) BEFORE the loop below reads each tracker's transforms to materialise
|
|
458
|
+
// the log entry. (Previously ActionsEngine applied as a side effect and this
|
|
459
|
+
// method merely re-read the already-staged trackers; that side effect is gone, so
|
|
460
|
+
// the application must happen explicitly here. A side-effecting engine that
|
|
461
|
+
// applied internally would instead return EMPTY actions and short-circuit at the
|
|
462
|
+
// guard above.)
|
|
463
|
+
//
|
|
464
|
+
// applyActions() throws if a referenced collection is not registered — the same
|
|
465
|
+
// "Collection not found" the engine's side-effecting apply used to surface. Convert
|
|
466
|
+
// it back into a failure result so execute() keeps its return contract.
|
|
467
|
+
try {
|
|
468
|
+
await this.applyActions(result.actions, transaction.stamp.id);
|
|
469
|
+
}
|
|
470
|
+
catch (error) {
|
|
471
|
+
const engineMs = Date.now() - tEngine;
|
|
472
|
+
log('execute:done trxId=%s engine=%dms apply-failed=true total=%dms', trxId, engineMs, Date.now() - t0);
|
|
473
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
474
|
+
}
|
|
475
|
+
// 2. Build a log entry per collection from the now-staged tracker transforms.
|
|
476
|
+
//
|
|
477
|
+
// NOTE: like commit(), this loop appends a log entry into each collection's
|
|
478
|
+
// tracker and these failure returns do NOT restore that state — so a partially
|
|
479
|
+
// applied engine transaction leaves appended-but-uncommitted entries in the
|
|
480
|
+
// trackers. This is deliberately NOT snapshot/restore-wrapped the way commit()
|
|
481
|
+
// is, because execute()'s asymmetry makes it lower risk: it is not the retryable
|
|
482
|
+
// session.commit() entry point (a failed execute() is not re-driven through the
|
|
483
|
+
// same loop), and its actions were tracked via applyActions() so rollback(stampId)
|
|
484
|
+
// CAN unwind them (unlike commit()'s directly-staged path). If execute() ever
|
|
485
|
+
// becomes retryable, mirror the commit() snapshot/restore fix here.
|
|
323
486
|
const tApply = Date.now();
|
|
324
487
|
const collectionTransforms = new Map();
|
|
325
488
|
const criticalBlocks = new Map();
|
|
@@ -334,13 +497,8 @@ export class TransactionCoordinator {
|
|
|
334
497
|
criticalBlocks.set(collectionActions.collectionId, applyResult.logTailBlockId);
|
|
335
498
|
actionResults.set(collectionActions.collectionId, applyResult.results);
|
|
336
499
|
}
|
|
337
|
-
// 3. Compute operations hash for validation
|
|
338
|
-
const
|
|
339
|
-
...Object.entries(transforms.inserts ?? {}).map(([blockId, block]) => ({ type: 'insert', collectionId, blockId, block })),
|
|
340
|
-
...Object.entries(transforms.updates ?? {}).map(([blockId, operations]) => ({ type: 'update', collectionId, blockId, operations })),
|
|
341
|
-
...(transforms.deletes ?? []).map(blockId => ({ type: 'delete', collectionId, blockId }))
|
|
342
|
-
]);
|
|
343
|
-
const operationsHash = await this.hashOperations(allOperations);
|
|
500
|
+
// 3. Compute operations hash for validation (order-independent; see commit()).
|
|
501
|
+
const operationsHash = await hashOperations(collectOperations(collectionTransforms));
|
|
344
502
|
const applyMs = Date.now() - tApply;
|
|
345
503
|
// 4. Coordinate (GATHER if multi-collection)
|
|
346
504
|
const tCoord = Date.now();
|
|
@@ -348,21 +506,33 @@ export class TransactionCoordinator {
|
|
|
348
506
|
const coordMs = Date.now() - tCoord;
|
|
349
507
|
if (!coordResult.success) {
|
|
350
508
|
log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
|
|
351
|
-
|
|
509
|
+
// Stop lying to the caller about a partial commit: if some collections durably
|
|
510
|
+
// committed, surface that set. execute() is not snapshot/restore-wrapped (see the
|
|
511
|
+
// note above), but the committed subset must still get the success-path local
|
|
512
|
+
// treatment (recordCommitted + tracker.reset, as on the success path below) so its
|
|
513
|
+
// trackers aren't left mis-tracking already-durable state.
|
|
514
|
+
const committed = coordResult.committedCollections ?? new Set();
|
|
515
|
+
if (committed.size > 0) {
|
|
516
|
+
for (const collectionActions of result.actions) {
|
|
517
|
+
const collection = this.collections.get(collectionActions.collectionId);
|
|
518
|
+
if (collection && committed.has(collectionActions.collectionId)) {
|
|
519
|
+
collection.recordCommitted(transaction.id);
|
|
520
|
+
collection.tracker.reset();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
success: false,
|
|
526
|
+
error: coordResult.error,
|
|
527
|
+
committedCollections: committed.size > 0 ? [...committed] : undefined,
|
|
528
|
+
failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
|
|
529
|
+
};
|
|
352
530
|
}
|
|
353
531
|
// 5. Update actionContext and reset trackers after successful commit
|
|
354
532
|
for (const collectionActions of result.actions) {
|
|
355
533
|
const collection = this.collections.get(collectionActions.collectionId);
|
|
356
534
|
if (collection) {
|
|
357
|
-
|
|
358
|
-
const actionId = transaction.id;
|
|
359
|
-
collection['source'].actionContext = {
|
|
360
|
-
committed: [
|
|
361
|
-
...(collection['source'].actionContext?.committed ?? []),
|
|
362
|
-
{ actionId, rev: newRev }
|
|
363
|
-
],
|
|
364
|
-
rev: newRev,
|
|
365
|
-
};
|
|
535
|
+
collection.recordCommitted(transaction.id);
|
|
366
536
|
collection.tracker.reset();
|
|
367
537
|
}
|
|
368
538
|
}
|
|
@@ -390,7 +560,7 @@ export class TransactionCoordinator {
|
|
|
390
560
|
};
|
|
391
561
|
}
|
|
392
562
|
// At this point, actions have already been executed through collection.act()
|
|
393
|
-
//
|
|
563
|
+
// (via the engine or the vtab's staging path). The collection's tracker
|
|
394
564
|
// already has the transforms, and the actions are in the pending buffer.
|
|
395
565
|
// Get transforms from the collection's tracker
|
|
396
566
|
const transforms = collection.tracker.transforms;
|
|
@@ -404,7 +574,7 @@ export class TransactionCoordinator {
|
|
|
404
574
|
}
|
|
405
575
|
// Generate action ID from transaction ID
|
|
406
576
|
const actionId = transaction.id;
|
|
407
|
-
const newRev =
|
|
577
|
+
const newRev = collection.getNextRev();
|
|
408
578
|
// Add actions to log (this updates the tracker with log block changes).
|
|
409
579
|
// Persist the transaction's read set on the entry so a later invalidation cascade can
|
|
410
580
|
// discover this action's read-dependents (see ActionEntry.reads). The whole transaction's
|
|
@@ -452,7 +622,16 @@ export class TransactionCoordinator {
|
|
|
452
622
|
// Targeted cancel: only cancel collections that are still pending (not already committed)
|
|
453
623
|
await this.cancelPhase(transaction.id, pendResult.pendedBlockIds, commitResult.committedCollections);
|
|
454
624
|
log('trx:phases trxId=%s gather=%dms pend=%dms commit=%dms (failed) total=%dms', trxId, gatherMs, pendMs, commitMs, Date.now() - t0);
|
|
455
|
-
|
|
625
|
+
// Surface the committed/failed partition so commit()/execute() can report which
|
|
626
|
+
// collections durably landed. A non-empty committedCollections is a PARTIAL commit:
|
|
627
|
+
// those collections cannot be rolled back and the caller must reconcile.
|
|
628
|
+
return {
|
|
629
|
+
success: false,
|
|
630
|
+
error: commitResult.error,
|
|
631
|
+
committedCollections: commitResult.committedCollections,
|
|
632
|
+
failedCollections: commitResult.failedCollections,
|
|
633
|
+
staleLoss: commitResult.staleLoss,
|
|
634
|
+
};
|
|
456
635
|
}
|
|
457
636
|
// 4. PROPAGATE and CHECKPOINT phases are handled by clusters automatically
|
|
458
637
|
// (as per user's note: "managed by each cluster, the client doesn't have to worry about them")
|
|
@@ -480,12 +659,15 @@ export class TransactionCoordinator {
|
|
|
480
659
|
// Query each critical cluster for their nominees and merge into supercluster
|
|
481
660
|
const nomineePromises = criticalBlockIds.map(blockId => this.transactor.queryClusterNominees(blockId));
|
|
482
661
|
const results = await Promise.all(nomineePromises);
|
|
483
|
-
// Merge all nominees into a single set
|
|
484
|
-
|
|
485
|
-
|
|
662
|
+
// Merge all nominees into a single set, deduped by peer identity. Each
|
|
663
|
+
// queryClusterNominees builds a fresh PeerId object per call (peerIdFromString),
|
|
664
|
+
// so a Set keyed by object reference would keep the same physical peer twice when
|
|
665
|
+
// it nominates for two critical clusters. Key by toString() to collapse duplicates.
|
|
666
|
+
const byId = results.reduce((acc, result) => {
|
|
667
|
+
result.nominees.forEach(nominee => acc.set(nominee.toString(), nominee));
|
|
486
668
|
return acc;
|
|
487
|
-
}, new
|
|
488
|
-
return
|
|
669
|
+
}, new Map());
|
|
670
|
+
return new Set(byId.values());
|
|
489
671
|
}
|
|
490
672
|
/**
|
|
491
673
|
* PEND phase: Distribute transaction to all affected block clusters.
|
|
@@ -499,44 +681,81 @@ export class TransactionCoordinator {
|
|
|
499
681
|
if (collectionTransforms.size === 0) {
|
|
500
682
|
return { success: false, error: 'No transforms to pend' };
|
|
501
683
|
}
|
|
502
|
-
const pendedBlockIds = new Map();
|
|
503
684
|
const actionId = transaction.id;
|
|
504
685
|
const nominees = superclusterNominees ? Array.from(superclusterNominees) : undefined;
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
686
|
+
// Fan out the independent per-collection pends concurrently. Each settles to a
|
|
687
|
+
// { collectionId, blockIds } on success, or rejects with the per-collection reason.
|
|
688
|
+
// NOTE: unbounded fan-out — one concurrent coordinator round-trip per collection.
|
|
689
|
+
// Transactions touch few collections today; if one ever spans very many, bound this
|
|
690
|
+
// with a concurrency limiter so peak in-flight round-trips stays sane. Same for commitPhase.
|
|
691
|
+
const outcomes = await Promise.allSettled(Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) => this.pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees)));
|
|
692
|
+
// Partition settled results: every collection that DID pend (keyed with its block
|
|
693
|
+
// ids), plus the first failure reason if any collection failed.
|
|
694
|
+
const pendedBlockIds = new Map();
|
|
695
|
+
let failure;
|
|
696
|
+
// Classify across ALL failures (mirroring commitPhase, and independent of iteration order):
|
|
697
|
+
// the pend is a retryable clean stale loss only if at least one failure was a conflicting pend
|
|
698
|
+
// (PendRejectedError.conflict) AND none was a hard failure. A single hard failure (storage/
|
|
699
|
+
// policy rejection, or a thrown/unavailable transactor) will not clear on a re-read, so
|
|
700
|
+
// re-driving it would just burn the retry budget — fail fast instead.
|
|
701
|
+
let anyConflict = false;
|
|
702
|
+
let anyHard = false;
|
|
703
|
+
for (const outcome of outcomes) {
|
|
704
|
+
if (outcome.status === 'fulfilled') {
|
|
705
|
+
pendedBlockIds.set(outcome.value.collectionId, outcome.value.blockIds);
|
|
510
706
|
}
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const pendRequest = {
|
|
515
|
-
actionId,
|
|
516
|
-
rev,
|
|
517
|
-
transforms,
|
|
518
|
-
policy: 'r', // Return policy: fail but return pending actions
|
|
519
|
-
transaction,
|
|
520
|
-
operationsHash,
|
|
521
|
-
superclusterNominees: nominees
|
|
522
|
-
};
|
|
523
|
-
// Pend the transaction
|
|
524
|
-
const pendResult = await this.transactor.pend(pendRequest);
|
|
525
|
-
if (!pendResult.success) {
|
|
526
|
-
// Cancel any already-pended collections before returning
|
|
527
|
-
for (const [, pendedBlockIdList] of pendedBlockIds.entries()) {
|
|
528
|
-
await this.transactor.cancel({ actionId, blockIds: pendedBlockIdList });
|
|
707
|
+
else {
|
|
708
|
+
if (failure === undefined) {
|
|
709
|
+
failure = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
529
710
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
711
|
+
if (outcome.reason instanceof PendRejectedError && outcome.reason.conflict)
|
|
712
|
+
anyConflict = true;
|
|
713
|
+
else
|
|
714
|
+
anyHard = true;
|
|
534
715
|
}
|
|
535
|
-
|
|
536
|
-
|
|
716
|
+
}
|
|
717
|
+
if (failure !== undefined) {
|
|
718
|
+
// Any failure aborts the whole pend. With concurrency several collections may
|
|
719
|
+
// have pended in parallel, so cancel EVERY successfully-pended collection — not
|
|
720
|
+
// only those started before the failure. Cancels are best-effort (cancelPhase
|
|
721
|
+
// swallows their errors) so they cannot mask the original pend failure.
|
|
722
|
+
await this.cancelPhase(actionId, pendedBlockIds);
|
|
723
|
+
return { success: false, error: failure, staleLoss: anyConflict && !anyHard };
|
|
537
724
|
}
|
|
538
725
|
return { success: true, pendedBlockIds };
|
|
539
726
|
}
|
|
727
|
+
/**
|
|
728
|
+
* Pend a single collection's transforms. Resolves with the collection id and its
|
|
729
|
+
* pended block ids on success; throws with a per-collection reason on failure so the
|
|
730
|
+
* fan-out in {@link pendPhase} can settle it as a rejection.
|
|
731
|
+
*/
|
|
732
|
+
async pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees) {
|
|
733
|
+
const collection = this.collections.get(collectionId);
|
|
734
|
+
if (!collection) {
|
|
735
|
+
throw new Error(`Collection not found: ${collectionId}`);
|
|
736
|
+
}
|
|
737
|
+
// Get revision from the collection's source
|
|
738
|
+
const rev = collection.getNextRev();
|
|
739
|
+
// Create pend request with transaction and operations hash for validation
|
|
740
|
+
const pendRequest = {
|
|
741
|
+
actionId,
|
|
742
|
+
rev,
|
|
743
|
+
transforms,
|
|
744
|
+
policy: 'r', // Return policy: fail but return pending actions
|
|
745
|
+
transaction,
|
|
746
|
+
operationsHash,
|
|
747
|
+
superclusterNominees: nominees
|
|
748
|
+
};
|
|
749
|
+
const pendResult = await this.transactor.pend(pendRequest);
|
|
750
|
+
if (!pendResult.success) {
|
|
751
|
+
// A committed `missing` action or a `pending` action on a touched block is an
|
|
752
|
+
// optimistic-concurrency conflict — retryable after a re-read. A bare `reason` is a hard
|
|
753
|
+
// rejection (storage/policy) that re-driving won't fix.
|
|
754
|
+
const conflict = Boolean(pendResult.missing?.length || pendResult.pending?.length);
|
|
755
|
+
throw new PendRejectedError(collectionId, conflict, pendResult.reason);
|
|
756
|
+
}
|
|
757
|
+
return { collectionId, blockIds: pendResult.blockIds };
|
|
758
|
+
}
|
|
540
759
|
/**
|
|
541
760
|
* COMMIT phase: Commit to all critical blocks with retry for transient failures.
|
|
542
761
|
*
|
|
@@ -545,60 +764,102 @@ export class TransactionCoordinator {
|
|
|
545
764
|
* Returns which collections committed vs failed so the caller can do targeted cancel.
|
|
546
765
|
*/
|
|
547
766
|
async commitPhase(actionId, criticalBlockIds, pendedBlockIds) {
|
|
767
|
+
// Fan out the independent per-collection commit-with-retry concurrently, then
|
|
768
|
+
// aggregate the committed/failed partition from the settled results.
|
|
769
|
+
const outcomes = await Promise.allSettled(Array.from(pendedBlockIds.entries()).map(([collectionId, blockIds]) => this.commitCollection(actionId, criticalBlockIds, collectionId, blockIds)));
|
|
548
770
|
const committedCollections = new Set();
|
|
549
771
|
const failedCollections = new Set();
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
772
|
+
const errors = [];
|
|
773
|
+
// Classify failures: a returned stale loss (someone committed a newer rev) is retryable after
|
|
774
|
+
// a re-read; a thrown/transient-exhausted or structural failure is not. staleLoss holds only
|
|
775
|
+
// if EVERY failure was a stale loss — a single hard failure makes the whole attempt not worth
|
|
776
|
+
// re-driving.
|
|
777
|
+
let anyStale = false;
|
|
778
|
+
let anyHard = false;
|
|
779
|
+
for (const outcome of outcomes) {
|
|
780
|
+
if (outcome.status === 'fulfilled') {
|
|
781
|
+
const { collectionId, committed, error, stale } = outcome.value;
|
|
782
|
+
if (committed) {
|
|
783
|
+
committedCollections.add(collectionId);
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
failedCollections.add(collectionId);
|
|
787
|
+
if (error)
|
|
788
|
+
errors.push(error);
|
|
789
|
+
if (stale)
|
|
790
|
+
anyStale = true;
|
|
791
|
+
else
|
|
792
|
+
anyHard = true;
|
|
793
|
+
}
|
|
561
794
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
failedCollections.add(collectionId);
|
|
568
|
-
return {
|
|
569
|
-
success: false,
|
|
570
|
-
error: `Log tail block not found for collection ${collectionId}`,
|
|
571
|
-
committedCollections,
|
|
572
|
-
failedCollections
|
|
573
|
-
};
|
|
795
|
+
else {
|
|
796
|
+
// commitCollection resolves rather than rejects, but treat any unexpected
|
|
797
|
+
// rejection as a (hard) failure so the partitioned sets stay honest.
|
|
798
|
+
errors.push(outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason));
|
|
799
|
+
anyHard = true;
|
|
574
800
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
801
|
+
}
|
|
802
|
+
if (failedCollections.size > 0 || errors.length > 0) {
|
|
803
|
+
return {
|
|
804
|
+
success: false,
|
|
805
|
+
error: errors.join('; ') || 'Commit failed',
|
|
806
|
+
committedCollections,
|
|
807
|
+
failedCollections,
|
|
808
|
+
staleLoss: anyStale && !anyHard,
|
|
581
809
|
};
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
810
|
+
}
|
|
811
|
+
return { success: true, committedCollections, failedCollections };
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Commit a single collection's pended blocks, retrying transient failures up to three
|
|
815
|
+
* times (forward recovery). Always resolves — success is carried in the returned
|
|
816
|
+
* `committed` flag — so the fan-out in {@link commitPhase} can aggregate every result.
|
|
817
|
+
*/
|
|
818
|
+
async commitCollection(actionId, criticalBlockIds, collectionId, blockIds) {
|
|
819
|
+
const collection = this.collections.get(collectionId);
|
|
820
|
+
if (!collection) {
|
|
821
|
+
return { collectionId, committed: false, error: `Collection not found: ${collectionId}` };
|
|
822
|
+
}
|
|
823
|
+
// Get revision
|
|
824
|
+
const rev = collection.getNextRev();
|
|
825
|
+
// Find the critical block (log tail) for this collection
|
|
826
|
+
const logTailBlockId = criticalBlockIds.find(blockId => blockIds.includes(blockId));
|
|
827
|
+
if (!logTailBlockId) {
|
|
828
|
+
return { collectionId, committed: false, error: `Log tail block not found for collection ${collectionId}` };
|
|
829
|
+
}
|
|
830
|
+
// Create commit request
|
|
831
|
+
const commitRequest = {
|
|
832
|
+
actionId,
|
|
833
|
+
blockIds,
|
|
834
|
+
tailId: logTailBlockId,
|
|
835
|
+
rev
|
|
836
|
+
};
|
|
837
|
+
// Retry ONLY transient/thrown failures (unreachable peers, timeout) — forward recovery.
|
|
838
|
+
// A returned { success:false } is a permanent stale loss (someone committed a newer rev);
|
|
839
|
+
// the identical request can never win, so return immediately without retrying. Either way
|
|
840
|
+
// cancelPhase (run by coordinateTransaction on commitPhase failure) releases the pend
|
|
841
|
+
// exactly once — commit itself no longer self-cancels.
|
|
842
|
+
let lastTransientError;
|
|
843
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
844
|
+
try {
|
|
585
845
|
const commitResult = await this.transactor.commit(commitRequest);
|
|
586
846
|
if (commitResult.success) {
|
|
587
|
-
committed
|
|
588
|
-
committedCollections.add(collectionId);
|
|
589
|
-
}
|
|
590
|
-
else if (attempt === 2) {
|
|
591
|
-
failedCollections.add(collectionId);
|
|
592
|
-
return {
|
|
593
|
-
success: false,
|
|
594
|
-
error: `Commit failed for collection ${collectionId} after 3 attempts`,
|
|
595
|
-
committedCollections,
|
|
596
|
-
failedCollections
|
|
597
|
-
};
|
|
847
|
+
return { collectionId, committed: true };
|
|
598
848
|
}
|
|
849
|
+
// Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
|
|
850
|
+
// mark it retryable at the coordinator level (after a re-read advances the rev).
|
|
851
|
+
return {
|
|
852
|
+
collectionId,
|
|
853
|
+
committed: false,
|
|
854
|
+
stale: true,
|
|
855
|
+
error: commitResult.reason ?? `Stale commit for collection ${collectionId}`
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
catch (e) {
|
|
859
|
+
lastTransientError = e instanceof Error ? e.message : String(e);
|
|
599
860
|
}
|
|
600
861
|
}
|
|
601
|
-
return {
|
|
862
|
+
return { collectionId, committed: false, error: `Commit failed for collection ${collectionId} after 3 attempts: ${lastTransientError}` };
|
|
602
863
|
}
|
|
603
864
|
/**
|
|
604
865
|
* CANCEL phase: Cancel pending actions on affected blocks.
|
|
@@ -607,11 +868,15 @@ export class TransactionCoordinator {
|
|
|
607
868
|
* recomputing from transforms. Optionally skips already-committed collections.
|
|
608
869
|
*/
|
|
609
870
|
async cancelPhase(actionId, pendedBlockIds, excludeCollections) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
871
|
+
// Fan out the per-collection cancels concurrently. Each is best-effort: a cancel
|
|
872
|
+
// fault is logged and swallowed so it cannot mask the pend/commit failure that
|
|
873
|
+
// triggered this sweep, and so one failed cancel does not abort the others.
|
|
874
|
+
const cancels = Array.from(pendedBlockIds.entries())
|
|
875
|
+
.filter(([collectionId]) => !excludeCollections?.has(collectionId))
|
|
876
|
+
.map(([collectionId, blockIds]) => this.transactor.cancel({ actionId, blockIds }).catch(err => {
|
|
877
|
+
log('cancelPhase: best-effort cancel failed collection=%s: %o', collectionId, err);
|
|
878
|
+
}));
|
|
879
|
+
await Promise.all(cancels);
|
|
615
880
|
}
|
|
616
881
|
}
|
|
617
882
|
//# sourceMappingURL=coordinator.js.map
|