@optimystic/db-core 0.25.1 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
- package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
- package/dist/src/cohort-topic/membership/verifier.js +17 -0
- package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
- package/dist/src/cohort-topic/promotion.d.ts +25 -0
- package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
- package/dist/src/cohort-topic/promotion.js +58 -11
- package/dist/src/cohort-topic/promotion.js.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
- package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/primitives.js +6 -0
- package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
- package/dist/src/cohort-topic/wire/types.d.ts +5 -0
- package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/validate.js +10 -19
- package/dist/src/cohort-topic/wire/validate.js.map +1 -1
- package/dist/src/collection/collection.d.ts +8 -1
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +19 -3
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts +2 -2
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +3 -3
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/struct.d.ts +5 -5
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/testing/index.d.ts +1 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/refresh-probe.d.ts +31 -0
- package/dist/src/testing/refresh-probe.d.ts.map +1 -0
- package/dist/src/testing/refresh-probe.js +39 -0
- package/dist/src/testing/refresh-probe.js.map +1 -0
- package/dist/src/testing/test-transactor.d.ts +58 -5
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +87 -4
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +90 -6
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +311 -90
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +42 -0
- package/dist/src/transaction/errors.d.ts.map +1 -1
- package/dist/src/transaction/errors.js +50 -0
- package/dist/src/transaction/errors.js.map +1 -1
- package/dist/src/transaction/index.d.ts +1 -1
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js +1 -1
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transaction/session.d.ts +7 -3
- package/dist/src/transaction/session.d.ts.map +1 -1
- package/dist/src/transaction/session.js +7 -3
- package/dist/src/transaction/session.js.map +1 -1
- package/dist/src/transaction/transaction.d.ts +12 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transaction/transaction.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +6 -26
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +4 -6
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +4 -6
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/atomic.d.ts.map +1 -1
- package/dist/src/transform/atomic.js +7 -0
- package/dist/src/transform/atomic.js.map +1 -1
- package/dist/src/transform/base-pins.d.ts +48 -0
- package/dist/src/transform/base-pins.d.ts.map +1 -0
- package/dist/src/transform/base-pins.js +65 -0
- package/dist/src/transform/base-pins.js.map +1 -0
- package/dist/src/transform/digest.d.ts +4 -2
- package/dist/src/transform/digest.d.ts.map +1 -1
- package/dist/src/transform/digest.js +14 -19
- package/dist/src/transform/digest.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +31 -3
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +105 -13
- package/dist/src/transform/tracker.js.map +1 -1
- package/package.json +1 -1
- package/src/cohort-topic/membership/verifier.ts +487 -461
- package/src/cohort-topic/promotion.ts +86 -14
- package/src/cohort-topic/wire/primitives.ts +195 -188
- package/src/cohort-topic/wire/types.ts +480 -475
- package/src/cohort-topic/wire/validate.ts +504 -512
- package/src/collection/collection.ts +19 -3
- package/src/matchmaking/wire.ts +605 -603
- package/src/network/struct.ts +5 -5
- package/src/testing/index.ts +1 -0
- package/src/testing/refresh-probe.ts +52 -0
- package/src/testing/test-transactor.ts +100 -4
- package/src/transaction/coordinator.ts +312 -99
- package/src/transaction/errors.ts +141 -91
- package/src/transaction/index.ts +1 -1
- package/src/transaction/session.ts +7 -3
- package/src/transaction/transaction.ts +12 -2
- package/src/transactor/network-transactor.ts +1025 -1045
- package/src/transactor/transactor-source.ts +170 -172
- package/src/transform/atomic.ts +6 -0
- package/src/transform/base-pins.ts +83 -0
- package/src/transform/digest.ts +14 -19
- package/src/transform/tracker.ts +108 -15
|
@@ -2,14 +2,14 @@ import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, Commi
|
|
|
2
2
|
import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
|
|
3
3
|
import type { PeerId } from "../network/types.js";
|
|
4
4
|
import { isConflictFailure } from "../network/stale-failure.js";
|
|
5
|
-
import type { Collection } from "../collection/collection.js";
|
|
5
|
+
import type { Collection, CollectionSnapshot } from "../collection/collection.js";
|
|
6
6
|
import type { SyncOptions } from "../collection/index.js";
|
|
7
7
|
import { isTransactionExpired, clampPriority } from "./transaction.js";
|
|
8
8
|
import { Log } from "../log/log.js";
|
|
9
9
|
import { blockIdsForTransforms } from "../transform/helpers.js";
|
|
10
10
|
import { computeBlockContentDigests, blockDigestsField } from "../transform/digest.js";
|
|
11
11
|
import { collectOperations, hashOperations } from "./operations-hash.js";
|
|
12
|
-
import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
|
|
12
|
+
import { CoordinatorConcurrentStampError, CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
|
|
13
13
|
import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
|
|
14
14
|
import { createLogger } from "../logger.js";
|
|
15
15
|
|
|
@@ -57,13 +57,43 @@ class PendRejectedError extends Error {
|
|
|
57
57
|
* - Commit transactions by running consensus phases (GATHER, PEND, COMMIT)
|
|
58
58
|
*/
|
|
59
59
|
export class TransactionCoordinator {
|
|
60
|
-
/** Per-stampId tracking:
|
|
60
|
+
/** Per-stampId tracking: the pre-staging snapshot of every collection the stamp may have
|
|
61
|
+
* staged into, which is everything {@link rollback} needs to rewind it.
|
|
62
|
+
*
|
|
63
|
+
* INVARIANT: `stampData.size <= 1` — at most ONE open stamp per coordinator, enforced by the
|
|
64
|
+
* guards in {@link applyActions} and {@link commit} (which throw
|
|
65
|
+
* {@link CoordinatorConcurrentStampError}) and {@link execute} (which returns it as a failure
|
|
66
|
+
* result). The registered collections hold exactly one staged state — one tracker transform
|
|
67
|
+
* set and one pending action queue each, shared by every open stamp — so two concurrent stamps
|
|
68
|
+
* would read each other's uncommitted rows and a commit of either would write the other's
|
|
69
|
+
* staged actions into its own durable log entry. A stamp is released by commit (success or
|
|
70
|
+
* partial) or by rollback; a CLEAN commit failure keeps its entry so `rollback(stampId)`
|
|
71
|
+
* stays a complete recovery, meaning an abandoned failed commit holds the coordinator against
|
|
72
|
+
* new stamps until it is rolled back. The guard sees only what opens a stamp, so two
|
|
73
|
+
* coordinators sharing collection instances — and writers that stage only via Tree.stage /
|
|
74
|
+
* Collection.act, which open none — stay the caller's contract ("own bridge per writer" —
|
|
75
|
+
* docs/transactions.md), not this map's.
|
|
76
|
+
*
|
|
77
|
+
* Each snapshot holds BOTH halves of a collection's staged state — tracker transforms AND
|
|
78
|
+
* the pending action queue — as one {@link Collection.snapshotPending} value. Restoring only
|
|
79
|
+
* the transforms would leave a rolled-back stamp's actions queued, where the next commit on
|
|
80
|
+
* that collection would write them into ITS durable log entry (and a conflicting sync's
|
|
81
|
+
* `replayActions` would re-apply them into the tracker as live data). Keeping the pair in one
|
|
82
|
+
* value makes half-restored staged state unrepresentable here.
|
|
83
|
+
*
|
|
84
|
+
* `preSnapshot` is keyed by the {@link Collection} INSTANCE, not by {@link CollectionId}: the
|
|
85
|
+
* collection map this coordinator is handed is owned by its caller (the Quereus adapter's
|
|
86
|
+
* registry), which may replace the instance stored under an id when a table re-initializes.
|
|
87
|
+
* An id-keyed snapshot would then restore the OLD instance's staged state onto the NEW one;
|
|
88
|
+
* instance keys make that unrepresentable — the new instance simply gets its own capture at
|
|
89
|
+
* the next reconcile, and the stale entry restores a detached object nobody reads.
|
|
90
|
+
*
|
|
91
|
+
* Capture is LAZY: a collection enters the map at the first {@link applyActions} after it
|
|
92
|
+
* appears in `this.collections` (see {@link captureUncaptured}), so a collection registered
|
|
93
|
+
* mid-transaction is still captured before anything stages into it. */
|
|
61
94
|
private stampData = new Map<string, {
|
|
62
|
-
|
|
63
|
-
preSnapshot: Map<CollectionId, Transforms>;
|
|
64
|
-
actionBatches: CollectionActions[][];
|
|
95
|
+
preSnapshot: Map<Collection<any>, CollectionSnapshot<any>>;
|
|
65
96
|
}>();
|
|
66
|
-
private nextStampOrder = 0;
|
|
67
97
|
|
|
68
98
|
constructor(
|
|
69
99
|
private readonly transactor: ITransactor,
|
|
@@ -84,25 +114,84 @@ export class TransactionCoordinator {
|
|
|
84
114
|
actions: CollectionActions[],
|
|
85
115
|
stampId: string
|
|
86
116
|
): Promise<void> {
|
|
87
|
-
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
let data = this.stampData.get(stampId);
|
|
118
|
+
if (!data) {
|
|
119
|
+
// At most one open stamp (see the invariant on stampData). Checked inside this method's
|
|
120
|
+
// await-free prologue — the check, the entry creation, and the capture below run as one
|
|
121
|
+
// atomic step, so a second stamp cannot register between a TRACKED stamp's commit guard
|
|
122
|
+
// and its log append: that stamp's entry stays in the map for the whole commit span, so
|
|
123
|
+
// this check keeps seeing it. An UNTRACKED commit (the Tree.stage / Collection.act path,
|
|
124
|
+
// which creates no entry) leaves the map empty and so cannot be interlocked this way —
|
|
125
|
+
// that is the documented out-of-scope case, not a hole this guard closes.
|
|
126
|
+
const open = this.openStampOtherThan(stampId);
|
|
127
|
+
if (open !== undefined) {
|
|
128
|
+
throw new CoordinatorConcurrentStampError(open, stampId);
|
|
92
129
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
preSnapshot: snapshot,
|
|
96
|
-
actionBatches: []
|
|
97
|
-
});
|
|
130
|
+
data = { preSnapshot: new Map() };
|
|
131
|
+
this.stampData.set(stampId, data);
|
|
98
132
|
}
|
|
99
|
-
|
|
133
|
+
|
|
134
|
+
// Runs on EVERY call, and stays await-free through to applyActionsRaw — see the method doc.
|
|
135
|
+
this.captureUncaptured(data.preSnapshot);
|
|
100
136
|
|
|
101
137
|
await this.applyActionsRaw(actions, stampId);
|
|
102
138
|
}
|
|
103
139
|
|
|
140
|
+
/** The id of the open stamp {@link stampData} tracks when it is not `stampId`; undefined when
|
|
141
|
+
* no OTHER stamp is open. The single-open-stamp invariant on {@link stampData} is what makes
|
|
142
|
+
* "the" (singular) correct here. */
|
|
143
|
+
private openStampOtherThan(stampId: string): string | undefined {
|
|
144
|
+
for (const open of this.stampData.keys()) {
|
|
145
|
+
if (open !== stampId) return open;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
104
150
|
/**
|
|
105
|
-
*
|
|
151
|
+
* Capture the pre-staging state of every registered collection `into` does not already hold.
|
|
152
|
+
*
|
|
153
|
+
* Called on EVERY {@link applyActions}, not only a stamp's first. `this.collections` is owned
|
|
154
|
+
* by the caller (the Quereus adapter's registry) and grows as tables open mid-transaction; a
|
|
155
|
+
* collection registered after the stamp's first call would otherwise be visible to commit but
|
|
156
|
+
* absent from every snapshot, so {@link rollback} would never visit it and its staged state
|
|
157
|
+
* would survive into the next transaction's durable log entry.
|
|
158
|
+
*
|
|
159
|
+
* `applyActions` is the right capture point because it precedes any staging that call performs
|
|
160
|
+
* — and on the Quereus path, where the vtab stages directly into the trees, the awaited
|
|
161
|
+
* empty-actions applyActions call exists precisely to be that pre-stage barrier (see
|
|
162
|
+
* optimystic-module.ts's "applyActions before any collection.stage" invariant).
|
|
163
|
+
*
|
|
164
|
+
* The already-captured guard is load-bearing: registration is idempotent (the adapter
|
|
165
|
+
* re-registers already-open index trees), and re-capturing a collection this stamp already
|
|
166
|
+
* staged into would record a DIRTY state as "before", making rollback preserve the very
|
|
167
|
+
* actions it must discard.
|
|
168
|
+
*
|
|
169
|
+
* Synchronous and await-free BY CONTRACT, for the same reason execute()'s pre-stage snapshot
|
|
170
|
+
* loop is: an interleaved stage would land inside a snapshot. Callers must not await between
|
|
171
|
+
* this call and the staging it guards.
|
|
172
|
+
*
|
|
173
|
+
* NOTE: this runs per applyActions call rather than once per transaction. Per-call cost for an
|
|
174
|
+
* already-captured collection is one Map.has; snapshotPending (which deep-copies the transforms)
|
|
175
|
+
* still runs at most once per collection per stamp. Unmeasured. If the registered-collection
|
|
176
|
+
* count ever grows large enough for the scan itself to matter, track a per-stamp "collections
|
|
177
|
+
* map size at last capture" rather than dropping the reconcile.
|
|
178
|
+
*/
|
|
179
|
+
private captureUncaptured(into: Map<Collection<any>, CollectionSnapshot<any>>): void {
|
|
180
|
+
for (const col of this.collections.values()) {
|
|
181
|
+
if (into.has(col)) continue;
|
|
182
|
+
into.set(col, col.snapshotPending());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Stage each action against its collection, tagged `transaction: stampId`, without touching
|
|
188
|
+
* the {@link stampData} bookkeeping.
|
|
189
|
+
*
|
|
190
|
+
* That tag is a contract, not an implementation detail: it is the provenance the rollback tests
|
|
191
|
+
* read (`expectNoActionsFromStamp` in coordinator-rollback-pending.spec.ts) to tell one stamp's
|
|
192
|
+
* queued actions from another's. Only {@link applyActions} calls this; the split exists just to
|
|
193
|
+
* keep the single-open-stamp guard and the pre-stage capture readable apart from the staging
|
|
194
|
+
* loop.
|
|
106
195
|
*/
|
|
107
196
|
private async applyActionsRaw(
|
|
108
197
|
actions: CollectionActions[],
|
|
@@ -143,6 +232,15 @@ export class TransactionCoordinator {
|
|
|
143
232
|
* @param options - Retry knobs; shares the {@link SyncOptions} vocabulary with `Collection.sync`.
|
|
144
233
|
*/
|
|
145
234
|
async commit(transaction: Transaction, options?: SyncOptions): Promise<void> {
|
|
235
|
+
// At most one open stamp (see the invariant on stampData). Checked once, BEFORE the retry
|
|
236
|
+
// loop — this is a hard failure, never retryable: the open stamp must be committed or
|
|
237
|
+
// rolled back first. This guard is what catches a caller that staged directly (Tree.stage /
|
|
238
|
+
// Collection.act, which creates no stampData entry) and commits while a sibling stamp is
|
|
239
|
+
// tracked — such a commit would sweep the sibling's queued actions into its own log entry.
|
|
240
|
+
const open = this.openStampOtherThan(transaction.stamp.id);
|
|
241
|
+
if (open !== undefined) {
|
|
242
|
+
throw new CoordinatorConcurrentStampError(open, transaction.stamp.id);
|
|
243
|
+
}
|
|
146
244
|
const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
|
|
147
245
|
const baseBackoffMs = options?.baseBackoffMs ?? DefaultBaseBackoffMs;
|
|
148
246
|
const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
|
|
@@ -347,7 +445,7 @@ export class TransactionCoordinator {
|
|
|
347
445
|
// failed commit leaves each tracker exactly as it was: a retry re-appends cleanly
|
|
348
446
|
// (no duplicate log entry) and a directly-staged tree's rollback (which no-ops
|
|
349
447
|
// when the stamp was never tracked via applyActions) has nothing poisoned to undo.
|
|
350
|
-
const preCommitSnapshots = new Map<CollectionId,
|
|
448
|
+
const preCommitSnapshots = new Map<CollectionId, CollectionSnapshot<any>>();
|
|
351
449
|
for (const { collectionId, collection } of collectionData) {
|
|
352
450
|
preCommitSnapshots.set(collectionId, collection.snapshotPending());
|
|
353
451
|
}
|
|
@@ -471,66 +569,61 @@ export class TransactionCoordinator {
|
|
|
471
569
|
collection.clearPendingActions();
|
|
472
570
|
}
|
|
473
571
|
|
|
474
|
-
// Clean up stamp tracking data
|
|
572
|
+
// Clean up stamp tracking data. The reset+clear above is collection-wide, which is safe
|
|
573
|
+
// precisely because the coordinator enforces at most ONE open stamp (the invariant on
|
|
574
|
+
// stampData): no sibling stamp's snapshot exists to go stale against this commit.
|
|
475
575
|
this.stampData.delete(transaction.stamp.id);
|
|
476
576
|
}
|
|
477
577
|
|
|
478
578
|
/**
|
|
479
579
|
* Rollback a transaction (undo only the given stampId's applied actions).
|
|
480
580
|
*
|
|
481
|
-
* Restores
|
|
482
|
-
*
|
|
483
|
-
*
|
|
581
|
+
* Restores each collection's staged state — tracker transforms and the pending action
|
|
582
|
+
* queue — to the snapshot taken before the stampId's first applyActions call. Because the
|
|
583
|
+
* coordinator allows at most ONE open stamp (the invariant on {@link stampData}), there is
|
|
584
|
+
* never a sibling stamp whose staged state that restore could clobber, so rewinding is the
|
|
585
|
+
* whole of rollback.
|
|
586
|
+
*
|
|
587
|
+
* Rolling back a stamp with no tracked entry — never opened, or already released by a commit
|
|
588
|
+
* or an earlier rollback — is a no-op.
|
|
589
|
+
*
|
|
590
|
+
* NOTE: this method is complete only while that invariant holds. If the guard is ever relaxed
|
|
591
|
+
* to permit concurrent stamps, a plain rewind stops being enough — it would discard a sibling
|
|
592
|
+
* stamp's staged state along with this one — and the per-collection-earliest-capture plus
|
|
593
|
+
* survivor-replay this method used to carry has to come back. Recover it from commit 26718f8a
|
|
594
|
+
* (`ticket(implement): coordinator-drop-multi-stamp-replay-machinery`) rather than re-deriving.
|
|
484
595
|
*
|
|
485
596
|
* @param stampId - The transaction stamp ID to rollback
|
|
486
597
|
*/
|
|
487
598
|
async rollback(stampId: string): Promise<void> {
|
|
488
|
-
// NOTE: unlike the commit path, this
|
|
489
|
-
//
|
|
490
|
-
//
|
|
491
|
-
// rollback
|
|
492
|
-
//
|
|
599
|
+
// NOTE: unlike the commit path, this overwrites BOTH halves of each participant's staged
|
|
600
|
+
// state — tracker transforms AND the pending action queue — WITHOUT holding their instance
|
|
601
|
+
// latches. Safe today because a session drives abort and commit from one call path, so a
|
|
602
|
+
// rollback cannot overlap a commit span on the same collections. The hazard class is the
|
|
603
|
+
// same one the transforms-only reset already carried: a concurrent act/sync would already
|
|
604
|
+
// have raced `tracker.reset`. If rollback ever becomes reachable concurrently with a commit
|
|
605
|
+
// (a background abort, a second coordinator sharing collection instances), latch the
|
|
606
|
+
// participants here the way commitOnce does.
|
|
493
607
|
const data = this.stampData.get(stampId);
|
|
494
608
|
if (!data) return;
|
|
495
609
|
|
|
496
610
|
this.stampData.delete(stampId);
|
|
497
611
|
|
|
498
|
-
//
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// Restore to the earliest snapshot
|
|
515
|
-
for (const [collectionId, transforms] of earliestSnapshot) {
|
|
516
|
-
const collection = this.collections.get(collectionId);
|
|
517
|
-
if (collection) {
|
|
518
|
-
collection.tracker.reset(structuredClone(transforms));
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Replay all remaining stamps' batches in order
|
|
523
|
-
for (const [replayStampId, replayData] of toReplay) {
|
|
524
|
-
// Update the snapshot to reflect current (post-replay) state
|
|
525
|
-
const newSnapshot = new Map<CollectionId, Transforms>();
|
|
526
|
-
for (const [id, col] of this.collections) {
|
|
527
|
-
newSnapshot.set(id, structuredClone(col.tracker.transforms));
|
|
528
|
-
}
|
|
529
|
-
replayData.preSnapshot = newSnapshot;
|
|
530
|
-
|
|
531
|
-
for (const actionBatch of replayData.actionBatches) {
|
|
532
|
-
await this.applyActionsRaw(actionBatch, replayStampId);
|
|
533
|
-
}
|
|
612
|
+
// Restore each captured collection — transforms AND pending queue together, so the
|
|
613
|
+
// rolled-back stamp's actions leave the queue instead of being written into the next
|
|
614
|
+
// transaction's durable log entry. `restorePending` deep-copies the transforms itself,
|
|
615
|
+
// so no structuredClone here. Restoring through the CAPTURED instance (rather than
|
|
616
|
+
// re-looking-up by id) is what keeps a mid-transaction instance swap under one id from
|
|
617
|
+
// pushing the old instance's staged state onto the new one.
|
|
618
|
+
// NOTE: this also discards actions staged OUTSIDE any tracked stamp (the Tree.stage /
|
|
619
|
+
// deferred-DML path, which calls Collection.act directly and creates no stampData entry)
|
|
620
|
+
// that landed after that collection's capture — including on a collection registered
|
|
621
|
+
// mid-transaction, whose capture is simply later than the eagerly-captured ones.
|
|
622
|
+
// Accepted: the tracker half already discards their transforms, and symmetric is the safer
|
|
623
|
+
// state — leaving such an action queued while its transforms are gone is exactly the
|
|
624
|
+
// phantom that a conflicting sync's replayActions would resurrect as live data.
|
|
625
|
+
for (const [collection, snapshot] of data.preSnapshot) {
|
|
626
|
+
collection.restorePending(snapshot);
|
|
534
627
|
}
|
|
535
628
|
}
|
|
536
629
|
|
|
@@ -621,6 +714,24 @@ export class TransactionCoordinator {
|
|
|
621
714
|
return { success: true }; // Nothing to do
|
|
622
715
|
}
|
|
623
716
|
|
|
717
|
+
// At most one open stamp (see the invariant on stampData). execute() reports failures as
|
|
718
|
+
// results rather than throws (matching its conversion of applyActions' "Collection not
|
|
719
|
+
// found" throw below), so the refusal is a failure result here where applyActions and
|
|
720
|
+
// commit throw.
|
|
721
|
+
//
|
|
722
|
+
// Deliberately BELOW the empty-actions short-circuit above: a transaction that stages
|
|
723
|
+
// nothing cannot mix its state with the open stamp's, so a read-only execute is allowed to
|
|
724
|
+
// run alongside one. Hoisting this to the top of the method would refuse those too — see
|
|
725
|
+
// the "does not refuse a second stamp that stages nothing" case in
|
|
726
|
+
// coordinator-single-stamp.spec.ts. The engine has already run by this point, which costs
|
|
727
|
+
// nothing here: reaching this line means the engine was a pure translator (a
|
|
728
|
+
// side-effecting engine returns EMPTY actions and short-circuits above, and its own
|
|
729
|
+
// staging goes through applyActions, which guards).
|
|
730
|
+
const openStamp = this.openStampOtherThan(transaction.stamp.id);
|
|
731
|
+
if (openStamp !== undefined) {
|
|
732
|
+
return { success: false, error: new CoordinatorConcurrentStampError(openStamp, transaction.stamp.id).message };
|
|
733
|
+
}
|
|
734
|
+
|
|
624
735
|
// 1b. Stage the returned actions into the collection trackers.
|
|
625
736
|
//
|
|
626
737
|
// Reaching here means the engine RETURNED non-empty actions — i.e. the pure-
|
|
@@ -632,11 +743,41 @@ export class TransactionCoordinator {
|
|
|
632
743
|
// method merely re-read the already-staged trackers; that side effect is gone, so
|
|
633
744
|
// the application must happen explicitly here. A side-effecting engine that
|
|
634
745
|
// applied internally would instead return EMPTY actions and short-circuit at the
|
|
635
|
-
//
|
|
746
|
+
// empty-actions check above.)
|
|
636
747
|
//
|
|
637
748
|
// applyActions() throws if a referenced collection is not registered — the same
|
|
638
749
|
// "Collection not found" the engine's side-effecting apply used to surface. Convert
|
|
639
750
|
// it back into a failure result so execute() keeps its return contract.
|
|
751
|
+
|
|
752
|
+
// Pre-staging snapshot, one per DISTINCT participating collection, captured here so the
|
|
753
|
+
// partial-commit branch below can unwind the collections that did NOT land. The unwind
|
|
754
|
+
// point is "before execute() staged anything", not "before the log append": unlike
|
|
755
|
+
// commit(), a re-drive of execute() re-runs the engine and re-stages every collection it
|
|
756
|
+
// names, so leaving this attempt's actions in the pending queue would double-stage them.
|
|
757
|
+
// That is also exactly the state rollback() would have restored, which is why dropping
|
|
758
|
+
// the stamp on that branch loses nothing.
|
|
759
|
+
//
|
|
760
|
+
// snapshotPending() is synchronous and latch-free — keep it that way: NO await may sit
|
|
761
|
+
// between this loop and the applyActions() call below, or an interleaved stage would be
|
|
762
|
+
// captured inside the snapshot. Because the whole loop runs before ANY staging, an engine
|
|
763
|
+
// naming one collection in two batches yields the state before EITHER batch whichever
|
|
764
|
+
// batch wins the key — so the dedupe is a COST guard (snapshotPending deep-copies the
|
|
765
|
+
// transforms and structuredClones the action context), not a correctness one. An
|
|
766
|
+
// unregistered collection is skipped; applyActions throws on it moments later and the
|
|
767
|
+
// catch below converts that to a failure result, so the map is never read.
|
|
768
|
+
//
|
|
769
|
+
// NOTE: this runs unconditionally, so the overwhelmingly common all-succeed execute() pays
|
|
770
|
+
// one deep transforms copy + one structuredClone of the action context per participant to
|
|
771
|
+
// serve a rare branch. Unmeasured, and symmetric with commitOnceLatched, which pays the
|
|
772
|
+
// same on every commit. If execute() ever shows up hot in a profile, the way out is a
|
|
773
|
+
// copy-on-first-stage snapshot, not dropping the restore.
|
|
774
|
+
const preStageSnapshots = new Map<CollectionId, CollectionSnapshot<any>>();
|
|
775
|
+
for (const { collectionId } of result.actions) {
|
|
776
|
+
if (preStageSnapshots.has(collectionId)) continue;
|
|
777
|
+
const collection = this.collections.get(collectionId);
|
|
778
|
+
if (collection) preStageSnapshots.set(collectionId, collection.snapshotPending());
|
|
779
|
+
}
|
|
780
|
+
|
|
640
781
|
try {
|
|
641
782
|
await this.applyActions(result.actions, transaction.stamp.id);
|
|
642
783
|
} catch (error) {
|
|
@@ -647,20 +788,49 @@ export class TransactionCoordinator {
|
|
|
647
788
|
|
|
648
789
|
// 2. Build a log entry per collection from the now-staged tracker transforms.
|
|
649
790
|
//
|
|
650
|
-
// NOTE: like commit(), this loop appends a log entry into each collection's
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
791
|
+
// NOTE: like commit(), this loop appends a log entry into each collection's tracker.
|
|
792
|
+
// What happens to that appended-but-uncommitted state on failure depends on whether
|
|
793
|
+
// anything landed durably:
|
|
794
|
+
//
|
|
795
|
+
// - Failure BEFORE any durable commit (engine failure, log-append failure, or a
|
|
796
|
+
// coordination failure with an empty committed set): the failure returns below do NOT
|
|
797
|
+
// restore, so the appended entries survive in the trackers and `stampData` is kept.
|
|
798
|
+
// That is deliberate — the actions were tracked via applyActions(), so rollback(stampId)
|
|
799
|
+
// is a valid AND complete recovery there: its capture predates this staging and covers
|
|
800
|
+
// every registered collection, including ones this call never touched. Do not add blind
|
|
801
|
+
// restores to these paths.
|
|
802
|
+
// - PARTIAL landing (some collections durably committed, others lost): the committed half
|
|
803
|
+
// gets the success-path fold, the failed half is restored to the pre-staging snapshot
|
|
804
|
+
// captured above, and `stampData` is dropped — because an all-or-nothing rollback() run
|
|
805
|
+
// after a partial landing would rewind the collection that DID commit, re-staging
|
|
806
|
+
// already-durable actions. Same disposition as commitOnceLatched. See that branch below.
|
|
659
807
|
const tApply = Date.now();
|
|
660
808
|
const collectionTransforms = new Map<CollectionId, Transforms>();
|
|
661
809
|
const criticalBlocks = new Map<CollectionId, BlockId>();
|
|
662
810
|
const actionResults = new Map<CollectionId, any[]>();
|
|
663
|
-
|
|
811
|
+
|
|
812
|
+
// Coalesce the engine's batches to ONE per collection. Nothing stops an engine from
|
|
813
|
+
// emitting two `CollectionActions` entries naming the same collection (with the built-in
|
|
814
|
+
// ActionsEngine that is just two statements against one table), but everything downstream
|
|
815
|
+
// of here is written per-PARTICIPANT, not per-batch: the apply loop would append two log
|
|
816
|
+
// entries stamped with the same revision (the revision only advances at recordCommitted),
|
|
817
|
+
// the id-keyed maps below would discard the first batch's entry, and both folds would call
|
|
818
|
+
// recordCommitted twice — the second throwing on the revision it already advanced past,
|
|
819
|
+
// AFTER the transaction committed durably. One log entry per collection per transaction is
|
|
820
|
+
// the invariant commitOnceLatched holds by construction (it appends one entry carrying that
|
|
821
|
+
// collection's whole pending queue), so match it here by grouping instead.
|
|
822
|
+
//
|
|
823
|
+
// First-appearance collection order, and each collection's own actions in the order the
|
|
824
|
+
// engine emitted them. The STAGING above deliberately ran on the original, un-coalesced
|
|
825
|
+
// list: staging order is what a re-executing validator reproduces, so it stays exactly as
|
|
826
|
+
// the engine emitted it. Grouping is only for the log-append and fold phase below.
|
|
827
|
+
const batches = new Map<CollectionId, unknown[]>();
|
|
828
|
+
for (const { collectionId, actions } of result.actions) {
|
|
829
|
+
const existing = batches.get(collectionId);
|
|
830
|
+
if (existing) existing.push(...actions);
|
|
831
|
+
else batches.set(collectionId, [...actions]);
|
|
832
|
+
}
|
|
833
|
+
const allCollectionIds = [...batches.keys()];
|
|
664
834
|
// Same single-capture rev threading as commitOnce: stamped at the log append below,
|
|
665
835
|
// named again at pend, commit, and recordCommitted.
|
|
666
836
|
const pendedRevs = new Map<CollectionId, number>();
|
|
@@ -668,21 +838,26 @@ export class TransactionCoordinator {
|
|
|
668
838
|
// Hold each participating collection's instance latch for the commit span, same
|
|
669
839
|
// discipline as commitOnce (sorted acquisition; see the comment there). Acquired only
|
|
670
840
|
// AFTER applyActions above: collection.act takes the same non-reentrant instance latch
|
|
671
|
-
// itself, so latching earlier would deadlock.
|
|
672
|
-
// instance's latch twice would also deadlock
|
|
673
|
-
//
|
|
841
|
+
// itself, so latching earlier would deadlock. The list must be DISTINCT — taking one
|
|
842
|
+
// instance's latch twice would also deadlock — which the grouping above now guarantees
|
|
843
|
+
// upstream (`allCollectionIds` is `batches.keys()`) instead of at the acquisition.
|
|
844
|
+
// Released in the finally: execute has early failure returns.
|
|
674
845
|
const latchReleases: (() => void)[] = [];
|
|
675
846
|
try {
|
|
676
|
-
|
|
847
|
+
// NOTE: deadlock-freedom against a concurrent commitOnce needs BOTH paths to acquire in
|
|
848
|
+
// the SAME order, not merely each in a sorted one. `CollectionId` is a string, so this
|
|
849
|
+
// default `.sort()` and commitOnce's explicit `<`/`>` comparator agree today. If either
|
|
850
|
+
// spelling changes — a custom comparator, a non-string id — change both together.
|
|
851
|
+
for (const collectionId of [...allCollectionIds].sort()) {
|
|
677
852
|
const collection = this.collections.get(collectionId);
|
|
678
853
|
if (collection) {
|
|
679
854
|
latchReleases.push(await collection.acquireLatch());
|
|
680
855
|
}
|
|
681
856
|
}
|
|
682
857
|
|
|
683
|
-
for (const
|
|
858
|
+
for (const [collectionId, actions] of batches) {
|
|
684
859
|
const applyResult = await this.applyActionsToCollection(
|
|
685
|
-
|
|
860
|
+
{ collectionId, actions },
|
|
686
861
|
transaction,
|
|
687
862
|
allCollectionIds
|
|
688
863
|
);
|
|
@@ -691,10 +866,10 @@ export class TransactionCoordinator {
|
|
|
691
866
|
return { success: false, error: applyResult.error };
|
|
692
867
|
}
|
|
693
868
|
|
|
694
|
-
collectionTransforms.set(
|
|
695
|
-
criticalBlocks.set(
|
|
696
|
-
actionResults.set(
|
|
697
|
-
pendedRevs.set(
|
|
869
|
+
collectionTransforms.set(collectionId, applyResult.transforms!);
|
|
870
|
+
criticalBlocks.set(collectionId, applyResult.logTailBlockId!);
|
|
871
|
+
actionResults.set(collectionId, applyResult.results!);
|
|
872
|
+
pendedRevs.set(collectionId, applyResult.rev!);
|
|
698
873
|
}
|
|
699
874
|
|
|
700
875
|
// 3. Compute operations hash for validation (order-independent; see commit()).
|
|
@@ -716,19 +891,42 @@ export class TransactionCoordinator {
|
|
|
716
891
|
if (!coordResult.success) {
|
|
717
892
|
log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
|
|
718
893
|
// Stop lying to the caller about a partial commit: if some collections durably
|
|
719
|
-
// committed, surface that set.
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
894
|
+
// committed, surface that set. The committed subset gets the full success-path
|
|
895
|
+
// local treatment (the same four steps as commitOnceLatched's partial-commit fold,
|
|
896
|
+
// in the same order — see the success fold below) so its trackers, read caches and
|
|
897
|
+
// pending queues aren't left mis-tracking already-durable state; the failed subset
|
|
898
|
+
// is unwound to its pre-staging snapshot, and the stamp tracking is dropped so no
|
|
899
|
+
// unsafe all-or-nothing undo handle survives (rollback() would rewind the winner).
|
|
900
|
+
// Kept await-free for the same reason the success fold is — restorePending is
|
|
901
|
+
// synchronous and latch-free by contract, so it is safe inside this latched span.
|
|
723
902
|
const committed = coordResult.committedCollections ?? new Set<CollectionId>();
|
|
724
903
|
if (committed.size > 0) {
|
|
725
|
-
for (const
|
|
726
|
-
const collection = this.collections.get(
|
|
727
|
-
if (collection
|
|
728
|
-
|
|
904
|
+
for (const collectionId of batches.keys()) {
|
|
905
|
+
const collection = this.collections.get(collectionId);
|
|
906
|
+
if (!collection) continue;
|
|
907
|
+
if (committed.has(collectionId)) {
|
|
908
|
+
const rev = collection.recordCommitted(transaction.id, pendedRevs.get(collectionId)!);
|
|
909
|
+
collection.applyCommittedToCache(collectionTransforms.get(collectionId)!, rev);
|
|
729
910
|
collection.tracker.reset();
|
|
911
|
+
collection.clearPendingActions();
|
|
912
|
+
} else {
|
|
913
|
+
// NOTE: blind overwrite of this collection's staged state — anything
|
|
914
|
+
// staged on it between the pre-stage snapshot above and here is discarded.
|
|
915
|
+
// No sibling stamp can be that writer (at most one stamp is open), so the
|
|
916
|
+
// only source is the untracked Tree.stage / Collection.act path, and this
|
|
917
|
+
// is the same single-call-path assumption rollback() documents. It cannot
|
|
918
|
+
// be softened by re-applying those actions here: collection.act takes the
|
|
919
|
+
// same non-reentrant instance latch this span already holds.
|
|
920
|
+
const snapshot = preStageSnapshots.get(collectionId);
|
|
921
|
+
if (snapshot) collection.restorePending(snapshot);
|
|
730
922
|
}
|
|
731
923
|
}
|
|
924
|
+
// The undo handle is now unsafe: rollback() is all-or-nothing and would rewind
|
|
925
|
+
// the collections that DID durably commit. Drop it, matching commitOnceLatched.
|
|
926
|
+
// Dropping outright (no tombstone) is safe because the coordinator enforces at
|
|
927
|
+
// most ONE open stamp (the invariant on stampData): no sibling stamp exists,
|
|
928
|
+
// so nothing else can later look for this entry and be misled by its absence.
|
|
929
|
+
this.stampData.delete(transaction.stamp.id);
|
|
732
930
|
}
|
|
733
931
|
return {
|
|
734
932
|
success: false,
|
|
@@ -738,19 +936,34 @@ export class TransactionCoordinator {
|
|
|
738
936
|
};
|
|
739
937
|
}
|
|
740
938
|
|
|
741
|
-
// 5.
|
|
742
|
-
|
|
743
|
-
|
|
939
|
+
// 5. Advance actionContext, fold the committed transforms into each collection's read
|
|
940
|
+
// cache, reset the tracker, and drop the now-committed pending actions — the same four
|
|
941
|
+
// steps, in the same order, as commitOnceLatched's success fold. Order matters: cache
|
|
942
|
+
// the committed blocks BEFORE resetting the tracker (the transforms are read live), so a
|
|
943
|
+
// collection with prior committed state serves the new revision instead of the stale
|
|
944
|
+
// cached one. Clearing pending keeps a subsequent commit() on this same collection from
|
|
945
|
+
// re-logging these already-durable actions.
|
|
946
|
+
// NOTE: this fold loop must stay await-free, for the same reason commitOnce's does —
|
|
947
|
+
// session-mode publish relies on it being event-loop-atomic across collections.
|
|
948
|
+
for (const collectionId of batches.keys()) {
|
|
949
|
+
const collection = this.collections.get(collectionId);
|
|
744
950
|
if (collection) {
|
|
745
|
-
collection.recordCommitted(transaction.id, pendedRevs.get(
|
|
951
|
+
const rev = collection.recordCommitted(transaction.id, pendedRevs.get(collectionId)!);
|
|
952
|
+
collection.applyCommittedToCache(collectionTransforms.get(collectionId)!, rev);
|
|
746
953
|
collection.tracker.reset();
|
|
954
|
+
collection.clearPendingActions();
|
|
747
955
|
}
|
|
748
956
|
}
|
|
749
957
|
|
|
750
958
|
// Clean up stamp tracking data
|
|
751
959
|
this.stampData.delete(transaction.stamp.id);
|
|
752
960
|
|
|
753
|
-
// 6. Return results from actions
|
|
961
|
+
// 6. Return results from actions.
|
|
962
|
+
// `actions` is the engine's ORIGINAL batch list, un-coalesced — the field is documented
|
|
963
|
+
// as "the actions produced by executing the transaction", and the caller's own shape is
|
|
964
|
+
// the honest answer to that. `results` is keyed by collection id, so the two are NOT
|
|
965
|
+
// positionally aligned when an engine named one collection in more than one batch; join
|
|
966
|
+
// them on collectionId, never by index.
|
|
754
967
|
log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
|
|
755
968
|
return {
|
|
756
969
|
success: true,
|