@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,22 +1,38 @@
|
|
|
1
|
-
import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId
|
|
1
|
+
import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId } from "../index.js";
|
|
2
2
|
import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
|
|
3
3
|
import type { PeerId } from "../network/types.js";
|
|
4
4
|
import type { Collection } from "../collection/collection.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
5
|
+
import type { SyncOptions } from "../collection/index.js";
|
|
6
|
+
import { isTransactionExpired, clampPriority } from "./transaction.js";
|
|
7
|
+
import { Log, blockIdsForTransforms } from "../index.js";
|
|
8
|
+
import { collectOperations, hashOperations } from "./operations-hash.js";
|
|
9
|
+
import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
|
|
10
|
+
import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
|
|
9
11
|
import { createLogger } from "../logger.js";
|
|
10
12
|
|
|
11
13
|
const log = createLogger('trx:coordinator');
|
|
12
14
|
|
|
15
|
+
/** Default max consecutive clean-stale-loss retries before {@link TransactionCoordinator.commit}
|
|
16
|
+
* gives up. Mirrors the single-collection sync default so the two retry loops share one policy. */
|
|
17
|
+
const DefaultMaxAttempts = 10;
|
|
18
|
+
/** Default base backoff (ms) before the first commit retry. */
|
|
19
|
+
const DefaultBaseBackoffMs = 100;
|
|
20
|
+
/** Default ceiling (ms) on a single commit-retry backoff sleep. */
|
|
21
|
+
const DefaultMaxBackoffMs = 5000;
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
|
-
*
|
|
24
|
+
* A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision (a
|
|
25
|
+
* committed `missing` action newer than our rev, or a `pending` action on a touched block) that a
|
|
26
|
+
* re-read + re-pend can clear. A bare rejection reason (storage full, policy) is NOT a conflict and
|
|
27
|
+
* is not worth re-driving. Thrown by {@link TransactionCoordinator.pendCollection} so the fan-out in
|
|
28
|
+
* pendPhase can settle it and read the flag off the rejection.
|
|
15
29
|
*/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
class PendRejectedError extends Error {
|
|
31
|
+
constructor(collectionId: CollectionId, readonly conflict: boolean, reason?: string) {
|
|
32
|
+
super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`);
|
|
33
|
+
this.name = 'PendRejectedError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
38
|
* Coordinates multi-collection transactions.
|
|
@@ -94,19 +110,105 @@ export class TransactionCoordinator {
|
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
/**
|
|
97
|
-
* Commit a transaction
|
|
113
|
+
* Commit a transaction with a bounded, jittered backoff retry around a CLEAN stale loss.
|
|
114
|
+
*
|
|
115
|
+
* The single-attempt work lives in {@link commitOnce}; this wrapper re-drives it when the attempt
|
|
116
|
+
* fails as a clean optimistic-concurrency loss ({@link CoordinatorStaleLossError} — nothing
|
|
117
|
+
* durably committed, every tracker restored to its pre-append state). Before each re-attempt it
|
|
118
|
+
* re-reads each collection to fresh revisions (so the retry pends against current state rather
|
|
119
|
+
* than immediately re-failing stale), then backs off with the same jitter policy as
|
|
120
|
+
* {@link Collection.sync}. Retry is bounded by `maxAttempts` and an optional wall-clock
|
|
121
|
+
* `deadlineMs`, and honours an abort `signal`.
|
|
122
|
+
*
|
|
123
|
+
* A {@link CoordinatorPartialCommitError} (a partial landing — some collection durably committed)
|
|
124
|
+
* is NOT retryable and escapes immediately: blindly retrying would re-log already-durable actions.
|
|
125
|
+
* Any other failure (expired transaction, unavailable transactor, unreachable cluster) also
|
|
126
|
+
* propagates without retry — only genuine clean stale losses are re-driven.
|
|
127
|
+
*
|
|
128
|
+
* Defaults are safe out of the box: a caller that passes no options gets bounded, jittered retry.
|
|
129
|
+
*
|
|
130
|
+
* @param transaction - The transaction to commit
|
|
131
|
+
* @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
|
|
132
|
+
*/
|
|
133
|
+
async commit(transaction: Transaction, options?: SyncOptions): Promise<void> {
|
|
134
|
+
const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
|
|
135
|
+
const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
|
|
136
|
+
const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
|
|
137
|
+
const deadlineMs = options?.deadlineMs;
|
|
138
|
+
const signal = options?.signal;
|
|
139
|
+
const startedAt = Date.now();
|
|
140
|
+
|
|
141
|
+
// Count of consecutive clean stale losses. There is no forward-progress notion here (a
|
|
142
|
+
// single commit either lands or it does not), so this simply bounds how many times we
|
|
143
|
+
// re-drive a losing transaction before surfacing a terminal error.
|
|
144
|
+
let staleLosses = 0;
|
|
145
|
+
let lastLoss: CoordinatorStaleLossError | undefined;
|
|
146
|
+
for (;;) {
|
|
147
|
+
if (signal?.aborted) {
|
|
148
|
+
throw makeAbortError(signal);
|
|
149
|
+
}
|
|
150
|
+
// Progress-agnostic ceiling: once we've taken at least one loss, give up if the
|
|
151
|
+
// wall-clock deadline passed (independent of the attempt cap).
|
|
152
|
+
if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
|
|
153
|
+
throw lastLoss;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Age the transaction's advisory priority by the number of losses taken so far, so a
|
|
157
|
+
// repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
|
|
158
|
+
// resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
|
|
159
|
+
// signature, so bumping it here does not churn identity. Left untouched on the first
|
|
160
|
+
// attempt (staleLosses == 0) so the initial pend serializes exactly as before.
|
|
161
|
+
if (staleLosses > 0) {
|
|
162
|
+
transaction.priority = clampPriority(staleLosses);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await this.commitOnce(transaction);
|
|
167
|
+
return;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
|
|
170
|
+
// unavailable transactor, etc. all propagate unchanged.
|
|
171
|
+
if (!(err instanceof CoordinatorStaleLossError)) {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
lastLoss = err;
|
|
175
|
+
staleLosses++;
|
|
176
|
+
if (staleLosses >= maxAttempts) {
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
|
|
180
|
+
await abortableDelay(delay, signal);
|
|
181
|
+
// Re-read fresh state before re-attempting so the next commit pends against current
|
|
182
|
+
// revisions (mirrors how Collection.sync calls updateInternal() before retrying).
|
|
183
|
+
// NOTE: refreshes EVERY registered collection, not only the participants of this
|
|
184
|
+
// transaction. Harmless (a non-participant's update() just fetches latest) and the
|
|
185
|
+
// registered set is small today; if a coordinator ever holds many collections and this
|
|
186
|
+
// shows up as retry latency, narrow it to the transaction's participating collections.
|
|
187
|
+
for (const collection of this.collections.values()) {
|
|
188
|
+
await collection.update();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Commit a transaction (single attempt): materialise a log entry from each collection's staged
|
|
98
196
|
* pending actions, then orchestrate the distributed consensus (GATHER/PEND/COMMIT).
|
|
99
197
|
*
|
|
100
|
-
* Called by
|
|
198
|
+
* Called by {@link commit} (which wraps it in the backoff+jitter retry loop). The
|
|
101
199
|
* staged mutations already live in each collection's tracker — applied either via
|
|
102
200
|
* applyActions() (engine-driven path) or directly via Collection.act()/Tree.stage
|
|
103
201
|
* (the vtab's deferred-DML path) — but in BOTH cases without a log entry yet, so
|
|
104
202
|
* this method appends that entry here (see the inline note below) before pending,
|
|
105
203
|
* and folds the committed transforms back into each collection's read cache.
|
|
106
204
|
*
|
|
205
|
+
* On a clean stale loss (nothing durable, every tracker restored) it throws
|
|
206
|
+
* {@link CoordinatorStaleLossError} so the caller can retry; on a partial landing it throws
|
|
207
|
+
* {@link CoordinatorPartialCommitError} (not retryable).
|
|
208
|
+
*
|
|
107
209
|
* @param transaction - The transaction to commit
|
|
108
210
|
*/
|
|
109
|
-
async
|
|
211
|
+
private async commitOnce(transaction: Transaction): Promise<void> {
|
|
110
212
|
if (isTransactionExpired(transaction.stamp)) {
|
|
111
213
|
throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
|
|
112
214
|
}
|
|
@@ -144,44 +246,117 @@ export class TransactionCoordinator {
|
|
|
144
246
|
const collectionTransforms = new Map<CollectionId, Transforms>();
|
|
145
247
|
const criticalBlocks = new Map<CollectionId, BlockId>();
|
|
146
248
|
|
|
249
|
+
// Snapshot EVERY participating collection's staged state (transforms + pending
|
|
250
|
+
// queue) BEFORE the append loop mutates any tracker. The loop appends log
|
|
251
|
+
// entries sequentially, so a failure on the Nth collection must also undo the
|
|
252
|
+
// 0..N-1 collections that already appended — and coordinateTransaction can fail
|
|
253
|
+
// after ALL of them appended. On any throw below we restore every snapshot, so a
|
|
254
|
+
// failed commit leaves each tracker exactly as it was: a retry re-appends cleanly
|
|
255
|
+
// (no duplicate log entry) and a directly-staged tree's rollback (which no-ops
|
|
256
|
+
// when the stamp was never tracked via applyActions) has nothing poisoned to undo.
|
|
257
|
+
const preCommitSnapshots = new Map<CollectionId, ReturnType<Collection<any>['snapshotPending']>>();
|
|
147
258
|
for (const { collectionId, collection } of collectionData) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
259
|
+
preCommitSnapshots.set(collectionId, collection.snapshotPending());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let coordResult: {
|
|
263
|
+
success: boolean;
|
|
264
|
+
error?: string;
|
|
265
|
+
committedCollections?: Set<CollectionId>;
|
|
266
|
+
failedCollections?: Set<CollectionId>;
|
|
267
|
+
staleLoss?: boolean;
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
for (const { collectionId, collection } of collectionData) {
|
|
271
|
+
const applyResult = await this.applyActionsToCollection(
|
|
272
|
+
{ collectionId, actions: collection.getPendingActions() },
|
|
273
|
+
transaction,
|
|
274
|
+
allCollectionIds
|
|
275
|
+
);
|
|
276
|
+
if (!applyResult.success) {
|
|
277
|
+
throw new Error(`Transaction commit failed: ${applyResult.error}`);
|
|
278
|
+
}
|
|
279
|
+
collectionTransforms.set(collectionId, applyResult.transforms!);
|
|
280
|
+
criticalBlocks.set(collectionId, applyResult.logTailBlockId!);
|
|
155
281
|
}
|
|
156
|
-
collectionTransforms.set(collectionId, applyResult.transforms!);
|
|
157
|
-
criticalBlocks.set(collectionId, applyResult.logTailBlockId!);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Compute hash of ALL operations across ALL collections (post-log-append).
|
|
161
|
-
// Validators re-execute the transaction and compare their computed hash.
|
|
162
|
-
const allOperations = Array.from(collectionTransforms.entries()).flatMap(([collectionId, transforms]) => [
|
|
163
|
-
...Object.entries(transforms.inserts ?? {}).map(([blockId, block]) =>
|
|
164
|
-
({ type: 'insert' as const, collectionId, blockId, block })
|
|
165
|
-
),
|
|
166
|
-
...Object.entries(transforms.updates ?? {}).map(([blockId, operations]) =>
|
|
167
|
-
({ type: 'update' as const, collectionId, blockId, operations })
|
|
168
|
-
),
|
|
169
|
-
...(transforms.deletes ?? []).map(blockId =>
|
|
170
|
-
({ type: 'delete' as const, collectionId, blockId })
|
|
171
|
-
)
|
|
172
|
-
]);
|
|
173
282
|
|
|
174
|
-
|
|
283
|
+
// Compute hash of ALL operations across ALL collections (post-log-append).
|
|
284
|
+
// Validators re-execute the transaction and compare their computed hash.
|
|
285
|
+
// The shared operations-hash module canonicalises (sort + canonical JSON) so
|
|
286
|
+
// this order-independent fingerprint matches what a validator recomputes.
|
|
287
|
+
const operationsHash = await hashOperations(collectOperations(collectionTransforms));
|
|
175
288
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
289
|
+
// Execute consensus phases (GATHER, PEND, COMMIT)
|
|
290
|
+
coordResult = await this.coordinateTransaction(
|
|
291
|
+
transaction,
|
|
292
|
+
operationsHash,
|
|
293
|
+
collectionTransforms,
|
|
294
|
+
criticalBlocks
|
|
295
|
+
);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
// A throw here means the failure happened BEFORE any collection could
|
|
298
|
+
// durably commit (a log-append failure, or coordinateTransaction rejecting
|
|
299
|
+
// unexpectedly). Nothing landed on the cluster, so roll every tracker back
|
|
300
|
+
// to its pre-append snapshot — a genuinely clean rollback that leaves each
|
|
301
|
+
// tracker pristine for retry (see txn-failed-commit-leaves-staged-log-entry).
|
|
302
|
+
for (const { collectionId, collection } of collectionData) {
|
|
303
|
+
collection.restorePending(preCommitSnapshots.get(collectionId)!);
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
183
307
|
|
|
184
308
|
if (!coordResult.success) {
|
|
309
|
+
const committed = coordResult.committedCollections ?? new Set<CollectionId>();
|
|
310
|
+
if (committed.size > 0) {
|
|
311
|
+
// PARTIAL COMMIT: at least one collection durably committed via consensus
|
|
312
|
+
// while another failed permanently. A uniform pre-append restore would
|
|
313
|
+
// corrupt the committed half — re-staging its already-durable actions as
|
|
314
|
+
// still-pending, so tracker memory would disagree with cluster storage.
|
|
315
|
+
// Split the local handling instead:
|
|
316
|
+
for (const { collectionId, collection } of collectionData) {
|
|
317
|
+
if (committed.has(collectionId)) {
|
|
318
|
+
// Committed → the success-path local treatment (see below): fold the
|
|
319
|
+
// committed transforms into the read cache BEFORE resetting the tracker,
|
|
320
|
+
// then drop the now-durable pending actions so a retry cannot re-log them.
|
|
321
|
+
// NOTE: no-double-apply on retry depends on clearPendingActions() running for
|
|
322
|
+
// EVERY committed collection here before any re-drive of commit(). If a committed
|
|
323
|
+
// collection kept its pending queue, a subsequent commit() would re-append and
|
|
324
|
+
// re-log its already-durable actions — a duplicate log entry on the winner. The
|
|
325
|
+
// no-double-apply-on-retry test in transaction.spec.ts locks this.
|
|
326
|
+
const rev = collection.recordCommitted(transaction.id);
|
|
327
|
+
collection.applyCommittedToCache(collectionTransforms.get(collectionId)!, rev);
|
|
328
|
+
collection.tracker.reset();
|
|
329
|
+
collection.clearPendingActions();
|
|
330
|
+
} else {
|
|
331
|
+
// Failed / never-committed → restore the pre-append snapshot so a retry
|
|
332
|
+
// re-appends cleanly (no duplicate log entry).
|
|
333
|
+
collection.restorePending(preCommitSnapshots.get(collectionId)!);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// The transaction half-landed, so it is neither cleanly retryable nor
|
|
337
|
+
// cleanly abortable: drop its stamp tracking (the success path does the
|
|
338
|
+
// same at the end) and surface the structured signal for reconciliation.
|
|
339
|
+
this.stampData.delete(transaction.stamp.id);
|
|
340
|
+
throw new CoordinatorPartialCommitError(
|
|
341
|
+
[...committed],
|
|
342
|
+
[...(coordResult.failedCollections ?? new Set<CollectionId>())],
|
|
343
|
+
coordResult.error
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// EMPTY committed set: PEND failed, or the whole commit failed cleanly with
|
|
348
|
+
// nothing durable. Restore every tracker so each is pristine for retry.
|
|
349
|
+
for (const { collectionId, collection } of collectionData) {
|
|
350
|
+
collection.restorePending(preCommitSnapshots.get(collectionId)!);
|
|
351
|
+
}
|
|
352
|
+
// Distinguish a genuine optimistic-concurrency conflict (a stale loss / pending
|
|
353
|
+
// contention — retryable after a re-read) from a hard failure (unavailable transactor,
|
|
354
|
+
// storage rejection, expired). Only the former is worth re-driving; the retry wrapper in
|
|
355
|
+
// commit() catches CoordinatorStaleLossError and re-attempts, while a plain Error escapes
|
|
356
|
+
// immediately (preserving the historical fail-fast behaviour for hard failures).
|
|
357
|
+
if (coordResult.staleLoss) {
|
|
358
|
+
throw new CoordinatorStaleLossError([...(coordResult.failedCollections ?? new Set(allCollectionIds))], coordResult.error);
|
|
359
|
+
}
|
|
185
360
|
throw new Error(`Transaction commit failed: ${coordResult.error}`);
|
|
186
361
|
}
|
|
187
362
|
|
|
@@ -193,15 +368,8 @@ export class TransactionCoordinator {
|
|
|
193
368
|
// serves the new revision instead of the stale cached one. Clearing
|
|
194
369
|
// pending keeps a subsequent commit from re-logging these actions.
|
|
195
370
|
for (const { collectionId, collection } of collectionData) {
|
|
196
|
-
const
|
|
197
|
-
collection
|
|
198
|
-
committed: [
|
|
199
|
-
...(collection['source'].actionContext?.committed ?? []),
|
|
200
|
-
{ actionId: transaction.id, rev: newRev }
|
|
201
|
-
],
|
|
202
|
-
rev: newRev,
|
|
203
|
-
};
|
|
204
|
-
collection.applyCommittedToCache(collectionTransforms.get(collectionId)!);
|
|
371
|
+
const rev = collection.recordCommitted(transaction.id);
|
|
372
|
+
collection.applyCommittedToCache(collectionTransforms.get(collectionId)!, rev);
|
|
205
373
|
collection.tracker.reset();
|
|
206
374
|
collection.clearPendingActions();
|
|
207
375
|
}
|
|
@@ -318,64 +486,10 @@ export class TransactionCoordinator {
|
|
|
318
486
|
}
|
|
319
487
|
}
|
|
320
488
|
|
|
321
|
-
/**
|
|
322
|
-
* Compute hash of all operations in a transaction.
|
|
323
|
-
* This hash is used for validation - validators re-execute the transaction
|
|
324
|
-
* and compare their computed operations hash with this one.
|
|
325
|
-
*/
|
|
326
|
-
private async hashOperations(operations: readonly Operation[]): Promise<string> {
|
|
327
|
-
const operationsData = JSON.stringify(operations);
|
|
328
|
-
return `ops:${await hashString(operationsData)}`;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Commit a transaction context.
|
|
333
|
-
*
|
|
334
|
-
* @deprecated Use TransactionSession instead of TransactionContext
|
|
335
|
-
* This is called by TransactionContext.commit().
|
|
336
|
-
*
|
|
337
|
-
* @param context - The transaction context to commit
|
|
338
|
-
* @returns Execution result with actions and results
|
|
339
|
-
*/
|
|
340
|
-
async commitTransaction(context: TransactionContext): Promise<ExecutionResult> {
|
|
341
|
-
const collectionActions = Array.from(context.getCollectionActions().entries()).map(
|
|
342
|
-
([collectionId, actions]) => ({ collectionId, actions })
|
|
343
|
-
);
|
|
344
|
-
|
|
345
|
-
if (collectionActions.length === 0) {
|
|
346
|
-
return { success: true }; // Nothing to commit
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Create transaction statements
|
|
350
|
-
const statements = createActionsStatements(collectionActions);
|
|
351
|
-
const reads = context.getReads();
|
|
352
|
-
|
|
353
|
-
// Create stamp from context
|
|
354
|
-
const stamp = await createTransactionStamp(
|
|
355
|
-
'local', // TODO: Get from context or coordinator
|
|
356
|
-
Date.now(),
|
|
357
|
-
'', // TODO: Get from engine
|
|
358
|
-
context.engine
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
const transaction: Transaction = {
|
|
362
|
-
stamp,
|
|
363
|
-
statements,
|
|
364
|
-
reads,
|
|
365
|
-
id: await createTransactionId(stamp.id, statements, reads)
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
const engine = new ActionsEngine(this);
|
|
369
|
-
|
|
370
|
-
// Execute through standard path
|
|
371
|
-
return await this.execute(transaction, engine);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
489
|
/**
|
|
375
490
|
* Execute a fully-formed transaction.
|
|
376
491
|
*
|
|
377
|
-
* This
|
|
378
|
-
* or indirectly via commitTransaction().
|
|
492
|
+
* This is called with a complete transaction (e.g., from Quereus).
|
|
379
493
|
*
|
|
380
494
|
* @param transaction - The transaction to execute
|
|
381
495
|
* @param engine - The engine to use for executing the transaction
|
|
@@ -405,7 +519,41 @@ export class TransactionCoordinator {
|
|
|
405
519
|
return { success: true }; // Nothing to do
|
|
406
520
|
}
|
|
407
521
|
|
|
408
|
-
//
|
|
522
|
+
// 1b. Stage the returned actions into the collection trackers.
|
|
523
|
+
//
|
|
524
|
+
// Reaching here means the engine RETURNED non-empty actions — i.e. the pure-
|
|
525
|
+
// translator model (see the ITransactionEngine contract): it translated the
|
|
526
|
+
// statements but did NOT apply them. So THIS path owns application — we stage the
|
|
527
|
+
// actions here via applyActions() (which also snapshots/tracks the stamp for
|
|
528
|
+
// rollback) BEFORE the loop below reads each tracker's transforms to materialise
|
|
529
|
+
// the log entry. (Previously ActionsEngine applied as a side effect and this
|
|
530
|
+
// method merely re-read the already-staged trackers; that side effect is gone, so
|
|
531
|
+
// the application must happen explicitly here. A side-effecting engine that
|
|
532
|
+
// applied internally would instead return EMPTY actions and short-circuit at the
|
|
533
|
+
// guard above.)
|
|
534
|
+
//
|
|
535
|
+
// applyActions() throws if a referenced collection is not registered — the same
|
|
536
|
+
// "Collection not found" the engine's side-effecting apply used to surface. Convert
|
|
537
|
+
// it back into a failure result so execute() keeps its return contract.
|
|
538
|
+
try {
|
|
539
|
+
await this.applyActions(result.actions, transaction.stamp.id);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
const engineMs = Date.now() - tEngine;
|
|
542
|
+
log('execute:done trxId=%s engine=%dms apply-failed=true total=%dms', trxId, engineMs, Date.now() - t0);
|
|
543
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// 2. Build a log entry per collection from the now-staged tracker transforms.
|
|
547
|
+
//
|
|
548
|
+
// NOTE: like commit(), this loop appends a log entry into each collection's
|
|
549
|
+
// tracker and these failure returns do NOT restore that state — so a partially
|
|
550
|
+
// applied engine transaction leaves appended-but-uncommitted entries in the
|
|
551
|
+
// trackers. This is deliberately NOT snapshot/restore-wrapped the way commit()
|
|
552
|
+
// is, because execute()'s asymmetry makes it lower risk: it is not the retryable
|
|
553
|
+
// session.commit() entry point (a failed execute() is not re-driven through the
|
|
554
|
+
// same loop), and its actions were tracked via applyActions() so rollback(stampId)
|
|
555
|
+
// CAN unwind them (unlike commit()'s directly-staged path). If execute() ever
|
|
556
|
+
// becomes retryable, mirror the commit() snapshot/restore fix here.
|
|
409
557
|
const tApply = Date.now();
|
|
410
558
|
const collectionTransforms = new Map<CollectionId, Transforms>();
|
|
411
559
|
const criticalBlocks = new Map<CollectionId, BlockId>();
|
|
@@ -428,19 +576,8 @@ export class TransactionCoordinator {
|
|
|
428
576
|
actionResults.set(collectionActions.collectionId, applyResult.results!);
|
|
429
577
|
}
|
|
430
578
|
|
|
431
|
-
// 3. Compute operations hash for validation
|
|
432
|
-
const
|
|
433
|
-
...Object.entries(transforms.inserts ?? {}).map(([blockId, block]) =>
|
|
434
|
-
({ type: 'insert' as const, collectionId, blockId, block })
|
|
435
|
-
),
|
|
436
|
-
...Object.entries(transforms.updates ?? {}).map(([blockId, operations]) =>
|
|
437
|
-
({ type: 'update' as const, collectionId, blockId, operations })
|
|
438
|
-
),
|
|
439
|
-
...(transforms.deletes ?? []).map(blockId =>
|
|
440
|
-
({ type: 'delete' as const, collectionId, blockId })
|
|
441
|
-
)
|
|
442
|
-
]);
|
|
443
|
-
const operationsHash = await this.hashOperations(allOperations);
|
|
579
|
+
// 3. Compute operations hash for validation (order-independent; see commit()).
|
|
580
|
+
const operationsHash = await hashOperations(collectOperations(collectionTransforms));
|
|
444
581
|
|
|
445
582
|
const applyMs = Date.now() - tApply;
|
|
446
583
|
|
|
@@ -456,22 +593,34 @@ export class TransactionCoordinator {
|
|
|
456
593
|
const coordMs = Date.now() - tCoord;
|
|
457
594
|
if (!coordResult.success) {
|
|
458
595
|
log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
|
|
459
|
-
|
|
596
|
+
// Stop lying to the caller about a partial commit: if some collections durably
|
|
597
|
+
// committed, surface that set. execute() is not snapshot/restore-wrapped (see the
|
|
598
|
+
// note above), but the committed subset must still get the success-path local
|
|
599
|
+
// treatment (recordCommitted + tracker.reset, as on the success path below) so its
|
|
600
|
+
// trackers aren't left mis-tracking already-durable state.
|
|
601
|
+
const committed = coordResult.committedCollections ?? new Set<CollectionId>();
|
|
602
|
+
if (committed.size > 0) {
|
|
603
|
+
for (const collectionActions of result.actions) {
|
|
604
|
+
const collection = this.collections.get(collectionActions.collectionId);
|
|
605
|
+
if (collection && committed.has(collectionActions.collectionId)) {
|
|
606
|
+
collection.recordCommitted(transaction.id);
|
|
607
|
+
collection.tracker.reset();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
success: false,
|
|
613
|
+
error: coordResult.error,
|
|
614
|
+
committedCollections: committed.size > 0 ? [...committed] : undefined,
|
|
615
|
+
failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
|
|
616
|
+
};
|
|
460
617
|
}
|
|
461
618
|
|
|
462
619
|
// 5. Update actionContext and reset trackers after successful commit
|
|
463
620
|
for (const collectionActions of result.actions) {
|
|
464
621
|
const collection = this.collections.get(collectionActions.collectionId);
|
|
465
622
|
if (collection) {
|
|
466
|
-
|
|
467
|
-
const actionId = transaction.id;
|
|
468
|
-
collection['source'].actionContext = {
|
|
469
|
-
committed: [
|
|
470
|
-
...(collection['source'].actionContext?.committed ?? []),
|
|
471
|
-
{ actionId, rev: newRev }
|
|
472
|
-
],
|
|
473
|
-
rev: newRev,
|
|
474
|
-
};
|
|
623
|
+
collection.recordCommitted(transaction.id);
|
|
475
624
|
collection.tracker.reset();
|
|
476
625
|
}
|
|
477
626
|
}
|
|
@@ -513,7 +662,7 @@ export class TransactionCoordinator {
|
|
|
513
662
|
}
|
|
514
663
|
|
|
515
664
|
// At this point, actions have already been executed through collection.act()
|
|
516
|
-
//
|
|
665
|
+
// (via the engine or the vtab's staging path). The collection's tracker
|
|
517
666
|
// already has the transforms, and the actions are in the pending buffer.
|
|
518
667
|
|
|
519
668
|
// Get transforms from the collection's tracker
|
|
@@ -530,7 +679,7 @@ export class TransactionCoordinator {
|
|
|
530
679
|
|
|
531
680
|
// Generate action ID from transaction ID
|
|
532
681
|
const actionId = transaction.id;
|
|
533
|
-
const newRev =
|
|
682
|
+
const newRev = collection.getNextRev();
|
|
534
683
|
|
|
535
684
|
// Add actions to log (this updates the tracker with log block changes).
|
|
536
685
|
// Persist the transaction's read set on the entry so a later invalidation cascade can
|
|
@@ -569,7 +718,15 @@ export class TransactionCoordinator {
|
|
|
569
718
|
operationsHash: string,
|
|
570
719
|
collectionTransforms: Map<CollectionId, Transforms>,
|
|
571
720
|
criticalBlocks: Map<CollectionId, BlockId>
|
|
572
|
-
): Promise<{
|
|
721
|
+
): Promise<{
|
|
722
|
+
success: boolean;
|
|
723
|
+
error?: string;
|
|
724
|
+
committedCollections?: Set<CollectionId>;
|
|
725
|
+
failedCollections?: Set<CollectionId>;
|
|
726
|
+
/** True when the failure was a clean optimistic-concurrency conflict (stale loss / pending
|
|
727
|
+
* contention) with nothing durable — i.e. safe to re-drive after a re-read. */
|
|
728
|
+
staleLoss?: boolean;
|
|
729
|
+
}> {
|
|
573
730
|
const trxId = transaction.id;
|
|
574
731
|
const t0 = Date.now();
|
|
575
732
|
|
|
@@ -609,7 +766,16 @@ export class TransactionCoordinator {
|
|
|
609
766
|
commitResult.committedCollections
|
|
610
767
|
);
|
|
611
768
|
log('trx:phases trxId=%s gather=%dms pend=%dms commit=%dms (failed) total=%dms', trxId, gatherMs, pendMs, commitMs, Date.now() - t0);
|
|
612
|
-
|
|
769
|
+
// Surface the committed/failed partition so commit()/execute() can report which
|
|
770
|
+
// collections durably landed. A non-empty committedCollections is a PARTIAL commit:
|
|
771
|
+
// those collections cannot be rolled back and the caller must reconcile.
|
|
772
|
+
return {
|
|
773
|
+
success: false,
|
|
774
|
+
error: commitResult.error,
|
|
775
|
+
committedCollections: commitResult.committedCollections,
|
|
776
|
+
failedCollections: commitResult.failedCollections,
|
|
777
|
+
staleLoss: commitResult.staleLoss,
|
|
778
|
+
};
|
|
613
779
|
}
|
|
614
780
|
|
|
615
781
|
// 4. PROPAGATE and CHECKPOINT phases are handled by clusters automatically
|
|
@@ -647,16 +813,19 @@ export class TransactionCoordinator {
|
|
|
647
813
|
);
|
|
648
814
|
const results = await Promise.all(nomineePromises);
|
|
649
815
|
|
|
650
|
-
// Merge all nominees into a single set
|
|
651
|
-
|
|
816
|
+
// Merge all nominees into a single set, deduped by peer identity. Each
|
|
817
|
+
// queryClusterNominees builds a fresh PeerId object per call (peerIdFromString),
|
|
818
|
+
// so a Set keyed by object reference would keep the same physical peer twice when
|
|
819
|
+
// it nominates for two critical clusters. Key by toString() to collapse duplicates.
|
|
820
|
+
const byId = results.reduce(
|
|
652
821
|
(acc, result) => {
|
|
653
|
-
result.nominees.forEach(nominee => acc.
|
|
822
|
+
result.nominees.forEach(nominee => acc.set(nominee.toString(), nominee));
|
|
654
823
|
return acc;
|
|
655
824
|
},
|
|
656
|
-
new
|
|
825
|
+
new Map<string, PeerId>()
|
|
657
826
|
);
|
|
658
827
|
|
|
659
|
-
return
|
|
828
|
+
return new Set(byId.values());
|
|
660
829
|
}
|
|
661
830
|
|
|
662
831
|
/**
|
|
@@ -672,56 +841,104 @@ export class TransactionCoordinator {
|
|
|
672
841
|
operationsHash: string,
|
|
673
842
|
collectionTransforms: ReadonlyMap<CollectionId, Transforms>,
|
|
674
843
|
superclusterNominees: ReadonlySet<PeerId> | null
|
|
675
|
-
): Promise<{ success: boolean; error?: string; pendedBlockIds?: Map<CollectionId, BlockId[]
|
|
844
|
+
): Promise<{ success: boolean; error?: string; pendedBlockIds?: Map<CollectionId, BlockId[]>; staleLoss?: boolean }> {
|
|
676
845
|
if (collectionTransforms.size === 0) {
|
|
677
846
|
return { success: false, error: 'No transforms to pend' };
|
|
678
847
|
}
|
|
679
848
|
|
|
680
|
-
const pendedBlockIds = new Map<CollectionId, BlockId[]>();
|
|
681
849
|
const actionId = transaction.id as ActionId;
|
|
682
850
|
const nominees = superclusterNominees ? Array.from(superclusterNominees) : undefined;
|
|
683
851
|
|
|
684
|
-
//
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
// Create pend request with transaction and operations hash for validation
|
|
695
|
-
const pendRequest: PendRequest = {
|
|
696
|
-
actionId,
|
|
697
|
-
rev,
|
|
698
|
-
transforms,
|
|
699
|
-
policy: 'r', // Return policy: fail but return pending actions
|
|
700
|
-
transaction,
|
|
701
|
-
operationsHash,
|
|
702
|
-
superclusterNominees: nominees
|
|
703
|
-
};
|
|
852
|
+
// Fan out the independent per-collection pends concurrently. Each settles to a
|
|
853
|
+
// { collectionId, blockIds } on success, or rejects with the per-collection reason.
|
|
854
|
+
// NOTE: unbounded fan-out — one concurrent coordinator round-trip per collection.
|
|
855
|
+
// Transactions touch few collections today; if one ever spans very many, bound this
|
|
856
|
+
// with a concurrency limiter so peak in-flight round-trips stays sane. Same for commitPhase.
|
|
857
|
+
const outcomes = await Promise.allSettled(
|
|
858
|
+
Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) =>
|
|
859
|
+
this.pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees)
|
|
860
|
+
)
|
|
861
|
+
);
|
|
704
862
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
863
|
+
// Partition settled results: every collection that DID pend (keyed with its block
|
|
864
|
+
// ids), plus the first failure reason if any collection failed.
|
|
865
|
+
const pendedBlockIds = new Map<CollectionId, BlockId[]>();
|
|
866
|
+
let failure: string | undefined;
|
|
867
|
+
// Classify across ALL failures (mirroring commitPhase, and independent of iteration order):
|
|
868
|
+
// the pend is a retryable clean stale loss only if at least one failure was a conflicting pend
|
|
869
|
+
// (PendRejectedError.conflict) AND none was a hard failure. A single hard failure (storage/
|
|
870
|
+
// policy rejection, or a thrown/unavailable transactor) will not clear on a re-read, so
|
|
871
|
+
// re-driving it would just burn the retry budget — fail fast instead.
|
|
872
|
+
let anyConflict = false;
|
|
873
|
+
let anyHard = false;
|
|
874
|
+
for (const outcome of outcomes) {
|
|
875
|
+
if (outcome.status === 'fulfilled') {
|
|
876
|
+
pendedBlockIds.set(outcome.value.collectionId, outcome.value.blockIds);
|
|
877
|
+
} else {
|
|
878
|
+
if (failure === undefined) {
|
|
879
|
+
failure = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
711
880
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
error: `Pend failed for collection ${collectionId}: ${pendResult.reason}`
|
|
715
|
-
};
|
|
881
|
+
if (outcome.reason instanceof PendRejectedError && outcome.reason.conflict) anyConflict = true;
|
|
882
|
+
else anyHard = true;
|
|
716
883
|
}
|
|
884
|
+
}
|
|
717
885
|
|
|
718
|
-
|
|
719
|
-
|
|
886
|
+
if (failure !== undefined) {
|
|
887
|
+
// Any failure aborts the whole pend. With concurrency several collections may
|
|
888
|
+
// have pended in parallel, so cancel EVERY successfully-pended collection — not
|
|
889
|
+
// only those started before the failure. Cancels are best-effort (cancelPhase
|
|
890
|
+
// swallows their errors) so they cannot mask the original pend failure.
|
|
891
|
+
await this.cancelPhase(actionId, pendedBlockIds);
|
|
892
|
+
return { success: false, error: failure, staleLoss: anyConflict && !anyHard };
|
|
720
893
|
}
|
|
721
894
|
|
|
722
895
|
return { success: true, pendedBlockIds };
|
|
723
896
|
}
|
|
724
897
|
|
|
898
|
+
/**
|
|
899
|
+
* Pend a single collection's transforms. Resolves with the collection id and its
|
|
900
|
+
* pended block ids on success; throws with a per-collection reason on failure so the
|
|
901
|
+
* fan-out in {@link pendPhase} can settle it as a rejection.
|
|
902
|
+
*/
|
|
903
|
+
private async pendCollection(
|
|
904
|
+
transaction: Transaction,
|
|
905
|
+
operationsHash: string,
|
|
906
|
+
collectionId: CollectionId,
|
|
907
|
+
transforms: Transforms,
|
|
908
|
+
actionId: ActionId,
|
|
909
|
+
nominees: PeerId[] | undefined
|
|
910
|
+
): Promise<{ collectionId: CollectionId; blockIds: BlockId[] }> {
|
|
911
|
+
const collection = this.collections.get(collectionId);
|
|
912
|
+
if (!collection) {
|
|
913
|
+
throw new Error(`Collection not found: ${collectionId}`);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Get revision from the collection's source
|
|
917
|
+
const rev = collection.getNextRev();
|
|
918
|
+
|
|
919
|
+
// Create pend request with transaction and operations hash for validation
|
|
920
|
+
const pendRequest: PendRequest = {
|
|
921
|
+
actionId,
|
|
922
|
+
rev,
|
|
923
|
+
transforms,
|
|
924
|
+
policy: 'r', // Return policy: fail but return pending actions
|
|
925
|
+
transaction,
|
|
926
|
+
operationsHash,
|
|
927
|
+
superclusterNominees: nominees
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
const pendResult = await this.transactor.pend(pendRequest);
|
|
931
|
+
if (!pendResult.success) {
|
|
932
|
+
// A committed `missing` action or a `pending` action on a touched block is an
|
|
933
|
+
// optimistic-concurrency conflict — retryable after a re-read. A bare `reason` is a hard
|
|
934
|
+
// rejection (storage/policy) that re-driving won't fix.
|
|
935
|
+
const conflict = Boolean(pendResult.missing?.length || pendResult.pending?.length);
|
|
936
|
+
throw new PendRejectedError(collectionId, conflict, pendResult.reason);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return { collectionId, blockIds: pendResult.blockIds };
|
|
940
|
+
}
|
|
941
|
+
|
|
725
942
|
/**
|
|
726
943
|
* COMMIT phase: Commit to all critical blocks with retry for transient failures.
|
|
727
944
|
*
|
|
@@ -738,69 +955,114 @@ export class TransactionCoordinator {
|
|
|
738
955
|
error?: string;
|
|
739
956
|
committedCollections: Set<CollectionId>;
|
|
740
957
|
failedCollections: Set<CollectionId>;
|
|
958
|
+
staleLoss?: boolean;
|
|
741
959
|
}> {
|
|
960
|
+
// Fan out the independent per-collection commit-with-retry concurrently, then
|
|
961
|
+
// aggregate the committed/failed partition from the settled results.
|
|
962
|
+
const outcomes = await Promise.allSettled(
|
|
963
|
+
Array.from(pendedBlockIds.entries()).map(([collectionId, blockIds]) =>
|
|
964
|
+
this.commitCollection(actionId, criticalBlockIds, collectionId, blockIds)
|
|
965
|
+
)
|
|
966
|
+
);
|
|
967
|
+
|
|
742
968
|
const committedCollections = new Set<CollectionId>();
|
|
743
969
|
const failedCollections = new Set<CollectionId>();
|
|
744
|
-
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
970
|
+
const errors: string[] = [];
|
|
971
|
+
// Classify failures: a returned stale loss (someone committed a newer rev) is retryable after
|
|
972
|
+
// a re-read; a thrown/transient-exhausted or structural failure is not. staleLoss holds only
|
|
973
|
+
// if EVERY failure was a stale loss — a single hard failure makes the whole attempt not worth
|
|
974
|
+
// re-driving.
|
|
975
|
+
let anyStale = false;
|
|
976
|
+
let anyHard = false;
|
|
977
|
+
for (const outcome of outcomes) {
|
|
978
|
+
if (outcome.status === 'fulfilled') {
|
|
979
|
+
const { collectionId, committed, error, stale } = outcome.value;
|
|
980
|
+
if (committed) {
|
|
981
|
+
committedCollections.add(collectionId);
|
|
982
|
+
} else {
|
|
983
|
+
failedCollections.add(collectionId);
|
|
984
|
+
if (error) errors.push(error);
|
|
985
|
+
if (stale) anyStale = true; else anyHard = true;
|
|
986
|
+
}
|
|
987
|
+
} else {
|
|
988
|
+
// commitCollection resolves rather than rejects, but treat any unexpected
|
|
989
|
+
// rejection as a (hard) failure so the partitioned sets stay honest.
|
|
990
|
+
errors.push(outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason));
|
|
991
|
+
anyHard = true;
|
|
756
992
|
}
|
|
993
|
+
}
|
|
757
994
|
|
|
758
|
-
|
|
759
|
-
|
|
995
|
+
if (failedCollections.size > 0 || errors.length > 0) {
|
|
996
|
+
return {
|
|
997
|
+
success: false,
|
|
998
|
+
error: errors.join('; ') || 'Commit failed',
|
|
999
|
+
committedCollections,
|
|
1000
|
+
failedCollections,
|
|
1001
|
+
staleLoss: anyStale && !anyHard,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
760
1004
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
blockIds.includes(blockId)
|
|
764
|
-
);
|
|
1005
|
+
return { success: true, committedCollections, failedCollections };
|
|
1006
|
+
}
|
|
765
1007
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1008
|
+
/**
|
|
1009
|
+
* Commit a single collection's pended blocks, retrying transient failures up to three
|
|
1010
|
+
* times (forward recovery). Always resolves — success is carried in the returned
|
|
1011
|
+
* `committed` flag — so the fan-out in {@link commitPhase} can aggregate every result.
|
|
1012
|
+
*/
|
|
1013
|
+
private async commitCollection(
|
|
1014
|
+
actionId: ActionId,
|
|
1015
|
+
criticalBlockIds: BlockId[],
|
|
1016
|
+
collectionId: CollectionId,
|
|
1017
|
+
blockIds: BlockId[]
|
|
1018
|
+
): Promise<{ collectionId: CollectionId; committed: boolean; error?: string; stale?: boolean }> {
|
|
1019
|
+
const collection = this.collections.get(collectionId);
|
|
1020
|
+
if (!collection) {
|
|
1021
|
+
return { collectionId, committed: false, error: `Collection not found: ${collectionId}` };
|
|
1022
|
+
}
|
|
775
1023
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
};
|
|
1024
|
+
// Get revision
|
|
1025
|
+
const rev = collection.getNextRev();
|
|
1026
|
+
|
|
1027
|
+
// Find the critical block (log tail) for this collection
|
|
1028
|
+
const logTailBlockId = criticalBlockIds.find(blockId => blockIds.includes(blockId));
|
|
1029
|
+
if (!logTailBlockId) {
|
|
1030
|
+
return { collectionId, committed: false, error: `Log tail block not found for collection ${collectionId}` };
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Create commit request
|
|
1034
|
+
const commitRequest: CommitRequest = {
|
|
1035
|
+
actionId,
|
|
1036
|
+
blockIds,
|
|
1037
|
+
tailId: logTailBlockId,
|
|
1038
|
+
rev
|
|
1039
|
+
};
|
|
783
1040
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1041
|
+
// Retry ONLY transient/thrown failures (unreachable peers, timeout) — forward recovery.
|
|
1042
|
+
// A returned { success:false } is a permanent stale loss (someone committed a newer rev);
|
|
1043
|
+
// the identical request can never win, so return immediately without retrying. Either way
|
|
1044
|
+
// cancelPhase (run by coordinateTransaction on commitPhase failure) releases the pend
|
|
1045
|
+
// exactly once — commit itself no longer self-cancels.
|
|
1046
|
+
let lastTransientError: string | undefined;
|
|
1047
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1048
|
+
try {
|
|
787
1049
|
const commitResult = await this.transactor.commit(commitRequest);
|
|
788
1050
|
if (commitResult.success) {
|
|
789
|
-
committed
|
|
790
|
-
committedCollections.add(collectionId);
|
|
791
|
-
} else if (attempt === 2) {
|
|
792
|
-
failedCollections.add(collectionId);
|
|
793
|
-
return {
|
|
794
|
-
success: false,
|
|
795
|
-
error: `Commit failed for collection ${collectionId} after 3 attempts`,
|
|
796
|
-
committedCollections,
|
|
797
|
-
failedCollections
|
|
798
|
-
};
|
|
1051
|
+
return { collectionId, committed: true };
|
|
799
1052
|
}
|
|
1053
|
+
// Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
|
|
1054
|
+
// mark it retryable at the coordinator level (after a re-read advances the rev).
|
|
1055
|
+
return {
|
|
1056
|
+
collectionId,
|
|
1057
|
+
committed: false,
|
|
1058
|
+
stale: true,
|
|
1059
|
+
error: commitResult.reason ?? `Stale commit for collection ${collectionId}`
|
|
1060
|
+
};
|
|
1061
|
+
} catch (e) {
|
|
1062
|
+
lastTransientError = e instanceof Error ? e.message : String(e);
|
|
800
1063
|
}
|
|
801
1064
|
}
|
|
802
|
-
|
|
803
|
-
return { success: true, committedCollections, failedCollections };
|
|
1065
|
+
return { collectionId, committed: false, error: `Commit failed for collection ${collectionId} after 3 attempts: ${lastTransientError}` };
|
|
804
1066
|
}
|
|
805
1067
|
|
|
806
1068
|
/**
|
|
@@ -814,10 +1076,17 @@ export class TransactionCoordinator {
|
|
|
814
1076
|
pendedBlockIds: Map<CollectionId, BlockId[]>,
|
|
815
1077
|
excludeCollections?: Set<CollectionId>
|
|
816
1078
|
): Promise<void> {
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1079
|
+
// Fan out the per-collection cancels concurrently. Each is best-effort: a cancel
|
|
1080
|
+
// fault is logged and swallowed so it cannot mask the pend/commit failure that
|
|
1081
|
+
// triggered this sweep, and so one failed cancel does not abort the others.
|
|
1082
|
+
const cancels = Array.from(pendedBlockIds.entries())
|
|
1083
|
+
.filter(([collectionId]) => !excludeCollections?.has(collectionId))
|
|
1084
|
+
.map(([collectionId, blockIds]) =>
|
|
1085
|
+
this.transactor.cancel({ actionId, blockIds }).catch(err => {
|
|
1086
|
+
log('cancelPhase: best-effort cancel failed collection=%s: %o', collectionId, err);
|
|
1087
|
+
})
|
|
1088
|
+
);
|
|
1089
|
+
await Promise.all(cancels);
|
|
821
1090
|
}
|
|
822
1091
|
|
|
823
1092
|
}
|