@optimystic/db-core 0.24.2 → 0.25.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 (103) hide show
  1. package/dist/src/blocks/helpers.d.ts +5 -0
  2. package/dist/src/blocks/helpers.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.js +12 -0
  4. package/dist/src/blocks/helpers.js.map +1 -1
  5. package/dist/src/cluster/membership.d.ts +7 -0
  6. package/dist/src/cluster/membership.d.ts.map +1 -1
  7. package/dist/src/cluster/membership.js +12 -8
  8. package/dist/src/cluster/membership.js.map +1 -1
  9. package/dist/src/cluster/structs.d.ts +27 -1
  10. package/dist/src/cluster/structs.d.ts.map +1 -1
  11. package/dist/src/cluster/structs.js.map +1 -1
  12. package/dist/src/collection/action.d.ts +14 -0
  13. package/dist/src/collection/action.d.ts.map +1 -1
  14. package/dist/src/collection/action.js +16 -1
  15. package/dist/src/collection/action.js.map +1 -1
  16. package/dist/src/collection/collection.d.ts +273 -4
  17. package/dist/src/collection/collection.d.ts.map +1 -1
  18. package/dist/src/collection/collection.js +427 -30
  19. package/dist/src/collection/collection.js.map +1 -1
  20. package/dist/src/collections/tree/tree.d.ts +16 -1
  21. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  22. package/dist/src/collections/tree/tree.js +19 -0
  23. package/dist/src/collections/tree/tree.js.map +1 -1
  24. package/dist/src/index.d.ts +1 -0
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +1 -0
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/network/i-repo.d.ts +11 -1
  29. package/dist/src/network/i-repo.d.ts.map +1 -1
  30. package/dist/src/network/stale-failure.d.ts +21 -0
  31. package/dist/src/network/stale-failure.d.ts.map +1 -1
  32. package/dist/src/network/stale-failure.js +22 -0
  33. package/dist/src/network/stale-failure.js.map +1 -1
  34. package/dist/src/network/struct.d.ts +66 -21
  35. package/dist/src/network/struct.d.ts.map +1 -1
  36. package/dist/src/network/struct.js.map +1 -1
  37. package/dist/src/testing/test-transactor.d.ts +22 -0
  38. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  39. package/dist/src/testing/test-transactor.js +44 -5
  40. package/dist/src/testing/test-transactor.js.map +1 -1
  41. package/dist/src/transaction/coordinator.d.ts +20 -0
  42. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  43. package/dist/src/transaction/coordinator.js +256 -113
  44. package/dist/src/transaction/coordinator.js.map +1 -1
  45. package/dist/src/transaction/operations-hash.d.ts +1 -1
  46. package/dist/src/transaction/operations-hash.js +1 -1
  47. package/dist/src/transaction/transaction.d.ts +4 -2
  48. package/dist/src/transaction/transaction.d.ts.map +1 -1
  49. package/dist/src/transactor/network-transactor.d.ts +21 -1
  50. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  51. package/dist/src/transactor/network-transactor.js +122 -29
  52. package/dist/src/transactor/network-transactor.js.map +1 -1
  53. package/dist/src/transactor/transactor-source.d.ts +11 -5
  54. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  55. package/dist/src/transactor/transactor-source.js +16 -8
  56. package/dist/src/transactor/transactor-source.js.map +1 -1
  57. package/dist/src/transform/cache-source.d.ts +13 -0
  58. package/dist/src/transform/cache-source.d.ts.map +1 -1
  59. package/dist/src/transform/cache-source.js +18 -0
  60. package/dist/src/transform/cache-source.js.map +1 -1
  61. package/dist/src/transform/digest.d.ts +18 -0
  62. package/dist/src/transform/digest.d.ts.map +1 -0
  63. package/dist/src/transform/digest.js +65 -0
  64. package/dist/src/transform/digest.js.map +1 -0
  65. package/dist/src/transform/index.d.ts +1 -0
  66. package/dist/src/transform/index.d.ts.map +1 -1
  67. package/dist/src/transform/index.js +1 -0
  68. package/dist/src/transform/index.js.map +1 -1
  69. package/dist/src/transform/tracker.d.ts +16 -0
  70. package/dist/src/transform/tracker.d.ts.map +1 -1
  71. package/dist/src/transform/tracker.js +40 -1
  72. package/dist/src/transform/tracker.js.map +1 -1
  73. package/dist/src/utility/canonical-json.d.ts +11 -0
  74. package/dist/src/utility/canonical-json.d.ts.map +1 -0
  75. package/dist/src/utility/canonical-json.js +15 -0
  76. package/dist/src/utility/canonical-json.js.map +1 -0
  77. package/dist/src/utility/lru-map.d.ts +2 -0
  78. package/dist/src/utility/lru-map.d.ts.map +1 -1
  79. package/dist/src/utility/lru-map.js +4 -0
  80. package/dist/src/utility/lru-map.js.map +1 -1
  81. package/package.json +2 -1
  82. package/src/blocks/helpers.ts +26 -13
  83. package/src/cluster/membership.ts +87 -85
  84. package/src/cluster/structs.ts +28 -1
  85. package/src/collection/action.ts +17 -0
  86. package/src/collection/collection.ts +1129 -688
  87. package/src/collections/tree/tree.ts +341 -320
  88. package/src/index.ts +24 -23
  89. package/src/network/i-repo.ts +59 -46
  90. package/src/network/stale-failure.ts +67 -43
  91. package/src/network/struct.ts +332 -270
  92. package/src/testing/test-transactor.ts +680 -638
  93. package/src/transaction/coordinator.ts +1266 -1110
  94. package/src/transaction/operations-hash.ts +1 -1
  95. package/src/transaction/transaction.ts +4 -2
  96. package/src/transactor/network-transactor.ts +123 -32
  97. package/src/transactor/transactor-source.ts +17 -9
  98. package/src/transform/cache-source.ts +20 -0
  99. package/src/transform/digest.ts +72 -0
  100. package/src/transform/index.ts +1 -0
  101. package/src/transform/tracker.ts +42 -1
  102. package/src/utility/canonical-json.ts +16 -0
  103. package/src/utility/lru-map.ts +5 -0
