@tanstack/db-sqlite-persistence-core 0.4.7 → 0.4.9
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/README.md +32 -3
- package/dist/cjs/broadcast-coordinator.cjs +282 -43
- package/dist/cjs/broadcast-coordinator.cjs.map +1 -1
- package/dist/cjs/broadcast-coordinator.d.cts +25 -8
- package/dist/cjs/committed-tx-anchor.cjs +9 -0
- package/dist/cjs/committed-tx-anchor.cjs.map +1 -0
- package/dist/cjs/committed-tx-anchor.d.cts +1 -0
- package/dist/cjs/index.cjs +4 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/persisted.cjs +1599 -313
- package/dist/cjs/persisted.cjs.map +1 -1
- package/dist/cjs/persisted.d.cts +106 -13
- package/dist/cjs/remote-subset-wire.cjs +11 -0
- package/dist/cjs/remote-subset-wire.cjs.map +1 -1
- package/dist/cjs/remote-subset-wire.d.cts +1 -0
- package/dist/cjs/sqlite-core-adapter.cjs +705 -64
- package/dist/cjs/sqlite-core-adapter.cjs.map +1 -1
- package/dist/cjs/sqlite-core-adapter.d.cts +53 -10
- package/dist/esm/broadcast-coordinator.d.ts +25 -8
- package/dist/esm/broadcast-coordinator.js +283 -44
- package/dist/esm/broadcast-coordinator.js.map +1 -1
- package/dist/esm/committed-tx-anchor.d.ts +1 -0
- package/dist/esm/committed-tx-anchor.js +9 -0
- package/dist/esm/committed-tx-anchor.js.map +1 -0
- package/dist/esm/index.js +6 -2
- package/dist/esm/persisted.d.ts +106 -13
- package/dist/esm/persisted.js +1601 -315
- package/dist/esm/persisted.js.map +1 -1
- package/dist/esm/remote-subset-wire.d.ts +1 -0
- package/dist/esm/remote-subset-wire.js +11 -0
- package/dist/esm/remote-subset-wire.js.map +1 -1
- package/dist/esm/sqlite-core-adapter.d.ts +53 -10
- package/dist/esm/sqlite-core-adapter.js +708 -67
- package/dist/esm/sqlite-core-adapter.js.map +1 -1
- package/package.json +2 -2
- package/src/broadcast-coordinator.ts +415 -62
- package/src/committed-tx-anchor.ts +13 -0
- package/src/persisted.ts +2283 -370
- package/src/remote-subset-wire.ts +12 -0
- package/src/sqlite-core-adapter.ts +891 -68
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { safeRandomUUID } from "@tanstack/db";
|
|
2
|
-
import {
|
|
2
|
+
import { isValidCommittedTxAnchor } from "./committed-tx-anchor.js";
|
|
3
|
+
import { DuplicateRemoteSubsetOwnerError, InvalidPersistenceAdapterError, RetryableRemoteSubsetAcquisitionError, IndeterminateCommitError, InvalidPersistedCollectionConfigError, toPersistedCollectionDurabilityError, PersistedCollectionDurabilityError } from "./errors.js";
|
|
3
4
|
import { toTransportedLoadSubsetOptions, toProcessLocalLoadSubsetOptions } from "./remote-subset-wire.js";
|
|
4
5
|
import { unloadRemoteSubsetOwner } from "./remote-subset-owner.js";
|
|
6
|
+
import { managedStorageTarget, eagerStorageTarget } from "./persisted.js";
|
|
5
7
|
const HEARTBEAT_INTERVAL_MS = 3e3;
|
|
6
8
|
const LEADERSHIP_RETRY_DELAY_MS = 200;
|
|
7
9
|
const RPC_TIMEOUT_MS = 1e4;
|
|
@@ -11,10 +13,16 @@ const REMOTE_SUBSET_REPLAY_RETRY_ATTEMPTS = 2;
|
|
|
11
13
|
const RPC_DEDUPE_RETENTION_MS = 6e4;
|
|
12
14
|
const WRITER_LOCK_BUSY_RETRY_MS = 50;
|
|
13
15
|
const WRITER_LOCK_MAX_RETRIES = 20;
|
|
16
|
+
function requireDurableElection(adapter) {
|
|
17
|
+
if (typeof adapter.reserveLeadershipTerm !== `function`) {
|
|
18
|
+
throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
14
21
|
class BroadcastCollectionCoordinator {
|
|
15
22
|
constructor(options) {
|
|
16
23
|
this.nodeId = safeRandomUUID();
|
|
17
24
|
this.collectionAdapters = /* @__PURE__ */ new Map();
|
|
25
|
+
this.collectionClaimIds = /* @__PURE__ */ new Map();
|
|
18
26
|
this.remoteSubsetOwners = /* @__PURE__ */ new Map();
|
|
19
27
|
this.remoteSubsetIds = /* @__PURE__ */ new Map();
|
|
20
28
|
this.outboundRemoteSubsetAcquisitions = /* @__PURE__ */ new Map();
|
|
@@ -27,6 +35,7 @@ class BroadcastCollectionCoordinator {
|
|
|
27
35
|
this.appliedEnvelopePruneTimer = null;
|
|
28
36
|
this.rejectDisposed = null;
|
|
29
37
|
this.disposed = false;
|
|
38
|
+
if (options.adapter) requireDurableElection(options.adapter);
|
|
30
39
|
this.dbName = options.dbName;
|
|
31
40
|
this.coordinatorName = options.coordinatorName;
|
|
32
41
|
this.defaultAdapter = options.adapter ?? null;
|
|
@@ -65,11 +74,18 @@ class BroadcastCollectionCoordinator {
|
|
|
65
74
|
* Called by a host persistence factory to wire the internally-created adapter.
|
|
66
75
|
*/
|
|
67
76
|
setAdapter(adapter) {
|
|
77
|
+
requireDurableElection(adapter);
|
|
68
78
|
this.defaultAdapter = adapter;
|
|
69
79
|
}
|
|
70
80
|
/** Register the persistence adapter that owns one collection. */
|
|
71
|
-
setAdapterForCollection(collectionId, adapter) {
|
|
81
|
+
setAdapterForCollection(collectionId, adapter, cacheGenerationClaimId) {
|
|
82
|
+
requireDurableElection(adapter);
|
|
72
83
|
this.collectionAdapters.set(collectionId, adapter);
|
|
84
|
+
if (cacheGenerationClaimId) {
|
|
85
|
+
this.collectionClaimIds.set(collectionId, cacheGenerationClaimId);
|
|
86
|
+
} else {
|
|
87
|
+
this.collectionClaimIds.delete(collectionId);
|
|
88
|
+
}
|
|
73
89
|
}
|
|
74
90
|
registerRemoteSubsetOwner(collectionId, owner) {
|
|
75
91
|
if (this.remoteSubsetOwners.has(collectionId)) {
|
|
@@ -213,10 +229,29 @@ class BroadcastCollectionCoordinator {
|
|
|
213
229
|
}
|
|
214
230
|
try {
|
|
215
231
|
await acquisition.release;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.scheduleRemoteSubsetReleaseRetry(acquisition, options);
|
|
234
|
+
throw error;
|
|
216
235
|
} finally {
|
|
217
236
|
acquisition.release = null;
|
|
218
237
|
}
|
|
219
238
|
}
|
|
239
|
+
scheduleRemoteSubsetReleaseRetry(acquisition, options) {
|
|
240
|
+
const key = remoteSubsetAcquisitionKey(
|
|
241
|
+
acquisition.collectionId,
|
|
242
|
+
acquisition.acquisitionId
|
|
243
|
+
);
|
|
244
|
+
if (this.isDisposed() || this.outboundRemoteSubsetAcquisitions.get(key) !== acquisition || acquisition.retryTimer !== null) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
acquisition.retryTimer = setTimeout(() => {
|
|
248
|
+
acquisition.retryTimer = null;
|
|
249
|
+
void this.requestReleaseRemoteSubset(
|
|
250
|
+
acquisition.collectionId,
|
|
251
|
+
options
|
|
252
|
+
).catch(() => void 0);
|
|
253
|
+
}, RPC_RETRY_DELAY_MS);
|
|
254
|
+
}
|
|
220
255
|
async acquireRemoteSubset(acquisition) {
|
|
221
256
|
if (acquisition.inFlight) return acquisition.inFlight;
|
|
222
257
|
const route = { localOwner: false };
|
|
@@ -276,7 +311,9 @@ class BroadcastCollectionCoordinator {
|
|
|
276
311
|
try {
|
|
277
312
|
await work;
|
|
278
313
|
acquired = true;
|
|
279
|
-
|
|
314
|
+
if (!acquisition.releaseRequested) {
|
|
315
|
+
this.cancelRemoteSubsetReplayRetry(acquisition);
|
|
316
|
+
}
|
|
280
317
|
} finally {
|
|
281
318
|
if (acquisition.inFlight === work) acquisition.inFlight = null;
|
|
282
319
|
const current = this.collections.get(acquisition.collectionId);
|
|
@@ -293,21 +330,34 @@ class BroadcastCollectionCoordinator {
|
|
|
293
330
|
}
|
|
294
331
|
}
|
|
295
332
|
}
|
|
296
|
-
async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false) {
|
|
333
|
+
async requestEnsurePersistedIndex(collectionId, signature, spec, scopedAdapter, localEnsureCompleted = false, cacheGenerationClaimId) {
|
|
297
334
|
if (this.isLeader(collectionId)) {
|
|
298
335
|
if (localEnsureCompleted) return;
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
336
|
+
const adapter = scopedAdapter ?? this.requireAdapter(collectionId);
|
|
337
|
+
if (cacheGenerationClaimId) {
|
|
338
|
+
await adapter.ensureIndex(
|
|
339
|
+
managedStorageTarget(collectionId, cacheGenerationClaimId),
|
|
340
|
+
signature,
|
|
341
|
+
spec,
|
|
342
|
+
{
|
|
343
|
+
cacheGenerationClaimId
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
} else {
|
|
347
|
+
await adapter.ensureIndex(
|
|
348
|
+
eagerStorageTarget(collectionId),
|
|
349
|
+
signature,
|
|
350
|
+
spec
|
|
351
|
+
);
|
|
352
|
+
}
|
|
304
353
|
return;
|
|
305
354
|
}
|
|
306
355
|
const response = await this.sendRPC(collectionId, {
|
|
307
356
|
type: `rpc:ensurePersistedIndex:req`,
|
|
308
357
|
rpcId: safeRandomUUID(),
|
|
309
358
|
signature,
|
|
310
|
-
spec
|
|
359
|
+
spec,
|
|
360
|
+
cacheGenerationClaimId
|
|
311
361
|
});
|
|
312
362
|
if (!response.ok) {
|
|
313
363
|
throw new Error(
|
|
@@ -315,7 +365,7 @@ class BroadcastCollectionCoordinator {
|
|
|
315
365
|
);
|
|
316
366
|
}
|
|
317
367
|
}
|
|
318
|
-
async requestApplyLocalMutations(collectionId, mutations) {
|
|
368
|
+
async requestApplyLocalMutations(collectionId, mutations, cacheGenerationClaimId) {
|
|
319
369
|
const initialRoute = this.waitForInitialRoute(collectionId);
|
|
320
370
|
if (initialRoute) await initialRoute;
|
|
321
371
|
if (this.isLeader(collectionId)) {
|
|
@@ -323,14 +373,16 @@ class BroadcastCollectionCoordinator {
|
|
|
323
373
|
type: `rpc:applyLocalMutations:req`,
|
|
324
374
|
rpcId: safeRandomUUID(),
|
|
325
375
|
envelopeId: safeRandomUUID(),
|
|
326
|
-
mutations
|
|
376
|
+
mutations,
|
|
377
|
+
cacheGenerationClaimId
|
|
327
378
|
});
|
|
328
379
|
}
|
|
329
380
|
return this.sendRPC(collectionId, {
|
|
330
381
|
type: `rpc:applyLocalMutations:req`,
|
|
331
382
|
rpcId: safeRandomUUID(),
|
|
332
383
|
envelopeId: safeRandomUUID(),
|
|
333
|
-
mutations
|
|
384
|
+
mutations,
|
|
385
|
+
cacheGenerationClaimId
|
|
334
386
|
});
|
|
335
387
|
}
|
|
336
388
|
async requestApplyCommittedTx(collectionId, tx, scopedAdapter) {
|
|
@@ -347,14 +399,39 @@ class BroadcastCollectionCoordinator {
|
|
|
347
399
|
}
|
|
348
400
|
return this.sendRPC(collectionId, request);
|
|
349
401
|
}
|
|
350
|
-
async
|
|
402
|
+
async reconcileCommittedTx(collectionId, tx, anchor, scopedAdapter) {
|
|
403
|
+
const initialRoute = this.waitForInitialRoute(collectionId);
|
|
404
|
+
if (initialRoute) await initialRoute;
|
|
405
|
+
let response;
|
|
406
|
+
for (let attempt = 0; attempt <= RPC_RETRY_ATTEMPTS; attempt++) {
|
|
407
|
+
if (attempt > 0) await sleep(RPC_RETRY_DELAY_MS * attempt);
|
|
408
|
+
const request = {
|
|
409
|
+
type: `rpc:reconcileCommittedTx:req`,
|
|
410
|
+
rpcId: safeRandomUUID(),
|
|
411
|
+
tx,
|
|
412
|
+
anchor
|
|
413
|
+
};
|
|
414
|
+
response = this.isLeader(collectionId) ? await this.handleReconcileCommittedTx(
|
|
415
|
+
collectionId,
|
|
416
|
+
request,
|
|
417
|
+
scopedAdapter
|
|
418
|
+
) : await this.sendRPC(
|
|
419
|
+
collectionId,
|
|
420
|
+
request
|
|
421
|
+
);
|
|
422
|
+
if (response.ok || response.code !== `NOT_LEADER`) return response;
|
|
423
|
+
}
|
|
424
|
+
return response;
|
|
425
|
+
}
|
|
426
|
+
async pullSince(collectionId, fromRowVersion, scopedAdapter, cacheGenerationClaimId) {
|
|
351
427
|
if (this.isLeader(collectionId)) {
|
|
352
428
|
return this.handlePullSince(
|
|
353
429
|
collectionId,
|
|
354
430
|
{
|
|
355
431
|
type: `rpc:pullSince:req`,
|
|
356
432
|
rpcId: safeRandomUUID(),
|
|
357
|
-
fromRowVersion
|
|
433
|
+
fromRowVersion,
|
|
434
|
+
cacheGenerationClaimId
|
|
358
435
|
},
|
|
359
436
|
scopedAdapter
|
|
360
437
|
);
|
|
@@ -362,7 +439,8 @@ class BroadcastCollectionCoordinator {
|
|
|
362
439
|
return this.sendRPC(collectionId, {
|
|
363
440
|
type: `rpc:pullSince:req`,
|
|
364
441
|
rpcId: safeRandomUUID(),
|
|
365
|
-
fromRowVersion
|
|
442
|
+
fromRowVersion,
|
|
443
|
+
cacheGenerationClaimId
|
|
366
444
|
});
|
|
367
445
|
}
|
|
368
446
|
// -----------------------------------------------------------------------
|
|
@@ -395,6 +473,7 @@ class BroadcastCollectionCoordinator {
|
|
|
395
473
|
this.channel.close();
|
|
396
474
|
this.collections.clear();
|
|
397
475
|
this.collectionAdapters.clear();
|
|
476
|
+
this.collectionClaimIds.clear();
|
|
398
477
|
for (const collectionId of this.remoteSubsetOwners.keys()) {
|
|
399
478
|
this.releaseInboundRemoteSubsetAcquisitions(collectionId);
|
|
400
479
|
}
|
|
@@ -452,16 +531,24 @@ class BroadcastCollectionCoordinator {
|
|
|
452
531
|
}
|
|
453
532
|
try {
|
|
454
533
|
const adapter = this.requireAdapter(collectionId);
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
534
|
+
const reserveTerm = adapter.reserveLeadershipTerm;
|
|
535
|
+
if (!reserveTerm)
|
|
536
|
+
throw new InvalidPersistenceAdapterError(`reserveLeadershipTerm`);
|
|
537
|
+
const pos = await this.withWriterLock(() => {
|
|
538
|
+
const claimId = this.collectionClaimIds.get(collectionId);
|
|
539
|
+
return reserveTerm.call(
|
|
540
|
+
adapter,
|
|
541
|
+
claimId ? managedStorageTarget(collectionId, claimId) : eagerStorageTarget(collectionId),
|
|
542
|
+
state.latestTerm,
|
|
543
|
+
{ cacheGenerationClaimId: claimId }
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
state.latestTerm = pos.latestTerm;
|
|
547
|
+
state.latestSeq = pos.latestSeq;
|
|
548
|
+
state.latestRowVersion = pos.latestRowVersion;
|
|
461
549
|
if (this.isDisposed() || !this.participatesInLeadership(state) || this.collections.get(collectionId) !== state || abortController.signal.aborted || state.lockAbortController !== abortController) {
|
|
462
550
|
return;
|
|
463
551
|
}
|
|
464
|
-
state.latestTerm++;
|
|
465
552
|
state.isLeader = true;
|
|
466
553
|
state.leaderId = this.nodeId;
|
|
467
554
|
this.notifyRouteWaiters(state);
|
|
@@ -602,6 +689,7 @@ class BroadcastCollectionCoordinator {
|
|
|
602
689
|
this.releaseLeadership(collectionId, state);
|
|
603
690
|
this.collections.delete(collectionId);
|
|
604
691
|
this.collectionAdapters.delete(collectionId);
|
|
692
|
+
this.collectionClaimIds.delete(collectionId);
|
|
605
693
|
const prefix = `${JSON.stringify([collectionId]).slice(0, -1)},`;
|
|
606
694
|
for (const key of this.appliedEnvelopes.keys()) {
|
|
607
695
|
if (key.startsWith(prefix)) this.appliedEnvelopes.delete(key);
|
|
@@ -718,6 +806,7 @@ class BroadcastCollectionCoordinator {
|
|
|
718
806
|
const heartbeat = payload;
|
|
719
807
|
if (typeof heartbeat.leaderId === `string` && typeof heartbeat.term === `number` && typeof heartbeat.latestSeq === `number` && typeof heartbeat.latestRowVersion === `number`) {
|
|
720
808
|
const state2 = this.getOrCreateCollectionState(envelope.collectionId);
|
|
809
|
+
if (state2.isLeader) return;
|
|
721
810
|
if (heartbeat.term < state2.latestTerm) return;
|
|
722
811
|
const changedLeader = state2.leaderId !== heartbeat.leaderId;
|
|
723
812
|
state2.leaderId = heartbeat.leaderId;
|
|
@@ -898,6 +987,8 @@ class BroadcastCollectionCoordinator {
|
|
|
898
987
|
return this.handleApplyLocalMutations(collectionId, request);
|
|
899
988
|
case `rpc:applyCommittedTx:req`:
|
|
900
989
|
return this.handleApplyCommittedTx(collectionId, request);
|
|
990
|
+
case `rpc:reconcileCommittedTx:req`:
|
|
991
|
+
return this.handleReconcileCommittedTx(collectionId, request);
|
|
901
992
|
case `rpc:pullSince:req`:
|
|
902
993
|
return this.handlePullSince(collectionId, request);
|
|
903
994
|
}
|
|
@@ -1012,10 +1103,7 @@ class BroadcastCollectionCoordinator {
|
|
|
1012
1103
|
acquisition.released = true;
|
|
1013
1104
|
acquisition.release = (async () => {
|
|
1014
1105
|
try {
|
|
1015
|
-
await acquisition.
|
|
1016
|
-
} catch {
|
|
1017
|
-
}
|
|
1018
|
-
try {
|
|
1106
|
+
await acquisition.admitted;
|
|
1019
1107
|
if (acquisition.transferred) {
|
|
1020
1108
|
await unloadRemoteSubsetOwner(acquisition.owner, acquisition.options);
|
|
1021
1109
|
}
|
|
@@ -1131,6 +1219,7 @@ class BroadcastCollectionCoordinator {
|
|
|
1131
1219
|
owner: options.owner,
|
|
1132
1220
|
options: options.options,
|
|
1133
1221
|
load: Promise.resolve(),
|
|
1222
|
+
admitted: Promise.resolve(),
|
|
1134
1223
|
transferred: false,
|
|
1135
1224
|
released: false,
|
|
1136
1225
|
terminalRelease: false,
|
|
@@ -1138,6 +1227,10 @@ class BroadcastCollectionCoordinator {
|
|
|
1138
1227
|
};
|
|
1139
1228
|
this.releasedRemoteSubsetAcquisitionTimes.delete(key);
|
|
1140
1229
|
this.inboundRemoteSubsetAcquisitions.set(key, acquisition);
|
|
1230
|
+
let resolveAdmission;
|
|
1231
|
+
acquisition.admitted = new Promise((resolve) => {
|
|
1232
|
+
resolveAdmission = resolve;
|
|
1233
|
+
});
|
|
1141
1234
|
let resolveLoad;
|
|
1142
1235
|
let rejectLoad;
|
|
1143
1236
|
acquisition.load = new Promise((resolve, reject) => {
|
|
@@ -1150,6 +1243,8 @@ class BroadcastCollectionCoordinator {
|
|
|
1150
1243
|
void Promise.resolve(load).then(resolveLoad, rejectLoad);
|
|
1151
1244
|
} catch (error) {
|
|
1152
1245
|
rejectLoad(error);
|
|
1246
|
+
} finally {
|
|
1247
|
+
resolveAdmission();
|
|
1153
1248
|
}
|
|
1154
1249
|
try {
|
|
1155
1250
|
await acquisition.load;
|
|
@@ -1174,10 +1269,23 @@ class BroadcastCollectionCoordinator {
|
|
|
1174
1269
|
}
|
|
1175
1270
|
}
|
|
1176
1271
|
async handleEnsurePersistedIndex(collectionId, request) {
|
|
1177
|
-
await this.withScheduledWriterLock(
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1272
|
+
await this.withScheduledWriterLock(collectionId, (adapter) => {
|
|
1273
|
+
if (request.cacheGenerationClaimId) {
|
|
1274
|
+
return adapter.ensureIndex(
|
|
1275
|
+
managedStorageTarget(collectionId, request.cacheGenerationClaimId),
|
|
1276
|
+
request.signature,
|
|
1277
|
+
request.spec,
|
|
1278
|
+
{
|
|
1279
|
+
cacheGenerationClaimId: request.cacheGenerationClaimId
|
|
1280
|
+
}
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
return adapter.ensureIndex(
|
|
1284
|
+
eagerStorageTarget(collectionId),
|
|
1285
|
+
request.signature,
|
|
1286
|
+
request.spec
|
|
1287
|
+
);
|
|
1288
|
+
});
|
|
1181
1289
|
return {
|
|
1182
1290
|
type: `rpc:ensurePersistedIndex:res`,
|
|
1183
1291
|
rpcId: request.rpcId,
|
|
@@ -1206,6 +1314,11 @@ class BroadcastCollectionCoordinator {
|
|
|
1206
1314
|
});
|
|
1207
1315
|
}
|
|
1208
1316
|
async applyLocalMutationsOnce(collectionId, request) {
|
|
1317
|
+
if (this.collectionClaimIds.has(collectionId) && !request.cacheGenerationClaimId) {
|
|
1318
|
+
throw new InvalidPersistedCollectionConfigError(
|
|
1319
|
+
`A managed local mutation requires a persisted cache claim`
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1209
1322
|
const state = this.collections.get(collectionId);
|
|
1210
1323
|
if (!state || !state.isLeader) {
|
|
1211
1324
|
return {
|
|
@@ -1231,6 +1344,7 @@ class BroadcastCollectionCoordinator {
|
|
|
1231
1344
|
}
|
|
1232
1345
|
const pendingTx = {
|
|
1233
1346
|
txId: safeRandomUUID(),
|
|
1347
|
+
cacheGenerationClaimId: request.cacheGenerationClaimId,
|
|
1234
1348
|
mutations: request.mutations.map((m) => ({
|
|
1235
1349
|
type: m.type,
|
|
1236
1350
|
key: m.key,
|
|
@@ -1391,6 +1505,10 @@ class BroadcastCollectionCoordinator {
|
|
|
1391
1505
|
}
|
|
1392
1506
|
);
|
|
1393
1507
|
this.pruneAppliedEnvelopes();
|
|
1508
|
+
this.publishCommittedTx(collectionId, state, tx);
|
|
1509
|
+
return response;
|
|
1510
|
+
}
|
|
1511
|
+
publishCommittedTx(collectionId, state, tx, forceReload = false) {
|
|
1394
1512
|
const committedBase = {
|
|
1395
1513
|
type: `tx:committed`,
|
|
1396
1514
|
term: tx.term,
|
|
@@ -1398,9 +1516,10 @@ class BroadcastCollectionCoordinator {
|
|
|
1398
1516
|
txId: tx.txId,
|
|
1399
1517
|
latestRowVersion: tx.rowVersion
|
|
1400
1518
|
};
|
|
1401
|
-
const committedPayload = tx.truncate ? {
|
|
1519
|
+
const committedPayload = tx.truncate || forceReload ? {
|
|
1402
1520
|
...committedBase,
|
|
1403
|
-
requiresFullReload: true
|
|
1521
|
+
requiresFullReload: true,
|
|
1522
|
+
...forceReload ? { reconciliationReload: true } : {}
|
|
1404
1523
|
} : {
|
|
1405
1524
|
...committedBase,
|
|
1406
1525
|
requiresFullReload: false,
|
|
@@ -1424,24 +1543,123 @@ class BroadcastCollectionCoordinator {
|
|
|
1424
1543
|
for (const subscriber of state.subscribers) {
|
|
1425
1544
|
subscriber(committed);
|
|
1426
1545
|
}
|
|
1546
|
+
}
|
|
1547
|
+
async handleReconcileCommittedTx(collectionId, request, scopedAdapter) {
|
|
1548
|
+
const state = this.collections.get(collectionId);
|
|
1549
|
+
const notLeader = () => ({
|
|
1550
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
1551
|
+
rpcId: request.rpcId,
|
|
1552
|
+
ok: false,
|
|
1553
|
+
code: `NOT_LEADER`,
|
|
1554
|
+
error: `not the leader for ${collectionId}`
|
|
1555
|
+
});
|
|
1556
|
+
if (!state?.isLeader) return notLeader();
|
|
1557
|
+
if (!isValidCommittedTxAnchor(request.anchor) || !hasReconciliationTxPayload(request.tx)) {
|
|
1558
|
+
return {
|
|
1559
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
1560
|
+
rpcId: request.rpcId,
|
|
1561
|
+
ok: false,
|
|
1562
|
+
code: `UNDETERMINED`,
|
|
1563
|
+
error: `the source transaction has no valid durable proof`
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
const expectedTerm = state.latestTerm;
|
|
1567
|
+
const result = await this.withScheduledWriterLock(
|
|
1568
|
+
collectionId,
|
|
1569
|
+
async (adapter) => {
|
|
1570
|
+
const candidate = this.nextPersistedTxIfLeader(
|
|
1571
|
+
collectionId,
|
|
1572
|
+
state,
|
|
1573
|
+
expectedTerm,
|
|
1574
|
+
request.tx
|
|
1575
|
+
);
|
|
1576
|
+
if (candidate === null) return null;
|
|
1577
|
+
if (!adapter.reconcileCommittedTx) return { kind: `unknown` };
|
|
1578
|
+
try {
|
|
1579
|
+
const reconciled = await adapter.reconcileCommittedTx(
|
|
1580
|
+
candidate.cacheGenerationClaimId ? managedStorageTarget(
|
|
1581
|
+
collectionId,
|
|
1582
|
+
candidate.cacheGenerationClaimId
|
|
1583
|
+
) : eagerStorageTarget(collectionId),
|
|
1584
|
+
candidate,
|
|
1585
|
+
request.anchor
|
|
1586
|
+
);
|
|
1587
|
+
if (reconciled.kind !== `unknown`) {
|
|
1588
|
+
state.latestRowVersion = Math.max(
|
|
1589
|
+
state.latestRowVersion,
|
|
1590
|
+
reconciled.latestRowVersion
|
|
1591
|
+
);
|
|
1592
|
+
if (reconciled.kind === `applied-now`) {
|
|
1593
|
+
state.latestSeq = reconciled.committed.seq;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
return reconciled;
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
throw toPersistedCollectionDurabilityError(collectionId, error);
|
|
1599
|
+
}
|
|
1600
|
+
},
|
|
1601
|
+
scopedAdapter
|
|
1602
|
+
);
|
|
1603
|
+
if (result === null) return notLeader();
|
|
1604
|
+
if (result.kind === `unknown`) {
|
|
1605
|
+
return {
|
|
1606
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
1607
|
+
rpcId: request.rpcId,
|
|
1608
|
+
ok: false,
|
|
1609
|
+
code: `UNDETERMINED`,
|
|
1610
|
+
error: `the durable outcome cannot be certified`
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
const current = {
|
|
1614
|
+
term: state.latestTerm,
|
|
1615
|
+
seq: state.latestSeq,
|
|
1616
|
+
rowVersion: state.latestRowVersion
|
|
1617
|
+
};
|
|
1618
|
+
const response = {
|
|
1619
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
1620
|
+
rpcId: request.rpcId,
|
|
1621
|
+
ok: true,
|
|
1622
|
+
alreadyApplied: result.kind === `already-applied`,
|
|
1623
|
+
committed: result.committed
|
|
1624
|
+
};
|
|
1625
|
+
if (!this.isDisposed() && this.collections.get(collectionId) === state) {
|
|
1626
|
+
this.publishCommittedTx(
|
|
1627
|
+
collectionId,
|
|
1628
|
+
state,
|
|
1629
|
+
result.kind === `already-applied` ? { ...request.tx, ...current } : { ...request.tx, ...result.committed },
|
|
1630
|
+
result.kind === `already-applied`
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1427
1633
|
return response;
|
|
1428
1634
|
}
|
|
1635
|
+
nextPersistedTxIfLeader(collectionId, state, expectedTerm, pendingTx) {
|
|
1636
|
+
if (this.isDisposed() || this.collections.get(collectionId) !== state || !state.isLeader || state.leaderId !== this.nodeId || state.latestTerm !== expectedTerm) {
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
return {
|
|
1640
|
+
...pendingTx,
|
|
1641
|
+
term: state.latestTerm,
|
|
1642
|
+
seq: state.latestSeq + 1,
|
|
1643
|
+
rowVersion: state.latestRowVersion + 1
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1429
1646
|
async applyDurablyAtNextStreamPosition(collectionId, state, pendingTx, scopedAdapter) {
|
|
1430
1647
|
const expectedTerm = state.latestTerm;
|
|
1431
1648
|
return this.withScheduledWriterLock(
|
|
1432
1649
|
collectionId,
|
|
1433
1650
|
async (adapter) => {
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
rowVersion: state.latestRowVersion + 1
|
|
1442
|
-
};
|
|
1651
|
+
const tx = this.nextPersistedTxIfLeader(
|
|
1652
|
+
collectionId,
|
|
1653
|
+
state,
|
|
1654
|
+
expectedTerm,
|
|
1655
|
+
pendingTx
|
|
1656
|
+
);
|
|
1657
|
+
if (tx === null) return null;
|
|
1443
1658
|
try {
|
|
1444
|
-
await adapter.applyCommittedTx(
|
|
1659
|
+
await adapter.applyCommittedTx(
|
|
1660
|
+
tx.cacheGenerationClaimId ? managedStorageTarget(collectionId, tx.cacheGenerationClaimId) : eagerStorageTarget(collectionId),
|
|
1661
|
+
tx
|
|
1662
|
+
);
|
|
1445
1663
|
} catch (error) {
|
|
1446
1664
|
throw toPersistedCollectionDurabilityError(collectionId, error);
|
|
1447
1665
|
}
|
|
@@ -1466,7 +1684,13 @@ class BroadcastCollectionCoordinator {
|
|
|
1466
1684
|
requiresFullReload: true
|
|
1467
1685
|
};
|
|
1468
1686
|
}
|
|
1469
|
-
const result = await adapter.pullSince(
|
|
1687
|
+
const result = await adapter.pullSince(
|
|
1688
|
+
request.cacheGenerationClaimId ? managedStorageTarget(collectionId, request.cacheGenerationClaimId) : eagerStorageTarget(collectionId),
|
|
1689
|
+
request.fromRowVersion,
|
|
1690
|
+
{
|
|
1691
|
+
cacheGenerationClaimId: request.cacheGenerationClaimId
|
|
1692
|
+
}
|
|
1693
|
+
);
|
|
1470
1694
|
if (result.requiresFullReload) {
|
|
1471
1695
|
return {
|
|
1472
1696
|
type: `rpc:pullSince:res`,
|
|
@@ -1582,12 +1806,18 @@ function isRPCRequest(payload) {
|
|
|
1582
1806
|
case `rpc:ensurePersistedIndex:req`:
|
|
1583
1807
|
case `rpc:applyLocalMutations:req`:
|
|
1584
1808
|
case `rpc:applyCommittedTx:req`:
|
|
1809
|
+
case `rpc:reconcileCommittedTx:req`:
|
|
1585
1810
|
case `rpc:pullSince:req`:
|
|
1586
1811
|
return true;
|
|
1587
1812
|
default:
|
|
1588
1813
|
return false;
|
|
1589
1814
|
}
|
|
1590
1815
|
}
|
|
1816
|
+
function hasReconciliationTxPayload(value) {
|
|
1817
|
+
if (!value || typeof value !== `object`) return false;
|
|
1818
|
+
const tx = value;
|
|
1819
|
+
return typeof tx.txId === `string` && Array.isArray(tx.mutations);
|
|
1820
|
+
}
|
|
1591
1821
|
function sleep(ms) {
|
|
1592
1822
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1593
1823
|
}
|
|
@@ -1661,6 +1891,15 @@ function createRPCErrorResponse(request, cause) {
|
|
|
1661
1891
|
code: `CONFLICT`,
|
|
1662
1892
|
error
|
|
1663
1893
|
};
|
|
1894
|
+
case `rpc:reconcileCommittedTx:req`:
|
|
1895
|
+
return {
|
|
1896
|
+
type: `rpc:reconcileCommittedTx:res`,
|
|
1897
|
+
rpcId: request.rpcId,
|
|
1898
|
+
ok: false,
|
|
1899
|
+
code: cause instanceof PersistedCollectionDurabilityError ? `PERSISTENCE_ERROR` : `UNDETERMINED`,
|
|
1900
|
+
error,
|
|
1901
|
+
...cause instanceof PersistedCollectionDurabilityError ? toSafeDurabilityDetails(cause) : {}
|
|
1902
|
+
};
|
|
1664
1903
|
case `rpc:pullSince:req`:
|
|
1665
1904
|
return {
|
|
1666
1905
|
type: `rpc:pullSince:res`,
|