@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.
Files changed (104) hide show
  1. package/dist/src/cohort-topic/membership/verifier.d.ts +8 -0
  2. package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
  3. package/dist/src/cohort-topic/membership/verifier.js +17 -0
  4. package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
  5. package/dist/src/cohort-topic/promotion.d.ts +25 -0
  6. package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
  7. package/dist/src/cohort-topic/promotion.js +58 -11
  8. package/dist/src/cohort-topic/promotion.js.map +1 -1
  9. package/dist/src/cohort-topic/wire/primitives.d.ts +6 -0
  10. package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -1
  11. package/dist/src/cohort-topic/wire/primitives.js +6 -0
  12. package/dist/src/cohort-topic/wire/primitives.js.map +1 -1
  13. package/dist/src/cohort-topic/wire/types.d.ts +5 -0
  14. package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
  16. package/dist/src/cohort-topic/wire/validate.js +10 -19
  17. package/dist/src/cohort-topic/wire/validate.js.map +1 -1
  18. package/dist/src/collection/collection.d.ts +8 -1
  19. package/dist/src/collection/collection.d.ts.map +1 -1
  20. package/dist/src/collection/collection.js +19 -3
  21. package/dist/src/collection/collection.js.map +1 -1
  22. package/dist/src/matchmaking/wire.d.ts +2 -2
  23. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  24. package/dist/src/matchmaking/wire.js +3 -3
  25. package/dist/src/matchmaking/wire.js.map +1 -1
  26. package/dist/src/network/struct.d.ts +5 -5
  27. package/dist/src/network/struct.d.ts.map +1 -1
  28. package/dist/src/testing/index.d.ts +1 -0
  29. package/dist/src/testing/index.d.ts.map +1 -1
  30. package/dist/src/testing/index.js +1 -0
  31. package/dist/src/testing/index.js.map +1 -1
  32. package/dist/src/testing/refresh-probe.d.ts +31 -0
  33. package/dist/src/testing/refresh-probe.d.ts.map +1 -0
  34. package/dist/src/testing/refresh-probe.js +39 -0
  35. package/dist/src/testing/refresh-probe.js.map +1 -0
  36. package/dist/src/testing/test-transactor.d.ts +58 -5
  37. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  38. package/dist/src/testing/test-transactor.js +87 -4
  39. package/dist/src/testing/test-transactor.js.map +1 -1
  40. package/dist/src/transaction/coordinator.d.ts +90 -6
  41. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  42. package/dist/src/transaction/coordinator.js +311 -90
  43. package/dist/src/transaction/coordinator.js.map +1 -1
  44. package/dist/src/transaction/errors.d.ts +42 -0
  45. package/dist/src/transaction/errors.d.ts.map +1 -1
  46. package/dist/src/transaction/errors.js +50 -0
  47. package/dist/src/transaction/errors.js.map +1 -1
  48. package/dist/src/transaction/index.d.ts +1 -1
  49. package/dist/src/transaction/index.d.ts.map +1 -1
  50. package/dist/src/transaction/index.js +1 -1
  51. package/dist/src/transaction/index.js.map +1 -1
  52. package/dist/src/transaction/session.d.ts +7 -3
  53. package/dist/src/transaction/session.d.ts.map +1 -1
  54. package/dist/src/transaction/session.js +7 -3
  55. package/dist/src/transaction/session.js.map +1 -1
  56. package/dist/src/transaction/transaction.d.ts +12 -2
  57. package/dist/src/transaction/transaction.d.ts.map +1 -1
  58. package/dist/src/transaction/transaction.js.map +1 -1
  59. package/dist/src/transactor/network-transactor.d.ts +1 -1
  60. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  61. package/dist/src/transactor/network-transactor.js +6 -26
  62. package/dist/src/transactor/network-transactor.js.map +1 -1
  63. package/dist/src/transactor/transactor-source.d.ts +4 -6
  64. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  65. package/dist/src/transactor/transactor-source.js +4 -6
  66. package/dist/src/transactor/transactor-source.js.map +1 -1
  67. package/dist/src/transform/atomic.d.ts.map +1 -1
  68. package/dist/src/transform/atomic.js +7 -0
  69. package/dist/src/transform/atomic.js.map +1 -1
  70. package/dist/src/transform/base-pins.d.ts +48 -0
  71. package/dist/src/transform/base-pins.d.ts.map +1 -0
  72. package/dist/src/transform/base-pins.js +65 -0
  73. package/dist/src/transform/base-pins.js.map +1 -0
  74. package/dist/src/transform/digest.d.ts +4 -2
  75. package/dist/src/transform/digest.d.ts.map +1 -1
  76. package/dist/src/transform/digest.js +14 -19
  77. package/dist/src/transform/digest.js.map +1 -1
  78. package/dist/src/transform/tracker.d.ts +31 -3
  79. package/dist/src/transform/tracker.d.ts.map +1 -1
  80. package/dist/src/transform/tracker.js +105 -13
  81. package/dist/src/transform/tracker.js.map +1 -1
  82. package/package.json +1 -1
  83. package/src/cohort-topic/membership/verifier.ts +487 -461
  84. package/src/cohort-topic/promotion.ts +86 -14
  85. package/src/cohort-topic/wire/primitives.ts +195 -188
  86. package/src/cohort-topic/wire/types.ts +480 -475
  87. package/src/cohort-topic/wire/validate.ts +504 -512
  88. package/src/collection/collection.ts +19 -3
  89. package/src/matchmaking/wire.ts +605 -603
  90. package/src/network/struct.ts +5 -5
  91. package/src/testing/index.ts +1 -0
  92. package/src/testing/refresh-probe.ts +52 -0
  93. package/src/testing/test-transactor.ts +100 -4
  94. package/src/transaction/coordinator.ts +312 -99
  95. package/src/transaction/errors.ts +141 -91
  96. package/src/transaction/index.ts +1 -1
  97. package/src/transaction/session.ts +7 -3
  98. package/src/transaction/transaction.ts +12 -2
  99. package/src/transactor/network-transactor.ts +1025 -1045
  100. package/src/transactor/transactor-source.ts +170 -172
  101. package/src/transform/atomic.ts +6 -0
  102. package/src/transform/base-pins.ts +83 -0
  103. package/src/transform/digest.ts +14 -19
  104. 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: snapshot before first apply + accumulated actions for replay */
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
- order: number;
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
- // On first call for this stampId, snapshot all collections for potential rollback
88
- if (!this.stampData.has(stampId)) {
89
- const snapshot = new Map<CollectionId, Transforms>();
90
- for (const [id, col] of this.collections) {
91
- snapshot.set(id, structuredClone(col.tracker.transforms));
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
- this.stampData.set(stampId, {
94
- order: this.nextStampOrder++,
95
- preSnapshot: snapshot,
96
- actionBatches: []
97
- });
130
+ data = { preSnapshot: new Map() };
131
+ this.stampData.set(stampId, data);
98
132
  }
99
- this.stampData.get(stampId)!.actionBatches.push(actions);
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
- * Apply actions without tracking (used internally and for replay during rollback).
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, ReturnType<Collection<any>['snapshotPending']>>();
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 tracker state to the snapshot taken before the stampId's first
482
- * applyActions call, then replays any later stamps' actions to preserve
483
- * other sessions' transforms.
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 resets and replays into participant trackers WITHOUT
489
- // holding their instance latches. Safe today because a session drives abort and commit from
490
- // one call path, so a rollback cannot overlap a commit span on the same collections. If
491
- // rollback ever becomes reachable concurrently with a commit (a background abort, a second
492
- // session sharing collection instances), latch the participants here the way commitOnce does.
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
- // Collect all remaining stamps to replay
499
- const toReplay = [...this.stampData.entries()]
500
- .sort(([, a], [, b]) => a.order - b.order);
501
-
502
- // Find the earliest snapshot among the rolled-back stamp and all remaining stamps.
503
- // This is necessary because interleaved execution means a lower-order stamp
504
- // may have batches applied after a higher-order stamp's snapshot was taken.
505
- let earliestSnapshot = data.preSnapshot;
506
- let earliestOrder = data.order;
507
- for (const [, d] of toReplay) {
508
- if (d.order < earliestOrder) {
509
- earliestSnapshot = d.preSnapshot;
510
- earliestOrder = d.order;
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
- // guard above.)
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
- // tracker and these failure returns do NOT restore that state — so a partially
652
- // applied engine transaction leaves appended-but-uncommitted entries in the
653
- // trackers. This is deliberately NOT snapshot/restore-wrapped the way commit()
654
- // is, because execute()'s asymmetry makes it lower risk: it is not the retryable
655
- // session.commit() entry point (a failed execute() is not re-driven through the
656
- // same loop), and its actions were tracked via applyActions() so rollback(stampId)
657
- // CAN unwind them (unlike commit()'s directly-staged path). If execute() ever
658
- // becomes retryable, mirror the commit() snapshot/restore fix here.
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
- const allCollectionIds = result.actions.map(ca => ca.collectionId);
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. Deduped before acquiring — taking one
672
- // instance's latch twice would also deadlock. Released in the finally: execute has
673
- // early failure returns.
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
- for (const collectionId of [...new Set(allCollectionIds)].sort()) {
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 collectionActions of result.actions) {
858
+ for (const [collectionId, actions] of batches) {
684
859
  const applyResult = await this.applyActionsToCollection(
685
- collectionActions,
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(collectionActions.collectionId, applyResult.transforms!);
695
- criticalBlocks.set(collectionActions.collectionId, applyResult.logTailBlockId!);
696
- actionResults.set(collectionActions.collectionId, applyResult.results!);
697
- pendedRevs.set(collectionActions.collectionId, applyResult.rev!);
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. execute() is not snapshot/restore-wrapped (see the
720
- // note above), but the committed subset must still get the success-path local
721
- // treatment (recordCommitted + tracker.reset, as on the success path below) so its
722
- // trackers aren't left mis-tracking already-durable state.
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 collectionActions of result.actions) {
726
- const collection = this.collections.get(collectionActions.collectionId);
727
- if (collection && committed.has(collectionActions.collectionId)) {
728
- collection.recordCommitted(transaction.id, pendedRevs.get(collectionActions.collectionId)!);
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. Update actionContext and reset trackers after successful commit
742
- for (const collectionActions of result.actions) {
743
- const collection = this.collections.get(collectionActions.collectionId);
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(collectionActions.collectionId)!);
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,