@@ -2,6 +2,7 @@ import { isConflictFailure } from "../network/stale-failure.js";
2
2
  import { isTransactionExpired, clampPriority } from "./transaction.js";
3
3
  import { Log } from "../log/log.js";
4
4
  import { blockIdsForTransforms } from "../transform/helpers.js";
5
+ import { computeBlockContentDigests, blockDigestsField } from "../transform/digest.js";
5
6
  import { collectOperations, hashOperations } from "./operations-hash.js";
6
7
  import { CoordinatorPartialCommitError, CoordinatorStaleLossError } from "./errors.js";
7
8
  import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
@@ -128,52 +129,67 @@ export class TransactionCoordinator {
128
129
  // re-drive a losing transaction before surfacing a terminal error.
129
130
  let staleLosses = 0;
130
131
  let lastLoss;
131
- for (;;) {
132
- if (signal?.aborted) {
133
- throw makeAbortError(signal);
134
- }
135
- // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
136
- // wall-clock deadline passed (independent of the attempt cap).
137
- if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
138
- throw lastLoss;
139
- }
140
- // Age the transaction's advisory priority by the number of losses taken so far, so a
141
- // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
142
- // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
143
- // signature, so bumping it here does not churn identity. Left untouched on the first
144
- // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
145
- if (staleLosses > 0) {
146
- transaction.priority = clampPriority(staleLosses);
147
- }
148
- try {
149
- await this.commitOnce(transaction);
150
- return;
151
- }
152
- catch (err) {
153
- // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
154
- // unavailable transactor, etc. all propagate unchanged.
155
- if (!(err instanceof CoordinatorStaleLossError)) {
156
- throw err;
132
+ // Disposers for the in-flight marks {@link commitOnce} sets on each participant it latches.
133
+ // They must outlive the individual attempt: the inter-attempt refresh below is the ONLY
134
+ // reader of the mark, and it deliberately runs after the commit span released its latches
135
+ // (`Latches` is non-reentrant), so a clear tied to the latch would already have run. Hence
136
+ // the finally spans the WHOLE retry loop every exit clears: return, stale-loss exhaustion,
137
+ // a partial landing, a hard error, an abort. Each disposer is id-guarded, so re-marking on a
138
+ // later attempt is harmless and a stale disposer cannot wipe a newer mark.
139
+ const inFlightDisposers = [];
140
+ try {
141
+ for (;;) {
142
+ if (signal?.aborted) {
143
+ throw makeAbortError(signal);
157
144
  }
158
- lastLoss = err;
159
- staleLosses++;
160
- if (staleLosses >= maxAttempts) {
161
- throw err;
145
+ // Progress-agnostic ceiling: once we've taken at least one loss, give up if the
146
+ // wall-clock deadline passed (independent of the attempt cap).
147
+ if (deadlineMs !== undefined && lastLoss && Date.now() - startedAt >= deadlineMs) {
148
+ throw lastLoss;
162
149
  }
163
- const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
164
- await abortableDelay(delay, signal);
165
- // Re-read fresh state before re-attempting so the next commit pends against current
166
- // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
167
- // NOTE: refreshes EVERY registered collection, not only the participants of this
168
- // transaction. Not free: a non-participant's update() throws CollectionHeaderVanishedError
169
- // if its header momentarily reads absent while it holds a committed revision, aborting
170
- // this retry. The registered set is small today; if that (or retry latency) ever bites,
171
- // narrow this to the transaction's participating collections.
172
- for (const collection of this.collections.values()) {
173
- await collection.update();
150
+ // Age the transaction's advisory priority by the number of losses taken so far, so a
151
+ // repeatedly-losing transaction out-ranks fresh (priority-0) rivals in the cluster's
152
+ // resolveRace. Fairness-only and capped at MaxPriority; excluded from the tx id / client
153
+ // signature, so bumping it here does not churn identity. Left untouched on the first
154
+ // attempt (staleLosses == 0) so the initial pend serializes exactly as before.
155
+ if (staleLosses > 0) {
156
+ transaction.priority = clampPriority(staleLosses);
157
+ }
158
+ try {
159
+ await this.commitOnce(transaction, inFlightDisposers);
160
+ return;
161
+ }
162
+ catch (err) {
163
+ // Only a CLEAN stale loss is retryable. A partial landing, an expired transaction, an
164
+ // unavailable transactor, etc. all propagate unchanged.
165
+ if (!(err instanceof CoordinatorStaleLossError)) {
166
+ throw err;
167
+ }
168
+ lastLoss = err;
169
+ staleLosses++;
170
+ if (staleLosses >= maxAttempts) {
171
+ throw err;
172
+ }
173
+ const delay = jitteredBackoffMs(staleLosses - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
174
+ await abortableDelay(delay, signal);
175
+ // Re-read fresh state before re-attempting so the next commit pends against current
176
+ // revisions (mirrors how Collection.sync calls updateInternal() before retrying).
177
+ // NOTE: refreshes EVERY registered collection, not only the participants of this
178
+ // transaction. Not free: a non-participant's update() throws CollectionHeaderVanishedError
179
+ // if its header momentarily reads absent while it holds a committed revision, aborting
180
+ // this retry. The registered set is small today; if that (or retry latency) ever bites,
181
+ // narrow this to the transaction's participating collections.
182
+ for (const collection of this.collections.values()) {
183
+ await collection.update();
184
+ }
174
185
  }
175
186
  }
176
187
  }
188
+ finally {
189
+ for (const dispose of inFlightDisposers) {
190
+ dispose();
191
+ }
192
+ }
177
193
  }
178
194
  /**
179
195
  * Commit a transaction (single attempt): materialise a log entry from each collection's staged
@@ -191,8 +207,14 @@ export class TransactionCoordinator {
191
207
  * {@link CoordinatorPartialCommitError} (not retryable).
192
208
  *
193
209
  * @param transaction - The transaction to commit
210
+ * @param inFlightDisposers - Collects one disposer per participant marked in flight under this
211
+ * transaction's id (see {@link Collection.beginInFlightAction}). REQUIRED, so a future caller
212
+ * cannot silently reintroduce the unmarked refresh this parameter exists to prevent: a caller
213
+ * that never refreshes between attempts passes a throwaway array and simply ignores it. The
214
+ * caller owns clearing them, because the mark has to survive past this attempt — see the
215
+ * array's declaration in {@link commit}.
194
216
  */
195
- async commitOnce(transaction) {
217
+ async commitOnce(transaction, inFlightDisposers) {
196
218
  if (isTransactionExpired(transaction.stamp)) {
197
219
  throw new Error(`Transaction expired at ${transaction.stamp.expiration}`);
198
220
  }
@@ -209,6 +231,57 @@ export class TransactionCoordinator {
209
231
  if (collectionData.length === 0) {
210
232
  return; // Nothing to commit
211
233
  }
234
+ // NOTE: this selection reads each tracker BEFORE the latches below are held, so a stage
235
+ // that lands between the filter and the acquisition is simply not part of this commit.
236
+ // Harmless today — a session stages and commits on one call path, so nothing races its
237
+ // own commit. If a caller ever stages a collection concurrently with committing it,
238
+ // re-derive the participant set inside the held span instead of filtering out here.
239
+ // Hold every participating collection's instance latch for the WHOLE commit span —
240
+ // snapshot, log append, the pend/commit round trips, and the local fold — so a
241
+ // reader-driven update()/sync() on the same instance cannot interleave: without this, a
242
+ // refresh could adopt the newly committed revision mid-flight and recordCommitted would
243
+ // land the action at a revision storage never assigned it. Acquisition is in sorted
244
+ // collection-id order, mirroring StorageRepo.commit's sorted block-id latch discipline
245
+ // (db-p2p/src/storage/block-latch.ts), so two concurrent commits over overlapping
246
+ // participant sets cannot deadlock. `Latches` is non-reentrant, so nothing inside the
247
+ // held span may call a latched Collection method (act/update/sync/updateAndSync) on a
248
+ // participant — the retry loop's blanket collection.update() in commit() runs OUTSIDE
249
+ // this span, after release.
250
+ // NOTE: the span covers the pend/commit consensus round trips, so every latched method on
251
+ // a participant instance (act/update/sync) queues for as long as the transactor takes.
252
+ // Accepted: correctness needs the whole span, and the transactor's own timeouts bound it.
253
+ // If a stalled peer is ever observed wedging unrelated readers, bound the hold instead —
254
+ // e.g. acquire with a deadline and fail the commit rather than queueing indefinitely.
255
+ const latchReleases = [];
256
+ try {
257
+ const latchOrder = [...collectionData].sort((a, b) => a.collectionId < b.collectionId ? -1 : a.collectionId > b.collectionId ? 1 : 0);
258
+ for (const { collection } of latchOrder) {
259
+ latchReleases.push(await collection.acquireLatch());
260
+ // Mark THIS attempt's action id on each participant while its latch is held, so the
261
+ // inter-attempt refresh in commit() recognises a log entry this transaction itself
262
+ // made durable (a torn commit: header and log tail committed, a later sweep block
263
+ // reported the conflict) and consumes it instead of replaying it into a second entry
264
+ // under the same id. `transaction.id` is stable across retries, so re-marking on a
265
+ // later attempt re-states the same fact. Only participants are marked; a registered
266
+ // non-participant is left unmarked and its refresh behaves exactly as a reader's.
267
+ inFlightDisposers.push(collection.beginInFlightAction(transaction.id));
268
+ }
269
+ await this.commitOnceLatched(transaction, collectionData);
270
+ }
271
+ finally {
272
+ for (const release of latchReleases.reverse()) {
273
+ release();
274
+ }
275
+ }
276
+ }
277
+ /**
278
+ * The body of {@link commitOnce}, run with every participating collection's instance latch
279
+ * held by the caller (see the acquisition comment there). Nothing in here may re-acquire a
280
+ * participant's latch — every Collection member it touches (snapshotPending, getPendingActions,
281
+ * recordCommitted, applyCommittedToCache, restorePending, clearPendingActions, tracker.reset)
282
+ * is latch-free by contract.
283
+ */
284
+ async commitOnceLatched(transaction, collectionData) {
212
285
  // Append each collection's staged actions to its log, then collect the
213
286
  // resulting transforms + critical (log-tail) block for consensus.
214
287
  //
@@ -224,6 +297,10 @@ export class TransactionCoordinator {
224
297
  const allCollectionIds = collectionData.map(({ collectionId }) => collectionId);
225
298
  const collectionTransforms = new Map();
226
299
  const criticalBlocks = new Map();
300
+ // The revision each collection's log entry was stamped with. Captured ONCE — at the log
301
+ // append in applyActionsToCollection, the single legitimate capture point — and threaded
302
+ // through pend, commit, and the local recordCommitted, so all four name the same number.
303
+ const pendedRevs = new Map();
227
304
  // Snapshot EVERY participating collection's staged state (transforms + pending
228
305
  // queue) BEFORE the append loop mutates any tracker. The loop appends log
229
306
  // entries sequentially, so a failure on the Nth collection must also undo the
@@ -245,6 +322,7 @@ export class TransactionCoordinator {
245
322
  }
246
323
  collectionTransforms.set(collectionId, applyResult.transforms);
247
324
  criticalBlocks.set(collectionId, applyResult.logTailBlockId);
325
+ pendedRevs.set(collectionId, applyResult.rev);
248
326
  }
249
327
  // Compute hash of ALL operations across ALL collections (post-log-append).
250
328
  // Validators re-execute the transaction and compare their computed hash.
@@ -252,7 +330,7 @@ export class TransactionCoordinator {
252
330
  // this order-independent fingerprint matches what a validator recomputes.
253
331
  const operationsHash = await hashOperations(collectOperations(collectionTransforms));
254
332
  // Execute consensus phases (GATHER, PEND, COMMIT)
255
- coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks);
333
+ coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks, pendedRevs);
256
334
  }
257
335
  catch (err) {
258
336
  // A throw here means the failure happened BEFORE any collection could
@@ -283,7 +361,7 @@ export class TransactionCoordinator {
283
361
  // collection kept its pending queue, a subsequent commit() would re-append and
284
362
  // re-log its already-durable actions — a duplicate log entry on the winner. The
285
363
  // no-double-apply-on-retry test in transaction.spec.ts locks this.
286
- const rev = collection.recordCommitted(transaction.id);
364
+ const rev = collection.recordCommitted(transaction.id, pendedRevs.get(collectionId));
287
365
  collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
288
366
  collection.tracker.reset();
289
367
  collection.clearPendingActions();
@@ -322,8 +400,10 @@ export class TransactionCoordinator {
322
400
  // with prior committed state (a pre-synced index, or any second commit)
323
401
  // serves the new revision instead of the stale cached one. Clearing
324
402
  // pending keeps a subsequent commit from re-logging these actions.
403
+ // NOTE: this fold loop must stay await-free — session-mode publish relies on it being
404
+ // event-loop-atomic across collections (see OptimysticModule's readCommittedSnapshot audit).
325
405
  for (const { collectionId, collection } of collectionData) {
326
- const rev = collection.recordCommitted(transaction.id);
406
+ const rev = collection.recordCommitted(transaction.id, pendedRevs.get(collectionId));
327
407
  collection.applyCommittedToCache(collectionTransforms.get(collectionId), rev);
328
408
  collection.tracker.reset();
329
409
  collection.clearPendingActions();
@@ -341,6 +421,11 @@ export class TransactionCoordinator {
341
421
  * @param stampId - The transaction stamp ID to rollback
342
422
  */
343
423
  async rollback(stampId) {
424
+ // NOTE: unlike the commit path, this resets and replays into participant trackers WITHOUT
425
+ // holding their instance latches. Safe today because a session drives abort and commit from
426
+ // one call path, so a rollback cannot overlap a commit span on the same collections. If
427
+ // rollback ever becomes reachable concurrently with a commit (a background abort, a second
428
+ // session sharing collection instances), latch the participants here the way commitOnce does.
344
429
  const data = this.stampData.get(stampId);
345
430
  if (!data)
346
431
  return;
@@ -496,63 +581,87 @@ export class TransactionCoordinator {
496
581
  const criticalBlocks = new Map();
497
582
  const actionResults = new Map();
498
583
  const allCollectionIds = result.actions.map(ca => ca.collectionId);
499
- for (const collectionActions of result.actions) {
500
- const applyResult = await this.applyActionsToCollection(collectionActions, transaction, allCollectionIds);
501
- if (!applyResult.success) {
502
- return { success: false, error: applyResult.error };
584
+ // Same single-capture rev threading as commitOnce: stamped at the log append below,
585
+ // named again at pend, commit, and recordCommitted.
586
+ const pendedRevs = new Map();
587
+ // Hold each participating collection's instance latch for the commit span, same
588
+ // discipline as commitOnce (sorted acquisition; see the comment there). Acquired only
589
+ // AFTER applyActions above: collection.act takes the same non-reentrant instance latch
590
+ // itself, so latching earlier would deadlock. Deduped before acquiring — taking one
591
+ // instance's latch twice would also deadlock. Released in the finally: execute has
592
+ // early failure returns.
593
+ const latchReleases = [];
594
+ try {
595
+ for (const collectionId of [...new Set(allCollectionIds)].sort()) {
596
+ const collection = this.collections.get(collectionId);
597
+ if (collection) {
598
+ latchReleases.push(await collection.acquireLatch());
599
+ }
503
600
  }
504
- collectionTransforms.set(collectionActions.collectionId, applyResult.transforms);
505
- criticalBlocks.set(collectionActions.collectionId, applyResult.logTailBlockId);
506
- actionResults.set(collectionActions.collectionId, applyResult.results);
507
- }
508
- // 3. Compute operations hash for validation (order-independent; see commit()).
509
- const operationsHash = await hashOperations(collectOperations(collectionTransforms));
510
- const applyMs = Date.now() - tApply;
511
- // 4. Coordinate (GATHER if multi-collection)
512
- const tCoord = Date.now();
513
- const coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks);
514
- const coordMs = Date.now() - tCoord;
515
- if (!coordResult.success) {
516
- log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
517
- // Stop lying to the caller about a partial commit: if some collections durably
518
- // committed, surface that set. execute() is not snapshot/restore-wrapped (see the
519
- // note above), but the committed subset must still get the success-path local
520
- // treatment (recordCommitted + tracker.reset, as on the success path below) so its
521
- // trackers aren't left mis-tracking already-durable state.
522
- const committed = coordResult.committedCollections ?? new Set();
523
- if (committed.size > 0) {
524
- for (const collectionActions of result.actions) {
525
- const collection = this.collections.get(collectionActions.collectionId);
526
- if (collection && committed.has(collectionActions.collectionId)) {
527
- collection.recordCommitted(transaction.id);
528
- collection.tracker.reset();
601
+ for (const collectionActions of result.actions) {
602
+ const applyResult = await this.applyActionsToCollection(collectionActions, transaction, allCollectionIds);
603
+ if (!applyResult.success) {
604
+ return { success: false, error: applyResult.error };
605
+ }
606
+ collectionTransforms.set(collectionActions.collectionId, applyResult.transforms);
607
+ criticalBlocks.set(collectionActions.collectionId, applyResult.logTailBlockId);
608
+ actionResults.set(collectionActions.collectionId, applyResult.results);
609
+ pendedRevs.set(collectionActions.collectionId, applyResult.rev);
610
+ }
611
+ // 3. Compute operations hash for validation (order-independent; see commit()).
612
+ const operationsHash = await hashOperations(collectOperations(collectionTransforms));
613
+ const applyMs = Date.now() - tApply;
614
+ // 4. Coordinate (GATHER if multi-collection)
615
+ const tCoord = Date.now();
616
+ const coordResult = await this.coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks, pendedRevs);
617
+ const coordMs = Date.now() - tCoord;
618
+ if (!coordResult.success) {
619
+ log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms success=false total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
620
+ // Stop lying to the caller about a partial commit: if some collections durably
621
+ // committed, surface that set. execute() is not snapshot/restore-wrapped (see the
622
+ // note above), but the committed subset must still get the success-path local
623
+ // treatment (recordCommitted + tracker.reset, as on the success path below) so its
624
+ // trackers aren't left mis-tracking already-durable state.
625
+ const committed = coordResult.committedCollections ?? new Set();
626
+ if (committed.size > 0) {
627
+ for (const collectionActions of result.actions) {
628
+ const collection = this.collections.get(collectionActions.collectionId);
629
+ if (collection && committed.has(collectionActions.collectionId)) {
630
+ collection.recordCommitted(transaction.id, pendedRevs.get(collectionActions.collectionId));
631
+ collection.tracker.reset();
632
+ }
529
633
  }
530
634
  }
635
+ return {
636
+ success: false,
637
+ error: coordResult.error,
638
+ committedCollections: committed.size > 0 ? [...committed] : undefined,
639
+ failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
640
+ };
531
641
  }
642
+ // 5. Update actionContext and reset trackers after successful commit
643
+ for (const collectionActions of result.actions) {
644
+ const collection = this.collections.get(collectionActions.collectionId);
645
+ if (collection) {
646
+ collection.recordCommitted(transaction.id, pendedRevs.get(collectionActions.collectionId));
647
+ collection.tracker.reset();
648
+ }
649
+ }
650
+ // Clean up stamp tracking data
651
+ this.stampData.delete(transaction.stamp.id);
652
+ // 6. Return results from actions
653
+ log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
532
654
  return {
533
- success: false,
534
- error: coordResult.error,
535
- committedCollections: committed.size > 0 ? [...committed] : undefined,
536
- failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
655
+ success: true,
656
+ actions: result.actions,
657
+ results: actionResults
537
658
  };
538
659
  }
539
- // 5. Update actionContext and reset trackers after successful commit
540
- for (const collectionActions of result.actions) {
541
- const collection = this.collections.get(collectionActions.collectionId);
542
- if (collection) {
543
- collection.recordCommitted(transaction.id);
544
- collection.tracker.reset();
660
+ finally {
661
+ for (const release of latchReleases.reverse()) {
662
+ release();
545
663
  }
546
664
  }
547
- // Clean up stamp tracking data
548
- this.stampData.delete(transaction.stamp.id);
549
- // 6. Return results from actions
550
- log('execute:done trxId=%s engine=%dms apply=%dms coordinate=%dms total=%dms', trxId, engineMs, applyMs, coordMs, Date.now() - t0);
551
- return {
552
- success: true,
553
- actions: result.actions,
554
- results: actionResults
555
- };
556
665
  }
557
666
  /**
558
667
  * Apply actions to a collection.
@@ -589,12 +698,24 @@ export class TransactionCoordinator {
589
698
  // reads are recorded on every collection's entry: a read may target a block in another
590
699
  // collection, and the cascade matches read-dependents by (blockId, revision) regardless of
591
700
  // which collection's log the dependent landed in.
701
+ // NOTE: `allCollectionIds` names the participants of THIS attempt, and a retry's participant
702
+ // set can be SMALLER than the first attempt's. After a torn commit, the participant whose
703
+ // entry landed durably consumes that entry on the inter-attempt refresh
704
+ // (Collection.inFlightActionId), empties its pending queue and resets its tracker, so
705
+ // commitOnce's non-empty-transforms filter drops it from the next attempt. The retry's
706
+ // entries therefore list only the REMAINING participants, while the torn participant's
707
+ // already-durable entry lists them all — one transaction id, two different
708
+ // `allCollectionIds` values across its entries. Nothing today keys off that list for
709
+ // correctness; a cross-collection invalidation cascade that treats it as "the definitive
710
+ // participant set of this transaction" must union it across the transaction's entries
711
+ // rather than trusting any single one.
592
712
  const addResult = await log.addActions(collectionActions.actions, actionId, newRev, () => blockIdsForTransforms(transforms), allCollectionIds, transaction.reads);
593
713
  // Return the transforms and log tail block ID
594
714
  return {
595
715
  success: true,
596
716
  transforms,
597
717
  logTailBlockId: addResult.tailPath.block.header.id,
718
+ rev: newRev,
598
719
  results: [] // TODO: Collect results from action handlers when we support read operations
599
720
  };
600
721
  }
@@ -605,8 +726,11 @@ export class TransactionCoordinator {
605
726
  * @param operationsHash - Hash of all operations for validation
606
727
  * @param collectionTransforms - Map of collectionId to its transforms
607
728
  * @param criticalBlocks - Map of collectionId to its log tail blockId
729
+ * @param pendedRevs - Per collection, the revision its log entry was stamped with (from
730
+ * applyActionsToCollection) — repeated verbatim on the pend and commit requests so storage
731
+ * and the local record name the same number.
608
732
  */
609
- async coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks) {
733
+ async coordinateTransaction(transaction, operationsHash, collectionTransforms, criticalBlocks, pendedRevs) {
610
734
  const trxId = transaction.id;
611
735
  const t0 = Date.now();
612
736
  // 1. GATHER phase: collect critical cluster nominees (skip if single collection)
@@ -616,7 +740,7 @@ export class TransactionCoordinator {
616
740
  const gatherMs = Date.now() - tGather;
617
741
  // 2. PEND phase: distribute to all block clusters
618
742
  const tPend = Date.now();
619
- const pendResult = await this.pendPhase(transaction, operationsHash, collectionTransforms, superclusterNominees);
743
+ const pendResult = await this.pendPhase(transaction, operationsHash, collectionTransforms, pendedRevs, superclusterNominees);
620
744
  const pendMs = Date.now() - tPend;
621
745
  if (!pendResult.success) {
622
746
  log('trx:phases trxId=%s gather=%dms pend=%dms (failed) total=%dms', trxId, gatherMs, pendMs, Date.now() - t0);
@@ -624,7 +748,7 @@ export class TransactionCoordinator {
624
748
  }
625
749
  // 3. COMMIT phase: commit to all critical blocks (with retry for forward recovery)
626
750
  const tCommit = Date.now();
627
- const commitResult = await this.commitPhase(transaction.id, criticalBlockIds, pendResult.pendedBlockIds);
751
+ const commitResult = await this.commitPhase(transaction.id, criticalBlockIds, pendResult.pendedBlockIds, pendedRevs);
628
752
  const commitMs = Date.now() - tCommit;
629
753
  if (!commitResult.success) {
630
754
  // Targeted cancel: only cancel collections that are still pending (not already committed)
@@ -683,9 +807,12 @@ export class TransactionCoordinator {
683
807
  * @param transaction - The full transaction for replay/validation
684
808
  * @param operationsHash - Hash of all operations for validation
685
809
  * @param collectionTransforms - Map of collectionId to its transforms
810
+ * @param pendedRevs - Per collection, the revision its log entry was stamped with — the pend
811
+ * request repeats it verbatim rather than recomputing from the collection (a recompute after
812
+ * the append could name a different number if the collection refreshed in between).
686
813
  * @param superclusterNominees - Nominees for multi-collection consensus (null for single-collection)
687
814
  */
688
- async pendPhase(transaction, operationsHash, collectionTransforms, superclusterNominees) {
815
+ async pendPhase(transaction, operationsHash, collectionTransforms, pendedRevs, superclusterNominees) {
689
816
  if (collectionTransforms.size === 0) {
690
817
  return { success: false, error: 'No transforms to pend' };
691
818
  }
@@ -696,7 +823,7 @@ export class TransactionCoordinator {
696
823
  // NOTE: unbounded fan-out — one concurrent coordinator round-trip per collection.
697
824
  // Transactions touch few collections today; if one ever spans very many, bound this
698
825
  // with a concurrency limiter so peak in-flight round-trips stays sane. Same for commitPhase.
699
- const outcomes = await Promise.allSettled(Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) => this.pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees)));
826
+ const outcomes = await Promise.allSettled(Array.from(collectionTransforms.entries()).map(([collectionId, transforms]) => this.pendCollection(transaction, operationsHash, collectionId, transforms, pendedRevs.get(collectionId), actionId, nominees)));
700
827
  // Partition settled results: every collection that DID pend (keyed with its block
701
828
  // ids), plus the first failure reason if any collection failed.
702
829
  const pendedBlockIds = new Map();
@@ -737,21 +864,23 @@ export class TransactionCoordinator {
737
864
  * pended block ids on success; throws with a per-collection reason on failure so the
738
865
  * fan-out in {@link pendPhase} can settle it as a rejection.
739
866
  */
740
- async pendCollection(transaction, operationsHash, collectionId, transforms, actionId, nominees) {
867
+ async pendCollection(transaction, operationsHash, collectionId, transforms,
868
+ /** The revision the log entry was stamped with (threaded from applyActionsToCollection),
869
+ * NOT recomputed here: a `getNextRev()` after the append round trips could name a number
870
+ * a concurrent refresh already moved past. */
871
+ rev, actionId, nominees) {
741
872
  const collection = this.collections.get(collectionId);
742
873
  if (!collection) {
743
874
  throw new Error(`Collection not found: ${collectionId}`);
744
875
  }
745
- // Get revision from the collection's source
746
- const rev = collection.getNextRev();
747
- // Create pend request with transaction and operations hash for validation
876
+ // Create pend request with the validation payload (transaction + operations hash) —
877
+ // always BOTH, as one pair: this is the only producer of PendRequest.validation.
748
878
  const pendRequest = {
749
879
  actionId,
750
880
  rev,
751
881
  transforms,
752
882
  policy: 'r', // Return policy: fail but return pending actions
753
- transaction,
754
- operationsHash,
883
+ validation: { transaction, operationsHash },
755
884
  superclusterNominees: nominees
756
885
  };
757
886
  const pendResult = await this.transactor.pend(pendRequest);
@@ -771,10 +900,10 @@ export class TransactionCoordinator {
771
900
  * to commit. Failed commits are retried (forward recovery) before giving up.
772
901
  * Returns which collections committed vs failed so the caller can do targeted cancel.
773
902
  */
774
- async commitPhase(actionId, criticalBlockIds, pendedBlockIds) {
903
+ async commitPhase(actionId, criticalBlockIds, pendedBlockIds, pendedRevs) {
775
904
  // Fan out the independent per-collection commit-with-retry concurrently, then
776
905
  // aggregate the committed/failed partition from the settled results.
777
- const outcomes = await Promise.allSettled(Array.from(pendedBlockIds.entries()).map(([collectionId, blockIds]) => this.commitCollection(actionId, criticalBlockIds, collectionId, blockIds)));
906
+ const outcomes = await Promise.allSettled(Array.from(pendedBlockIds.entries()).map(([collectionId, blockIds]) => this.commitCollection(actionId, criticalBlockIds, collectionId, blockIds, pendedRevs.get(collectionId))));
778
907
  const committedCollections = new Set();
779
908
  const failedCollections = new Set();
780
909
  const errors = [];
@@ -823,24 +952,33 @@ export class TransactionCoordinator {
823
952
  * times (forward recovery). Always resolves — success is carried in the returned
824
953
  * `committed` flag — so the fan-out in {@link commitPhase} can aggregate every result.
825
954
  */
826
- async commitCollection(actionId, criticalBlockIds, collectionId, blockIds) {
955
+ async commitCollection(actionId, criticalBlockIds, collectionId, blockIds,
956
+ /** The revision this collection PENDED at, threaded from the log append — the same bug
957
+ * family as pendCollection's: recomputing `getNextRev()` here, after the pend round
958
+ * trips, could stamp the CommitRequest with a different number than the pend named. */
959
+ rev) {
827
960
  const collection = this.collections.get(collectionId);
828
961
  if (!collection) {
829
962
  return { collectionId, committed: false, error: `Collection not found: ${collectionId}` };
830
963
  }
831
- // Get revision
832
- const rev = collection.getNextRev();
833
964
  // Find the critical block (log tail) for this collection
834
965
  const logTailBlockId = criticalBlockIds.find(blockId => blockIds.includes(blockId));
835
966
  if (!logTailBlockId) {
836
967
  return { collectionId, committed: false, error: `Log tail block not found for collection ${collectionId}` };
837
968
  }
969
+ // Declare what each pended block will contain once committed. The collection's tracker still
970
+ // holds this transaction's staged transforms (it is reset only after commit succeeds) and
971
+ // layers over the collection's CacheSource, so this is a purely local computation; an id whose
972
+ // base is not cached is omitted and falls back to corroboration on the member side. Only the
973
+ // client can declare this — CoordinatorRepo.commit forwards without materializing.
974
+ const blockDigests = await computeBlockContentDigests(collection.tracker, blockIds);
838
975
  // Create commit request
839
976
  const commitRequest = {
840
977
  actionId,
841
978
  blockIds,
842
979
  tailId: logTailBlockId,
843
- rev
980
+ rev,
981
+ ...blockDigestsField(blockDigests)
844
982
  };
845
983
  // Retry ONLY transient/thrown failures (unreachable peers, timeout) — forward recovery.
846
984
  // A returned { success:false } is a permanent stale loss (someone committed a newer rev);
@@ -858,9 +996,14 @@ export class TransactionCoordinator {
858
996
  // mark it retryable at the coordinator level (after a re-read advances the rev).
859
997
  // NOTE: deliberately does NOT consult `isConflictFailure` / `StaleFailure.conflict`
860
998
  // like the pend path does. Once the pend succeeded, a returned commit failure means
861
- // the revision slot moved, and no commit producer sets `conflict` today. If a commit
862
- // producer ever starts distinguishing hard commit rejections (validator policy,
863
- // storage fault) from lost races, gate `stale` on isConflictFailure here.
999
+ // the revision slot moved. One commit producer DOES set `conflict` now
1000
+ // db-p2p's CoordinatorRepo.commit returns lost commit-consensus races and classified
1001
+ // stale-commit rejections as `{ success:false, conflict:true }` (returning, not
1002
+ // throwing, is what keeps them out of the verbatim retry above) — but every returned
1003
+ // failure still maps to `stale: true` here, and `isConflictFailure` covers that new
1004
+ // shape, so no behavior change is needed. If a commit producer ever starts returning
1005
+ // hard commit rejections (validator policy, storage fault) as results too, gate
1006
+ // `stale` on isConflictFailure here.
864
1007
  return {
865
1008
  collectionId,
866
1009
  committed: false,