@optimystic/db-core 0.24.2 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/blocks/helpers.d.ts +5 -0
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +12 -0
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +7 -0
- package/dist/src/cluster/membership.d.ts.map +1 -1
- package/dist/src/cluster/membership.js +12 -8
- package/dist/src/cluster/membership.js.map +1 -1
- package/dist/src/cluster/structs.d.ts +27 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/action.d.ts +14 -0
- package/dist/src/collection/action.d.ts.map +1 -1
- package/dist/src/collection/action.js +16 -1
- package/dist/src/collection/action.js.map +1 -1
- package/dist/src/collection/collection.d.ts +273 -4
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +427 -30
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +16 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +19 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/network/i-repo.d.ts +11 -1
- package/dist/src/network/i-repo.d.ts.map +1 -1
- package/dist/src/network/stale-failure.d.ts +21 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +22 -0
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +66 -21
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +22 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +44 -5
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +20 -0
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +256 -113
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +1 -1
- package/dist/src/transaction/operations-hash.js +1 -1
- package/dist/src/transaction/transaction.d.ts +4 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +21 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +122 -29
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +11 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +16 -8
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +13 -0
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +18 -0
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/digest.d.ts +18 -0
- package/dist/src/transform/digest.d.ts.map +1 -0
- package/dist/src/transform/digest.js +65 -0
- package/dist/src/transform/digest.js.map +1 -0
- package/dist/src/transform/index.d.ts +1 -0
- package/dist/src/transform/index.d.ts.map +1 -1
- package/dist/src/transform/index.js +1 -0
- package/dist/src/transform/index.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +16 -0
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +40 -1
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/canonical-json.d.ts +11 -0
- package/dist/src/utility/canonical-json.d.ts.map +1 -0
- package/dist/src/utility/canonical-json.js +15 -0
- package/dist/src/utility/canonical-json.js.map +1 -0
- package/dist/src/utility/lru-map.d.ts +2 -0
- package/dist/src/utility/lru-map.d.ts.map +1 -1
- package/dist/src/utility/lru-map.js +4 -0
- package/dist/src/utility/lru-map.js.map +1 -1
- package/package.json +2 -1
- package/src/blocks/helpers.ts +26 -13
- package/src/cluster/membership.ts +87 -85
- package/src/cluster/structs.ts +28 -1
- package/src/collection/action.ts +17 -0
- package/src/collection/collection.ts +1129 -688
- package/src/collections/tree/tree.ts +341 -320
- package/src/index.ts +24 -23
- package/src/network/i-repo.ts +59 -46
- package/src/network/stale-failure.ts +67 -43
- package/src/network/struct.ts +332 -270
- package/src/testing/test-transactor.ts +680 -638
- package/src/transaction/coordinator.ts +1266 -1110
- package/src/transaction/operations-hash.ts +1 -1
- package/src/transaction/transaction.ts +4 -2
- package/src/transactor/network-transactor.ts +123 -32
- package/src/transactor/transactor-source.ts +17 -9
- package/src/transform/cache-source.ts +20 -0
- package/src/transform/digest.ts +72 -0
- package/src/transform/index.ts +1 -0
- package/src/transform/tracker.ts +42 -1
- package/src/utility/canonical-json.ts +16 -0
- 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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
if (
|
|
161
|
-
throw
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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:
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
failedCollections: coordResult.failedCollections ? [...coordResult.failedCollections] : undefined,
|
|
655
|
+
success: true,
|
|
656
|
+
actions: result.actions,
|
|
657
|
+
results: actionResults
|
|
537
658
|
};
|
|
538
659
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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,
|
|
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
|
-
//
|
|
746
|
-
|
|
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
|
|
862
|
-
//
|
|
863
|
-
//
|
|
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,
|