@minnowdb/core 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -32
- package/dist/block-format/block.d.ts +32 -13
- package/dist/block-format/block.js +179 -55
- package/dist/block-format/checksum.d.ts +2 -1
- package/dist/block-format/checksum.js +5 -2
- package/dist/block-format/codecs.d.ts +7 -2
- package/dist/block-format/codecs.js +53 -12
- package/dist/block-format/column.d.ts +3 -2
- package/dist/block-format/column.js +96 -30
- package/dist/block-format/index.d.ts +2 -2
- package/dist/block-format/index.js +2 -2
- package/dist/block-format/physical.d.ts +8 -2
- package/dist/block-format/physical.js +14 -6
- package/dist/block-format/types.d.ts +7 -3
- package/dist/block-format/types.js +0 -1
- package/dist/block-format/unicode.d.ts +11 -0
- package/dist/block-format/unicode.js +46 -0
- package/dist/date-value.d.ts +15 -0
- package/dist/date-value.js +64 -0
- package/dist/engine/artifact-cache.d.ts +2 -1
- package/dist/engine/artifact-cache.js +5 -1
- package/dist/engine/batch.d.ts +11 -3
- package/dist/engine/batch.js +17 -7
- package/dist/engine/buffered-writer.d.ts +2 -1
- package/dist/engine/buffered-writer.js +34 -9
- package/dist/engine/cache-limits.d.ts +25 -0
- package/dist/engine/cache-limits.js +25 -0
- package/dist/engine/catalog.d.ts +24 -5
- package/dist/engine/catalog.js +34 -7
- package/dist/engine/client.d.ts +29 -7
- package/dist/engine/client.js +216 -40
- package/dist/engine/database.d.ts +224 -39
- package/dist/engine/database.js +7475 -1539
- package/dist/engine/defaults.d.ts +4 -8
- package/dist/engine/defaults.js +46 -22
- package/dist/engine/errors.d.ts +19 -1
- package/dist/engine/errors.js +32 -2
- package/dist/engine/fts.d.ts +0 -6
- package/dist/engine/fts.js +41 -14
- package/dist/engine/group-index.d.ts +0 -1
- package/dist/engine/group-index.js +6 -11
- package/dist/engine/index.d.ts +16 -10
- package/dist/engine/index.js +12 -9
- package/dist/engine/join-index.d.ts +0 -1
- package/dist/engine/join-index.js +2 -2
- package/dist/engine/keyed-live.d.ts +57 -0
- package/dist/engine/keyed-live.js +226 -0
- package/dist/engine/live-api.d.ts +4 -0
- package/dist/engine/live-api.js +4 -0
- package/dist/engine/live.d.ts +43 -26
- package/dist/engine/live.js +504 -126
- package/dist/engine/memory.d.ts +2 -1
- package/dist/engine/memory.js +3 -2
- package/dist/engine/optimizer.d.ts +0 -1
- package/dist/engine/optimizer.js +262 -27
- package/dist/engine/query-api.d.ts +3 -0
- package/dist/engine/query-api.js +3 -0
- package/dist/engine/query-cache.d.ts +1 -2
- package/dist/engine/query-cache.js +7 -5
- package/dist/engine/query.d.ts +134 -29
- package/dist/engine/query.js +1668 -372
- package/dist/engine/result-wire.d.ts +0 -1
- package/dist/engine/result-wire.js +2 -2
- package/dist/engine/schema-wire.d.ts +6 -3
- package/dist/engine/schema-wire.js +40 -34
- package/dist/engine/schema.d.ts +156 -80
- package/dist/engine/schema.js +570 -108
- package/dist/engine/sort-keys.d.ts +4 -4
- package/dist/engine/sort-keys.js +44 -29
- package/dist/engine/sql-domains.d.ts +31 -0
- package/dist/engine/sql-domains.js +585 -0
- package/dist/engine/sql-driver.d.ts +19 -0
- package/dist/engine/sql-driver.js +1 -0
- package/dist/engine/sql-json.d.ts +7 -9
- package/dist/engine/sql-json.js +75 -15
- package/dist/engine/sql-semantics.d.ts +8 -3
- package/dist/engine/sql-semantics.js +458 -30
- package/dist/engine/typed-live.d.ts +67 -0
- package/dist/engine/typed-live.js +349 -0
- package/dist/engine/vector.d.ts +12 -2
- package/dist/engine/vector.js +635 -119
- package/dist/engine/worker-host.d.ts +9 -2
- package/dist/engine/worker-host.js +620 -152
- package/dist/engine/worker.d.ts +0 -1
- package/dist/engine/worker.js +3 -3
- package/dist/engine/write-block-planner.d.ts +18 -0
- package/dist/engine/write-block-planner.js +112 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/plan/index.d.ts +3 -4
- package/dist/plan/index.js +3 -4
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/storage/indexeddb.d.ts +99 -51
- package/dist/storage/indexeddb.js +13448 -2660
- package/dist/storage/memory.d.ts +64 -57
- package/dist/storage/memory.js +1042 -128
- package/dist/storage/opfs/files.d.ts +18 -7
- package/dist/storage/opfs/files.js +115 -28
- package/dist/storage/opfs/index.d.ts +1 -1
- package/dist/storage/opfs/index.js +1 -1
- package/dist/storage/opfs/leader.d.ts +409 -75
- package/dist/storage/opfs/leader.js +4621 -650
- package/dist/storage/opfs/rpc.d.ts +15 -3
- package/dist/storage/opfs/rpc.js +224 -2
- package/dist/storage/opfs/snapshot-ledger.d.ts +40 -0
- package/dist/storage/opfs/snapshot-ledger.js +281 -0
- package/dist/storage/opfs/store.d.ts +33 -99
- package/dist/storage/opfs/store.js +453 -364
- package/dist/storage/persistence.d.ts +37 -0
- package/dist/storage/persistence.js +78 -0
- package/dist/storage/snapshot-stream.d.ts +24 -0
- package/dist/storage/snapshot-stream.js +897 -0
- package/dist/storage/snapshot.d.ts +9 -85
- package/dist/storage/snapshot.js +33 -265
- package/dist/storage/toolkit/extents.d.ts +41 -7
- package/dist/storage/toolkit/extents.js +402 -39
- package/dist/storage/toolkit/index.d.ts +4 -5
- package/dist/storage/toolkit/index.js +28 -4
- package/dist/storage/toolkit/record-core.d.ts +182 -55
- package/dist/storage/toolkit/record-core.js +6158 -1331
- package/dist/storage/toolkit/sync-file.d.ts +10 -1
- package/dist/storage/toolkit/sync-file.js +50 -2
- package/dist/storage/toolkit/wal.d.ts +23 -5
- package/dist/storage/toolkit/wal.js +89 -27
- package/dist/storage/toolkit/wire.d.ts +15 -2
- package/dist/storage/toolkit/wire.js +199 -10
- package/dist/storage/types.d.ts +1439 -204
- package/dist/storage/types.js +1745 -141
- package/dist/testing/block-store-conformance.d.ts +0 -1
- package/dist/testing/block-store-conformance.js +1009 -119
- package/dist/testing/index.d.ts +46 -26
- package/dist/testing/index.js +112 -57
- package/dist/testing/opfs-shim.d.ts +13 -3
- package/dist/testing/opfs-shim.js +40 -6
- package/dist/testing/simulator.d.ts +97 -0
- package/dist/testing/simulator.js +591 -0
- package/dist/testing/sqllogictest.d.ts +89 -0
- package/dist/testing/sqllogictest.js +434 -0
- package/dist/transactions/index.d.ts +92 -16
- package/dist/transactions/index.js +1074 -236
- package/dist/worker-protocol/index.d.ts +3 -2
- package/dist/worker-protocol/index.js +2 -5
- package/package.json +53 -3
- package/postgres-feature-profile.json +223 -0
- package/sql-feature-matrix.json +172 -252
- package/dist/block-format/block.d.ts.map +0 -1
- package/dist/block-format/block.js.map +0 -1
- package/dist/block-format/checksum.d.ts.map +0 -1
- package/dist/block-format/checksum.js.map +0 -1
- package/dist/block-format/codecs.d.ts.map +0 -1
- package/dist/block-format/codecs.js.map +0 -1
- package/dist/block-format/column.d.ts.map +0 -1
- package/dist/block-format/column.js.map +0 -1
- package/dist/block-format/index.d.ts.map +0 -1
- package/dist/block-format/index.js.map +0 -1
- package/dist/block-format/physical.d.ts.map +0 -1
- package/dist/block-format/physical.js.map +0 -1
- package/dist/block-format/types.d.ts.map +0 -1
- package/dist/block-format/types.js.map +0 -1
- package/dist/engine/artifact-cache.d.ts.map +0 -1
- package/dist/engine/artifact-cache.js.map +0 -1
- package/dist/engine/batch.d.ts.map +0 -1
- package/dist/engine/batch.js.map +0 -1
- package/dist/engine/buffered-writer.d.ts.map +0 -1
- package/dist/engine/buffered-writer.js.map +0 -1
- package/dist/engine/catalog.d.ts.map +0 -1
- package/dist/engine/catalog.js.map +0 -1
- package/dist/engine/client.d.ts.map +0 -1
- package/dist/engine/client.js.map +0 -1
- package/dist/engine/coordinator.d.ts +0 -17
- package/dist/engine/coordinator.d.ts.map +0 -1
- package/dist/engine/coordinator.js +0 -60
- package/dist/engine/coordinator.js.map +0 -1
- package/dist/engine/database.d.ts.map +0 -1
- package/dist/engine/database.js.map +0 -1
- package/dist/engine/defaults.d.ts.map +0 -1
- package/dist/engine/defaults.js.map +0 -1
- package/dist/engine/errors.d.ts.map +0 -1
- package/dist/engine/errors.js.map +0 -1
- package/dist/engine/fts.d.ts.map +0 -1
- package/dist/engine/fts.js.map +0 -1
- package/dist/engine/group-index.d.ts.map +0 -1
- package/dist/engine/group-index.js.map +0 -1
- package/dist/engine/index.d.ts.map +0 -1
- package/dist/engine/index.js.map +0 -1
- package/dist/engine/join-index.d.ts.map +0 -1
- package/dist/engine/join-index.js.map +0 -1
- package/dist/engine/live.d.ts.map +0 -1
- package/dist/engine/live.js.map +0 -1
- package/dist/engine/memory.d.ts.map +0 -1
- package/dist/engine/memory.js.map +0 -1
- package/dist/engine/optimizer.d.ts.map +0 -1
- package/dist/engine/optimizer.js.map +0 -1
- package/dist/engine/query-cache.d.ts.map +0 -1
- package/dist/engine/query-cache.js.map +0 -1
- package/dist/engine/query.d.ts.map +0 -1
- package/dist/engine/query.js.map +0 -1
- package/dist/engine/result-wire.d.ts.map +0 -1
- package/dist/engine/result-wire.js.map +0 -1
- package/dist/engine/schema-wire.d.ts.map +0 -1
- package/dist/engine/schema-wire.js.map +0 -1
- package/dist/engine/schema.d.ts.map +0 -1
- package/dist/engine/schema.js.map +0 -1
- package/dist/engine/sort-keys.d.ts.map +0 -1
- package/dist/engine/sort-keys.js.map +0 -1
- package/dist/engine/sql-json.d.ts.map +0 -1
- package/dist/engine/sql-json.js.map +0 -1
- package/dist/engine/sql-semantics.d.ts.map +0 -1
- package/dist/engine/sql-semantics.js.map +0 -1
- package/dist/engine/vector.d.ts.map +0 -1
- package/dist/engine/vector.js.map +0 -1
- package/dist/engine/worker-host.d.ts.map +0 -1
- package/dist/engine/worker-host.js.map +0 -1
- package/dist/engine/worker.d.ts.map +0 -1
- package/dist/engine/worker.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/plan/index.d.ts.map +0 -1
- package/dist/plan/index.js.map +0 -1
- package/dist/storage/fixture-shape.d.ts +0 -42
- package/dist/storage/fixture-shape.d.ts.map +0 -1
- package/dist/storage/fixture-shape.js +0 -146
- package/dist/storage/fixture-shape.js.map +0 -1
- package/dist/storage/index.d.ts.map +0 -1
- package/dist/storage/index.js.map +0 -1
- package/dist/storage/indexeddb.d.ts.map +0 -1
- package/dist/storage/indexeddb.js.map +0 -1
- package/dist/storage/memory.d.ts.map +0 -1
- package/dist/storage/memory.js.map +0 -1
- package/dist/storage/opfs/files.d.ts.map +0 -1
- package/dist/storage/opfs/files.js.map +0 -1
- package/dist/storage/opfs/index.d.ts.map +0 -1
- package/dist/storage/opfs/index.js.map +0 -1
- package/dist/storage/opfs/leader.d.ts.map +0 -1
- package/dist/storage/opfs/leader.js.map +0 -1
- package/dist/storage/opfs/rpc.d.ts.map +0 -1
- package/dist/storage/opfs/rpc.js.map +0 -1
- package/dist/storage/opfs/store.d.ts.map +0 -1
- package/dist/storage/opfs/store.js.map +0 -1
- package/dist/storage/snapshot.d.ts.map +0 -1
- package/dist/storage/snapshot.js.map +0 -1
- package/dist/storage/toolkit/extents.d.ts.map +0 -1
- package/dist/storage/toolkit/extents.js.map +0 -1
- package/dist/storage/toolkit/index.d.ts.map +0 -1
- package/dist/storage/toolkit/index.js.map +0 -1
- package/dist/storage/toolkit/record-core.d.ts.map +0 -1
- package/dist/storage/toolkit/record-core.js.map +0 -1
- package/dist/storage/toolkit/sync-file.d.ts.map +0 -1
- package/dist/storage/toolkit/sync-file.js.map +0 -1
- package/dist/storage/toolkit/wal.d.ts.map +0 -1
- package/dist/storage/toolkit/wal.js.map +0 -1
- package/dist/storage/toolkit/wire.d.ts.map +0 -1
- package/dist/storage/toolkit/wire.js.map +0 -1
- package/dist/storage/types.d.ts.map +0 -1
- package/dist/storage/types.js.map +0 -1
- package/dist/testing/block-store-conformance.d.ts.map +0 -1
- package/dist/testing/block-store-conformance.js.map +0 -1
- package/dist/testing/index.d.ts.map +0 -1
- package/dist/testing/index.js.map +0 -1
- package/dist/testing/opfs-shim.d.ts.map +0 -1
- package/dist/testing/opfs-shim.js.map +0 -1
- package/dist/testing/seeds.d.ts +0 -11
- package/dist/testing/seeds.d.ts.map +0 -1
- package/dist/testing/seeds.js +0 -50
- package/dist/testing/seeds.js.map +0 -1
- package/dist/transactions/index.d.ts.map +0 -1
- package/dist/transactions/index.js.map +0 -1
- package/dist/worker-protocol/index.d.ts.map +0 -1
- package/dist/worker-protocol/index.js.map +0 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { dateIsoString, dateMilliseconds } from "../date-value.js";
|
|
2
|
+
import { assertTransactionArtifactBatchLimits, assertTransactionArtifactJournalLimits, GarbageCollectionJobConflictError, LeaseConflictError, LeaseExpiredError, MAX_LEVEL_ZERO_SEGMENTS, MAX_MANIFEST_BLOCK_PRESENCE_IDS, MAX_LEASE_TTL_MS, MAX_TRANSACTION_STAGE_BLOCKS, MAX_TRANSACTION_STAGE_BYTES, MAX_TRANSACTION_STAGE_SEGMENTS, MAX_TRANSACTION_COMMIT_DELTA_BYTES, MAX_TRANSACTION_COMMIT_DELTA_ENTRIES, transactionCommitDeltaRetainedBytes, SnapshotManifestMissingError, TransactionRecordConflictError, WriteConflictError, } from "../storage/types.js";
|
|
3
|
+
export const DEFAULT_TRANSACTION_TTL_MS = 30_000;
|
|
4
|
+
export { MAX_LEASE_TTL_MS };
|
|
2
5
|
export class TransactionClosedError extends Error {
|
|
3
6
|
transactionId;
|
|
4
7
|
name = "TransactionClosedError";
|
|
@@ -10,30 +13,30 @@ export class TransactionClosedError extends Error {
|
|
|
10
13
|
export class Snapshot {
|
|
11
14
|
store;
|
|
12
15
|
version;
|
|
13
|
-
|
|
14
|
-
constructor(store, version, blockIds) {
|
|
16
|
+
constructor(store, version) {
|
|
15
17
|
this.store = store;
|
|
16
18
|
this.version = version;
|
|
17
|
-
this.#blockIds = new Set(blockIds);
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
-
return
|
|
20
|
+
async hasBlock(id) {
|
|
21
|
+
return (await this.store.hasManifestBlocks(this.version, [id]))[0] === true;
|
|
21
22
|
}
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
async hasBlocks(ids) {
|
|
24
|
+
const present = [];
|
|
25
|
+
for (let start = 0; start < ids.length; start += MAX_MANIFEST_BLOCK_PRESENCE_IDS) {
|
|
26
|
+
present.push(...(await this.store.hasManifestBlocks(this.version, ids.slice(start, start + MAX_MANIFEST_BLOCK_PRESENCE_IDS))));
|
|
27
|
+
}
|
|
28
|
+
return present;
|
|
24
29
|
}
|
|
25
30
|
async getBlock(id) {
|
|
26
|
-
|
|
27
|
-
return undefined;
|
|
28
|
-
return this.store.getBlock(id);
|
|
31
|
+
return this.store.readManifestBlock(this.version, id);
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
export class LeasedSnapshot extends Snapshot {
|
|
32
35
|
now;
|
|
33
36
|
#record;
|
|
34
37
|
#released = false;
|
|
35
|
-
constructor(store, version,
|
|
36
|
-
super(store, version
|
|
38
|
+
constructor(store, version, record, now) {
|
|
39
|
+
super(store, version);
|
|
37
40
|
this.now = now;
|
|
38
41
|
this.#record = structuredClone(record);
|
|
39
42
|
this.#store = store;
|
|
@@ -51,9 +54,14 @@ export class LeasedSnapshot extends Snapshot {
|
|
|
51
54
|
}
|
|
52
55
|
async renew(ttlMs) {
|
|
53
56
|
this.#assertOpen();
|
|
54
|
-
|
|
55
|
-
const expiresAt =
|
|
56
|
-
this.#record = await this.#store.renewLease(
|
|
57
|
+
const now = this.now();
|
|
58
|
+
const expiresAt = expiryAfter(now, ttlMs);
|
|
59
|
+
this.#record = await this.#store.renewLease({
|
|
60
|
+
id: this.#record.id,
|
|
61
|
+
expectedRevision: this.#record.revision,
|
|
62
|
+
expiresAtCutoff: dateIsoString(now),
|
|
63
|
+
expiresAt,
|
|
64
|
+
});
|
|
57
65
|
return new Date(this.#record.expiresAt);
|
|
58
66
|
}
|
|
59
67
|
/**
|
|
@@ -65,20 +73,25 @@ export class LeasedSnapshot extends Snapshot {
|
|
|
65
73
|
*/
|
|
66
74
|
async moveTo(version, ttlMs) {
|
|
67
75
|
this.#assertOpen();
|
|
68
|
-
validateTtl(ttlMs);
|
|
69
76
|
const move = this.#store.moveLease?.bind(this.#store);
|
|
70
77
|
if (move === undefined)
|
|
71
78
|
return undefined;
|
|
72
|
-
const
|
|
73
|
-
const expiresAt =
|
|
74
|
-
const record = await move(
|
|
79
|
+
const now = this.now();
|
|
80
|
+
const expiresAt = expiryAfter(now, ttlMs);
|
|
81
|
+
const record = await move({
|
|
82
|
+
id: this.#record.id,
|
|
83
|
+
expectedRevision: this.#record.revision,
|
|
84
|
+
manifestVersion: version,
|
|
85
|
+
expiresAtCutoff: dateIsoString(now),
|
|
86
|
+
expiresAt,
|
|
87
|
+
});
|
|
75
88
|
this.#released = true;
|
|
76
|
-
return new LeasedSnapshot(this.#store, version,
|
|
89
|
+
return new LeasedSnapshot(this.#store, version, record, this.now);
|
|
77
90
|
}
|
|
78
91
|
async release() {
|
|
79
92
|
if (this.#released)
|
|
80
93
|
return;
|
|
81
|
-
await this.#store.removeLease(this.#record.id);
|
|
94
|
+
await this.#store.removeLease({ id: this.#record.id, ownerId: this.#record.ownerId });
|
|
82
95
|
this.#released = true;
|
|
83
96
|
}
|
|
84
97
|
/** Closes this snapshot without touching the store — for a lease the store no longer holds. */
|
|
@@ -93,12 +106,27 @@ export class LeasedSnapshot extends Snapshot {
|
|
|
93
106
|
export class DatabaseTransaction {
|
|
94
107
|
store;
|
|
95
108
|
now;
|
|
109
|
+
ttlMs;
|
|
96
110
|
#record;
|
|
97
111
|
#uniqueKeyChanges = [];
|
|
98
|
-
#ftsChanges =
|
|
99
|
-
#
|
|
112
|
+
#ftsChanges = new Map();
|
|
113
|
+
#commitDeltaBytes = 0;
|
|
114
|
+
#commitDeltaEntries = 0;
|
|
115
|
+
#compactionJobId = null;
|
|
116
|
+
#compactionSourceBlockIds = new Set();
|
|
100
117
|
#changedTableIds = new Set();
|
|
118
|
+
#levelZeroSegmentLimits = new Map();
|
|
101
119
|
#logicallyUnchanged = false;
|
|
120
|
+
/**
|
|
121
|
+
* One storage-sized artifact batch may stay process-local until commit. This lets the common
|
|
122
|
+
* one-statement transaction use the store's atomic writeTransaction operation instead of
|
|
123
|
+
* staging a durable journal and committing it in a second adapter transaction. A second batch
|
|
124
|
+
* flushes this one first, so retained bytes remain bounded by the storage batch limits.
|
|
125
|
+
*/
|
|
126
|
+
#deferredBlocks = [];
|
|
127
|
+
#deferredSegments = [];
|
|
128
|
+
/** Segment metadata retained for local post-commit catalog-cache advancement. */
|
|
129
|
+
#knownSegments = new Map();
|
|
102
130
|
/**
|
|
103
131
|
* Whether the record exists in the store. A deferred transaction (`beginDeferred`) starts
|
|
104
132
|
* without one: it is written on the first two-step staging call, or folded into the
|
|
@@ -106,11 +134,33 @@ export class DatabaseTransaction {
|
|
|
106
134
|
* touches nothing.
|
|
107
135
|
*/
|
|
108
136
|
#persisted;
|
|
109
|
-
|
|
137
|
+
/** Durable pin for a deferred transaction that has no transaction record yet. */
|
|
138
|
+
#snapshotLease;
|
|
139
|
+
/** Serializes revisioned operations against the one lease record. */
|
|
140
|
+
#snapshotLeaseOperation = Promise.resolve();
|
|
141
|
+
#snapshotRenewal;
|
|
142
|
+
#heartbeatTimer;
|
|
143
|
+
#renewal;
|
|
144
|
+
#initialCatalogProbe;
|
|
145
|
+
constructor(store, record, now, ttlMs, createWeakRef, options = {}) {
|
|
110
146
|
this.store = store;
|
|
111
147
|
this.now = now;
|
|
148
|
+
this.ttlMs = ttlMs;
|
|
112
149
|
this.#record = structuredClone(record);
|
|
113
150
|
this.#persisted = options.persisted ?? true;
|
|
151
|
+
this.#initialCatalogProbe = options.initialCatalogProbe;
|
|
152
|
+
this.#snapshotLease = options.snapshotLease;
|
|
153
|
+
const reference = createWeakRef(this);
|
|
154
|
+
const timer = setInterval(() => {
|
|
155
|
+
const transaction = reference.deref();
|
|
156
|
+
if (transaction === undefined) {
|
|
157
|
+
clearInterval(timer);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
void transaction.#renewOwnership(true).catch(() => undefined);
|
|
161
|
+
}, Math.max(1, Math.floor(ttlMs / 3)));
|
|
162
|
+
timer.unref?.();
|
|
163
|
+
this.#heartbeatTimer = timer;
|
|
114
164
|
}
|
|
115
165
|
get id() {
|
|
116
166
|
return this.#record.id;
|
|
@@ -122,13 +172,43 @@ export class DatabaseTransaction {
|
|
|
122
172
|
return this.#record.snapshotVersion;
|
|
123
173
|
}
|
|
124
174
|
get pendingBlockIds() {
|
|
125
|
-
return [...this.#record.pendingBlockIds];
|
|
175
|
+
return [...this.#record.pendingBlockIds, ...this.#deferredBlocks.map((block) => block.id)];
|
|
126
176
|
}
|
|
127
177
|
get pendingSegmentIds() {
|
|
128
|
-
return [
|
|
178
|
+
return [
|
|
179
|
+
...this.#record.pendingSegmentIds,
|
|
180
|
+
...this.#deferredSegments.map((segment) => segment.id),
|
|
181
|
+
];
|
|
129
182
|
}
|
|
130
|
-
|
|
131
|
-
|
|
183
|
+
/** Process-local segments retained for a bounded single-shot commit. */
|
|
184
|
+
get deferredSegments() {
|
|
185
|
+
return structuredClone(this.#deferredSegments);
|
|
186
|
+
}
|
|
187
|
+
get pendingTableNextRowId() {
|
|
188
|
+
return this.#record.pendingTableNextRowId;
|
|
189
|
+
}
|
|
190
|
+
get initialCatalogProbe() {
|
|
191
|
+
return this.#initialCatalogProbe === undefined ? undefined : { ...this.#initialCatalogProbe };
|
|
192
|
+
}
|
|
193
|
+
/** Exact local commit contribution; undefined until this transaction commits. */
|
|
194
|
+
get committedCatalogContribution() {
|
|
195
|
+
if (this.#record.status !== "committed")
|
|
196
|
+
return undefined;
|
|
197
|
+
return {
|
|
198
|
+
transaction: structuredClone(this.#record),
|
|
199
|
+
segments: structuredClone([...this.#knownSegments.values()]),
|
|
200
|
+
...(this.#initialCatalogProbe === undefined
|
|
201
|
+
? {}
|
|
202
|
+
: { initialCatalogProbe: { ...this.#initialCatalogProbe } }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
get compactionSourceBlockIds() {
|
|
206
|
+
return [...this.#compactionSourceBlockIds].sort();
|
|
207
|
+
}
|
|
208
|
+
/** Extends durable ownership; scope owners call this while user code is legitimately waiting. */
|
|
209
|
+
async renew() {
|
|
210
|
+
this.#assertActive();
|
|
211
|
+
await this.#renewOwnership(true);
|
|
132
212
|
}
|
|
133
213
|
/**
|
|
134
214
|
* Counts every registration this transaction carries: staged blocks and segments, unique-key
|
|
@@ -138,21 +218,185 @@ export class DatabaseTransaction {
|
|
|
138
218
|
* since there is no statement-level rollback inside a transaction.
|
|
139
219
|
*/
|
|
140
220
|
get stagedWorkCount() {
|
|
141
|
-
return (this
|
|
142
|
-
this
|
|
221
|
+
return (this.pendingBlockIds.length +
|
|
222
|
+
this.pendingSegmentIds.length +
|
|
143
223
|
this.#uniqueKeyChanges.length +
|
|
144
|
-
this.#ftsChanges.
|
|
145
|
-
this.#
|
|
224
|
+
this.#ftsChanges.size +
|
|
225
|
+
this.#compactionSourceBlockIds.size);
|
|
226
|
+
}
|
|
227
|
+
/** Captures all publish-relevant state; row-id/autoincrement reservations deliberately burn. */
|
|
228
|
+
checkpoint() {
|
|
229
|
+
this.#assertActive();
|
|
230
|
+
return {
|
|
231
|
+
transactionId: this.id,
|
|
232
|
+
pendingBlockIds: this.pendingBlockIds,
|
|
233
|
+
pendingSegmentIds: this.pendingSegmentIds,
|
|
234
|
+
uniqueKeyChanges: structuredClone(this.#uniqueKeyChanges),
|
|
235
|
+
ftsChanges: structuredClone(this.#materializedFtsChanges()),
|
|
236
|
+
compactionJobId: this.#compactionJobId,
|
|
237
|
+
compactionSourceBlockIds: [...this.#compactionSourceBlockIds],
|
|
238
|
+
levelZeroSegmentLimits: [...this.#levelZeroSegmentLimits].map(([tableId, limit]) => ({
|
|
239
|
+
tableId,
|
|
240
|
+
limit,
|
|
241
|
+
})),
|
|
242
|
+
changedTableIds: [...this.#changedTableIds],
|
|
243
|
+
logicallyUnchanged: this.#logicallyUnchanged,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** Conservative retained-size estimate used to refuse savepoint clones before allocating. */
|
|
247
|
+
checkpointRetainedBytes() {
|
|
248
|
+
this.#assertActive();
|
|
249
|
+
let bytes = 256;
|
|
250
|
+
const addString = (value) => {
|
|
251
|
+
bytes = checkpointByteSum(bytes, 16 + value.length * 2);
|
|
252
|
+
};
|
|
253
|
+
addString(this.id);
|
|
254
|
+
for (const id of this.pendingBlockIds)
|
|
255
|
+
addString(id);
|
|
256
|
+
for (const id of this.pendingSegmentIds)
|
|
257
|
+
addString(id);
|
|
258
|
+
for (const change of this.#uniqueKeyChanges) {
|
|
259
|
+
addString(change.tableId);
|
|
260
|
+
for (const token of change.keyTokens)
|
|
261
|
+
addString(token);
|
|
262
|
+
bytes = checkpointByteSum(bytes, 32);
|
|
263
|
+
}
|
|
264
|
+
for (const change of this.#materializedFtsChanges()) {
|
|
265
|
+
addString(change.tableId);
|
|
266
|
+
for (const column of change.columns) {
|
|
267
|
+
addString(column.columnId);
|
|
268
|
+
bytes = checkpointByteSum(bytes, 48);
|
|
269
|
+
for (const posting of column.postings) {
|
|
270
|
+
addString(posting.term);
|
|
271
|
+
bytes = checkpointByteSum(bytes, posting.rowIds.length * 8 + posting.tf.length * 8 + 32);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (this.#compactionJobId !== null)
|
|
276
|
+
addString(this.#compactionJobId);
|
|
277
|
+
for (const id of this.#compactionSourceBlockIds)
|
|
278
|
+
addString(id);
|
|
279
|
+
for (const [tableId] of this.#levelZeroSegmentLimits)
|
|
280
|
+
addString(tableId);
|
|
281
|
+
for (const id of this.#changedTableIds)
|
|
282
|
+
addString(id);
|
|
283
|
+
return bytes;
|
|
284
|
+
}
|
|
285
|
+
/** Rewinds staged artifacts and every in-memory commit delta to one earlier checkpoint. */
|
|
286
|
+
async rollbackTo(checkpoint) {
|
|
287
|
+
this.#assertActive();
|
|
288
|
+
if (checkpoint.transactionId !== this.id) {
|
|
289
|
+
throw new TypeError("A transaction checkpoint belongs to another transaction");
|
|
290
|
+
}
|
|
291
|
+
const blockPrefix = checkpoint.pendingBlockIds;
|
|
292
|
+
const segmentPrefix = checkpoint.pendingSegmentIds;
|
|
293
|
+
const retainedBlocks = new Set(blockPrefix);
|
|
294
|
+
const retainedSegments = new Set(segmentPrefix);
|
|
295
|
+
const currentBlockIds = this.pendingBlockIds;
|
|
296
|
+
const currentSegmentIds = this.pendingSegmentIds;
|
|
297
|
+
if (blockPrefix.some((id) => !currentBlockIds.includes(id)) ||
|
|
298
|
+
segmentPrefix.some((id) => !currentSegmentIds.includes(id))) {
|
|
299
|
+
throw new TypeError("A transaction checkpoint is no longer reachable");
|
|
300
|
+
}
|
|
301
|
+
const removedBlockIds = currentBlockIds.filter((id) => !retainedBlocks.has(id));
|
|
302
|
+
const removedSegmentIds = currentSegmentIds.filter((id) => !retainedSegments.has(id));
|
|
303
|
+
if (this.#persisted && (removedBlockIds.length > 0 || removedSegmentIds.length > 0)) {
|
|
304
|
+
await this.#renewOwnership();
|
|
305
|
+
this.#record = await this.store.rollbackTransactionArtifacts({
|
|
306
|
+
transactionId: this.id,
|
|
307
|
+
expectedRevision: this.#record.revision,
|
|
308
|
+
pendingBlockIds: blockPrefix,
|
|
309
|
+
pendingSegmentIds: segmentPrefix,
|
|
310
|
+
removeBlockIds: removedBlockIds,
|
|
311
|
+
removeSegmentIds: removedSegmentIds,
|
|
312
|
+
updatedAt: dateIsoString(this.now()),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
else if (!this.#persisted) {
|
|
316
|
+
for (let index = this.#deferredBlocks.length - 1; index >= 0; index -= 1) {
|
|
317
|
+
if (!retainedBlocks.has(this.#deferredBlocks[index]?.id ?? "")) {
|
|
318
|
+
this.#deferredBlocks.splice(index, 1);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (let index = this.#deferredSegments.length - 1; index >= 0; index -= 1) {
|
|
322
|
+
if (!retainedSegments.has(this.#deferredSegments[index]?.id ?? "")) {
|
|
323
|
+
this.#deferredSegments.splice(index, 1);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const id of removedSegmentIds)
|
|
328
|
+
this.#knownSegments.delete(id);
|
|
329
|
+
this.#uniqueKeyChanges.splice(0);
|
|
330
|
+
this.#ftsChanges.clear();
|
|
331
|
+
this.#commitDeltaBytes = 0;
|
|
332
|
+
this.#commitDeltaEntries = 0;
|
|
333
|
+
for (const changes of checkpoint.uniqueKeyChanges)
|
|
334
|
+
this.setUniqueKeyChanges(changes);
|
|
335
|
+
for (const changes of checkpoint.ftsChanges)
|
|
336
|
+
this.setFtsChanges(changes);
|
|
337
|
+
this.#compactionJobId = checkpoint.compactionJobId;
|
|
338
|
+
this.#compactionSourceBlockIds.clear();
|
|
339
|
+
for (const id of checkpoint.compactionSourceBlockIds)
|
|
340
|
+
this.#compactionSourceBlockIds.add(id);
|
|
341
|
+
this.#levelZeroSegmentLimits.clear();
|
|
342
|
+
for (const { tableId, limit } of checkpoint.levelZeroSegmentLimits) {
|
|
343
|
+
this.#levelZeroSegmentLimits.set(tableId, limit);
|
|
344
|
+
}
|
|
345
|
+
this.#changedTableIds.clear();
|
|
346
|
+
for (const id of checkpoint.changedTableIds)
|
|
347
|
+
this.#changedTableIds.add(id);
|
|
348
|
+
this.#logicallyUnchanged = checkpoint.logicallyUnchanged;
|
|
146
349
|
}
|
|
147
350
|
async snapshot() {
|
|
148
351
|
return loadSnapshot(this.store, this.#record.snapshotVersion);
|
|
149
352
|
}
|
|
150
353
|
async getBlock(id) {
|
|
151
354
|
this.#assertActive();
|
|
355
|
+
const deferred = this.#deferredBlocks.find((block) => block.id === id);
|
|
356
|
+
if (deferred !== undefined)
|
|
357
|
+
return new Uint8Array(deferred.bytes);
|
|
152
358
|
if (this.#record.pendingBlockIds.includes(id))
|
|
153
359
|
return this.store.getBlock(id);
|
|
154
360
|
return (await this.snapshot()).getBlock(id);
|
|
155
361
|
}
|
|
362
|
+
#assertProspectiveArtifactJournal(blocks, segments) {
|
|
363
|
+
const blockIds = new Set(this.pendingBlockIds);
|
|
364
|
+
const segmentIds = new Set(this.pendingSegmentIds);
|
|
365
|
+
for (const block of blocks) {
|
|
366
|
+
if (block.id.length === 0)
|
|
367
|
+
throw new TypeError("Block ID cannot be empty");
|
|
368
|
+
if (blockIds.has(block.id))
|
|
369
|
+
throw new Error(`Block already exists: ${block.id}`);
|
|
370
|
+
blockIds.add(block.id);
|
|
371
|
+
}
|
|
372
|
+
for (const segment of segments) {
|
|
373
|
+
if (segment.id.length === 0)
|
|
374
|
+
throw new TypeError("Segment ID cannot be empty");
|
|
375
|
+
if (segmentIds.has(segment.id))
|
|
376
|
+
throw new Error(`Segment already exists: ${segment.id}`);
|
|
377
|
+
segmentIds.add(segment.id);
|
|
378
|
+
}
|
|
379
|
+
assertTransactionArtifactJournalLimits([...blockIds], [...segmentIds]);
|
|
380
|
+
}
|
|
381
|
+
async #stageArtifactBatch(blocks, segments) {
|
|
382
|
+
if (blocks.length === 0 && segments.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
assertTransactionArtifactBatchLimits(blocks, segments);
|
|
385
|
+
await this.#ensurePersisted();
|
|
386
|
+
await this.#renewOwnership();
|
|
387
|
+
try {
|
|
388
|
+
this.#record = await this.store.stageTransactionArtifacts({
|
|
389
|
+
transactionId: this.id,
|
|
390
|
+
expectedRevision: this.#record.revision,
|
|
391
|
+
blocks,
|
|
392
|
+
segments,
|
|
393
|
+
updatedAt: dateIsoString(this.now()),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
await this.#recoverStagedAcknowledgement(error, blocks, segments);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
156
400
|
async stageBlock(id, bytes) {
|
|
157
401
|
return this.stageBlocks([{ id, bytes }]);
|
|
158
402
|
}
|
|
@@ -160,12 +404,7 @@ export class DatabaseTransaction {
|
|
|
160
404
|
this.#assertActive();
|
|
161
405
|
if (blocks.length === 0)
|
|
162
406
|
return;
|
|
163
|
-
await this
|
|
164
|
-
await this.store.addBlocks(blocks);
|
|
165
|
-
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
166
|
-
pendingBlockIds: [...this.#record.pendingBlockIds, ...blocks.map((block) => block.id)],
|
|
167
|
-
updatedAt: this.now().toISOString(),
|
|
168
|
-
});
|
|
407
|
+
await this.stageArtifacts(blocks, []);
|
|
169
408
|
}
|
|
170
409
|
/**
|
|
171
410
|
* Stages blocks and segments in one journal step. When the store implements the atomic
|
|
@@ -180,31 +419,38 @@ export class DatabaseTransaction {
|
|
|
180
419
|
throw new Error(`Segment ${segment.id} belongs to another transaction`);
|
|
181
420
|
}
|
|
182
421
|
}
|
|
183
|
-
// The journal
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
const ordinalBase = this
|
|
422
|
+
// The journal is an append-only ordered sequence. Stamp the same position on each segment:
|
|
423
|
+
// reads and recovery use the redundant pair to reject reordering or ambiguous same-commit
|
|
424
|
+
// folds before publication.
|
|
425
|
+
const ordinalBase = this.pendingSegmentIds.length;
|
|
187
426
|
const ordered = segments.map((segment, index) => ({
|
|
188
427
|
...segment,
|
|
189
|
-
commitOrdinal:
|
|
428
|
+
commitOrdinal: ordinalBase + index,
|
|
190
429
|
}));
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
430
|
+
this.#assertProspectiveArtifactJournal(blocks, ordered);
|
|
431
|
+
this.#registerLevelZeroSegments(ordered);
|
|
432
|
+
if (blocks.length === 0 && ordered.length === 0)
|
|
433
|
+
return;
|
|
434
|
+
const batches = transactionArtifactBatches(blocks, ordered);
|
|
435
|
+
if (!this.#persisted &&
|
|
436
|
+
this.#deferredBlocks.length === 0 &&
|
|
437
|
+
this.#deferredSegments.length === 0 &&
|
|
438
|
+
batches.length === 1) {
|
|
439
|
+
assertTransactionArtifactBatchLimits(blocks, ordered);
|
|
440
|
+
this.#deferredBlocks.push(...blocks.map((block) => ({ id: block.id, bytes: new Uint8Array(block.bytes) })));
|
|
441
|
+
this.#deferredSegments.push(...structuredClone(ordered));
|
|
194
442
|
for (const segment of ordered)
|
|
195
|
-
|
|
443
|
+
this.#knownSegments.set(segment.id, structuredClone(segment));
|
|
444
|
+
for (const segment of ordered)
|
|
445
|
+
this.#changedTableIds.add(segment.tableId);
|
|
196
446
|
return;
|
|
197
447
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
blocks,
|
|
205
|
-
segments: ordered,
|
|
206
|
-
updatedAt: this.now().toISOString(),
|
|
207
|
-
});
|
|
448
|
+
await this.#persistDeferredArtifacts();
|
|
449
|
+
for (const batch of batches) {
|
|
450
|
+
await this.#stageArtifactBatch(batch.blocks, batch.segments);
|
|
451
|
+
}
|
|
452
|
+
for (const segment of ordered)
|
|
453
|
+
this.#knownSegments.set(segment.id, structuredClone(segment));
|
|
208
454
|
for (const segment of ordered)
|
|
209
455
|
this.#changedTableIds.add(segment.tableId);
|
|
210
456
|
}
|
|
@@ -226,62 +472,88 @@ export class DatabaseTransaction {
|
|
|
226
472
|
}
|
|
227
473
|
const pendingBlockIds = new Set(this.#record.pendingBlockIds);
|
|
228
474
|
const pendingSegmentIds = new Set(this.#record.pendingSegmentIds);
|
|
475
|
+
await this.#assertRetainedArtifactsUnchanged(blocks.filter((block) => pendingBlockIds.has(block.id)), segments.filter((segment) => pendingSegmentIds.has(segment.id)));
|
|
476
|
+
const unstagedBlocks = blocks.filter((block) => !pendingBlockIds.has(block.id));
|
|
477
|
+
const unstagedSegments = segments.filter((segment) => !pendingSegmentIds.has(segment.id));
|
|
229
478
|
if (blocks.length + segments.length > 0 &&
|
|
230
|
-
|
|
231
|
-
|
|
479
|
+
unstagedBlocks.length === 0 &&
|
|
480
|
+
unstagedSegments.length === 0) {
|
|
232
481
|
return this.commit();
|
|
233
482
|
}
|
|
483
|
+
this.#assertProspectiveArtifactJournal(unstagedBlocks, unstagedSegments);
|
|
234
484
|
const single = this.store.writeTransaction?.bind(this.store);
|
|
235
|
-
|
|
236
|
-
|
|
485
|
+
const batches = transactionArtifactBatches(unstagedBlocks, unstagedSegments);
|
|
486
|
+
if (single === undefined || batches.length !== 1) {
|
|
487
|
+
await this.stageArtifacts(unstagedBlocks, unstagedSegments);
|
|
237
488
|
return this.commit();
|
|
238
489
|
}
|
|
490
|
+
await this.#renewOwnership();
|
|
239
491
|
const ordinalBase = this.#record.pendingSegmentIds.length;
|
|
240
|
-
const ordered =
|
|
492
|
+
const ordered = unstagedSegments.map((segment, index) => ({
|
|
241
493
|
...segment,
|
|
242
|
-
commitOrdinal:
|
|
494
|
+
commitOrdinal: ordinalBase + index,
|
|
243
495
|
}));
|
|
496
|
+
assertTransactionArtifactBatchLimits(unstagedBlocks, ordered);
|
|
497
|
+
this.#registerLevelZeroSegments(ordered);
|
|
244
498
|
const changedTableIds = new Set(this.#changedTableIds);
|
|
245
499
|
for (const segment of ordered)
|
|
246
500
|
changedTableIds.add(segment.tableId);
|
|
247
501
|
const { snapshotVersion: _pinned, ...fresh } = this.#record;
|
|
248
502
|
void _pinned;
|
|
249
|
-
const committedAt = this.now()
|
|
503
|
+
const committedAt = dateIsoString(this.now());
|
|
504
|
+
const committedRevision = advanceRevision(this.#record.revision, 2);
|
|
505
|
+
const compactionJobId = this.#compactionJobId;
|
|
506
|
+
const ftsChanges = this.#materializedFtsChanges();
|
|
507
|
+
// Keep the exact staged metadata before issuing the atomic write. A store is allowed to
|
|
508
|
+
// commit and then lose the acknowledgement; #recoverCommitted turns that into success, so
|
|
509
|
+
// recording this only after the await would make the caller's local catalog advancement
|
|
510
|
+
// omit segments that are already durable.
|
|
511
|
+
for (const segment of ordered)
|
|
512
|
+
this.#knownSegments.set(segment.id, structuredClone(segment));
|
|
513
|
+
for (const segment of ordered)
|
|
514
|
+
this.#changedTableIds.add(segment.tableId);
|
|
250
515
|
try {
|
|
251
516
|
const manifest = await single({
|
|
252
517
|
transaction: this.#persisted
|
|
253
518
|
? { id: this.id, expectedRevision: this.#record.revision }
|
|
254
519
|
: { record: fresh },
|
|
255
|
-
blocks,
|
|
520
|
+
blocks: unstagedBlocks,
|
|
256
521
|
segments: ordered,
|
|
257
522
|
expectedManifestVersion: this.#record.snapshotVersion,
|
|
258
523
|
changedTableIds: this.#logicallyUnchanged ? [] : [...changedTableIds],
|
|
259
|
-
...(this.#
|
|
524
|
+
...(this.#compactionSourceBlockIds.size === 0
|
|
260
525
|
? {}
|
|
261
|
-
: {
|
|
526
|
+
: {
|
|
527
|
+
compactionJobId: requiredCompactionJobId(compactionJobId),
|
|
528
|
+
removedBlockIds: [...this.#compactionSourceBlockIds],
|
|
529
|
+
}),
|
|
262
530
|
...(this.#uniqueKeyChanges.length === 0
|
|
263
531
|
? {}
|
|
264
532
|
: { uniqueKeyChanges: this.#uniqueKeyChanges }),
|
|
265
|
-
...(
|
|
533
|
+
...(ftsChanges.length === 0 ? {} : { ftsChanges }),
|
|
534
|
+
...(this.#levelZeroSegmentLimits.size === 0
|
|
535
|
+
? {}
|
|
536
|
+
: {
|
|
537
|
+
levelZeroSegmentLimits: [...this.#levelZeroSegmentLimits].map(([tableId, limit]) => ({
|
|
538
|
+
tableId,
|
|
539
|
+
limit,
|
|
540
|
+
})),
|
|
541
|
+
}),
|
|
266
542
|
committedAt,
|
|
267
543
|
});
|
|
268
|
-
for (const segment of ordered)
|
|
269
|
-
this.#changedTableIds.add(segment.tableId);
|
|
270
544
|
this.#record = {
|
|
271
545
|
...this.#record,
|
|
272
|
-
pendingBlockIds: [
|
|
273
|
-
|
|
274
|
-
].sort(),
|
|
275
|
-
pendingSegmentIds: [
|
|
276
|
-
...new Set([...pendingSegmentIds, ...ordered.map((segment) => segment.id)]),
|
|
277
|
-
].sort(),
|
|
546
|
+
pendingBlockIds: [...pendingBlockIds, ...unstagedBlocks.map((block) => block.id)],
|
|
547
|
+
pendingSegmentIds: [...pendingSegmentIds, ...ordered.map((segment) => segment.id)],
|
|
278
548
|
status: "committed",
|
|
279
549
|
committedVersion: manifest.version,
|
|
280
550
|
// One journal step and one commit, as the two-step shape would have left it.
|
|
281
|
-
revision:
|
|
551
|
+
revision: committedRevision,
|
|
282
552
|
updatedAt: committedAt,
|
|
283
553
|
};
|
|
284
554
|
this.#persisted = true;
|
|
555
|
+
this.#stopHeartbeat();
|
|
556
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
285
557
|
return manifest;
|
|
286
558
|
}
|
|
287
559
|
catch (error) {
|
|
@@ -297,6 +569,7 @@ export class DatabaseTransaction {
|
|
|
297
569
|
*/
|
|
298
570
|
async stageExistingBlocks(blockIds) {
|
|
299
571
|
this.#assertActive();
|
|
572
|
+
await this.#persistDeferredArtifacts();
|
|
300
573
|
const pending = new Set(this.#record.pendingBlockIds);
|
|
301
574
|
const additions = [...new Set(blockIds)].filter((id) => !pending.has(id));
|
|
302
575
|
if (additions.length === 0)
|
|
@@ -304,15 +577,17 @@ export class DatabaseTransaction {
|
|
|
304
577
|
if (additions.some((id) => id.length === 0)) {
|
|
305
578
|
throw new TypeError("Block ID cannot be empty");
|
|
306
579
|
}
|
|
580
|
+
assertTransactionArtifactJournalLimits([...this.#record.pendingBlockIds, ...additions], this.#record.pendingSegmentIds);
|
|
307
581
|
for (const id of additions) {
|
|
308
582
|
if ((await this.store.getBlock(id)) === undefined) {
|
|
309
583
|
throw new Error(`Cannot stage a missing existing block: ${id}`);
|
|
310
584
|
}
|
|
311
585
|
}
|
|
312
586
|
await this.#ensurePersisted();
|
|
587
|
+
await this.#renewOwnership();
|
|
313
588
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
314
589
|
pendingBlockIds: [...this.#record.pendingBlockIds, ...additions],
|
|
315
|
-
updatedAt: this.now()
|
|
590
|
+
updatedAt: dateIsoString(this.now()),
|
|
316
591
|
});
|
|
317
592
|
}
|
|
318
593
|
async stageSegment(record) {
|
|
@@ -320,20 +595,12 @@ export class DatabaseTransaction {
|
|
|
320
595
|
if (record.transactionId !== this.id) {
|
|
321
596
|
throw new Error(`Segment ${record.id} belongs to another transaction`);
|
|
322
597
|
}
|
|
323
|
-
|
|
324
|
-
record = { ...record, commitOrdinal: this.#record.pendingSegmentIds.length };
|
|
325
|
-
}
|
|
326
|
-
this.#changedTableIds.add(record.tableId);
|
|
327
|
-
await this.#ensurePersisted();
|
|
328
|
-
await this.store.addSegment(record);
|
|
329
|
-
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
330
|
-
pendingSegmentIds: [...this.#record.pendingSegmentIds, record.id],
|
|
331
|
-
updatedAt: this.now().toISOString(),
|
|
332
|
-
});
|
|
598
|
+
await this.stageArtifacts([], [record]);
|
|
333
599
|
}
|
|
334
600
|
/** Reconciles a persisted segment whose journal update was interrupted. */
|
|
335
601
|
async stageExistingSegment(segmentId) {
|
|
336
602
|
this.#assertActive();
|
|
603
|
+
await this.#persistDeferredArtifacts();
|
|
337
604
|
if (this.#record.pendingSegmentIds.includes(segmentId))
|
|
338
605
|
return;
|
|
339
606
|
const record = await this.store.getSegment(segmentId);
|
|
@@ -342,13 +609,65 @@ export class DatabaseTransaction {
|
|
|
342
609
|
if (record.transactionId !== this.id) {
|
|
343
610
|
throw new Error(`Segment ${segmentId} belongs to another transaction`);
|
|
344
611
|
}
|
|
612
|
+
if (record.commitOrdinal !== this.#record.pendingSegmentIds.length) {
|
|
613
|
+
throw new Error(`Existing segment ${segmentId} is not the next journal ordinal`);
|
|
614
|
+
}
|
|
615
|
+
assertTransactionArtifactJournalLimits(this.#record.pendingBlockIds, [
|
|
616
|
+
...this.#record.pendingSegmentIds,
|
|
617
|
+
segmentId,
|
|
618
|
+
]);
|
|
619
|
+
this.#registerLevelZeroSegments([record]);
|
|
345
620
|
this.#changedTableIds.add(record.tableId);
|
|
346
621
|
await this.#ensurePersisted();
|
|
622
|
+
await this.#renewOwnership();
|
|
347
623
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
348
624
|
pendingSegmentIds: [...this.#record.pendingSegmentIds, segmentId],
|
|
349
|
-
updatedAt: this.now()
|
|
625
|
+
updatedAt: dateIsoString(this.now()),
|
|
350
626
|
});
|
|
351
627
|
}
|
|
628
|
+
/** Atomically adopts an exact unpublished output left by an aborted attempt of this job. */
|
|
629
|
+
async adoptAbortedCompactionSegment(record, abortedOwner) {
|
|
630
|
+
this.#assertActive();
|
|
631
|
+
await this.#persistDeferredArtifacts();
|
|
632
|
+
if (record.transactionId !== this.id) {
|
|
633
|
+
throw new Error(`Segment ${record.id} belongs to another transaction`);
|
|
634
|
+
}
|
|
635
|
+
if (abortedOwner.status !== "aborted") {
|
|
636
|
+
throw new Error(`Segment ${record.id} owner is not aborted`);
|
|
637
|
+
}
|
|
638
|
+
const compactionJobId = requiredCompactionJobId(this.#compactionJobId);
|
|
639
|
+
this.#assertProspectiveArtifactJournal([], [record]);
|
|
640
|
+
this.#registerLevelZeroSegments([record]);
|
|
641
|
+
this.#changedTableIds.add(record.tableId);
|
|
642
|
+
await this.#ensurePersisted();
|
|
643
|
+
await this.#renewOwnership();
|
|
644
|
+
try {
|
|
645
|
+
this.#record = await this.store.adoptAbortedSegment({
|
|
646
|
+
segment: record,
|
|
647
|
+
expectedAbortedTransactionId: abortedOwner.id,
|
|
648
|
+
expectedAbortedTransactionRevision: abortedOwner.revision,
|
|
649
|
+
replacementTransactionId: this.id,
|
|
650
|
+
expectedReplacementTransactionRevision: this.#record.revision,
|
|
651
|
+
compactionJobId,
|
|
652
|
+
updatedAt: dateIsoString(this.now()),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
// A lost acknowledgement is safe to reconcile: the exact segment and journal move are one
|
|
657
|
+
// adapter transaction. Any partial or foreign state fails the strict equality check.
|
|
658
|
+
const [current, segment] = await Promise.all([
|
|
659
|
+
this.store.getTransaction(this.id),
|
|
660
|
+
this.store.getSegment(record.id),
|
|
661
|
+
]);
|
|
662
|
+
if (current?.status !== "active" ||
|
|
663
|
+
!current.pendingSegmentIds.includes(record.id) ||
|
|
664
|
+
segment?.transactionId !== this.id ||
|
|
665
|
+
!sameSegmentArtifacts(segment, record)) {
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
this.#record = current;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
352
671
|
/**
|
|
353
672
|
* Records that this commit changes a table's logical content without staging a segment for
|
|
354
673
|
* it — dropping the table, for instance, whose only publish is the retirement of its blocks.
|
|
@@ -373,14 +692,23 @@ export class DatabaseTransaction {
|
|
|
373
692
|
/** Appends one operation's key changes; entries commit in operation order. */
|
|
374
693
|
setUniqueKeyChanges(changes) {
|
|
375
694
|
this.#assertActive();
|
|
376
|
-
|
|
695
|
+
if (changes.keyTokens.length > MAX_TRANSACTION_COMMIT_DELTA_ENTRIES) {
|
|
696
|
+
throw new RangeError(`Transaction commit deltas exceed ${String(MAX_TRANSACTION_COMMIT_DELTA_ENTRIES)} entries`);
|
|
697
|
+
}
|
|
698
|
+
// Walk the caller-owned array before allocating a Set or clone. The aggregate tracker below
|
|
699
|
+
// charges the deduplicated retained form, while this preflight prevents one hostile call from
|
|
700
|
+
// creating a database-sized temporary allocation first.
|
|
701
|
+
transactionCommitDeltaRetainedBytes([changes], []);
|
|
702
|
+
const normalized = {
|
|
377
703
|
tableId: changes.tableId,
|
|
378
704
|
// Deduplicated, not sorted: membership is all a store reads from these, and sorting fifty
|
|
379
705
|
// thousand tokens cost a bulk delete a fifth of its time.
|
|
380
706
|
keyTokens: [...new Set(changes.keyTokens)],
|
|
381
707
|
requireAbsent: changes.requireAbsent,
|
|
382
708
|
...(changes.remove === undefined ? {} : { remove: changes.remove }),
|
|
383
|
-
}
|
|
709
|
+
};
|
|
710
|
+
this.#reserveCommitDelta([normalized], []);
|
|
711
|
+
this.#uniqueKeyChanges.push(normalized);
|
|
384
712
|
}
|
|
385
713
|
/**
|
|
386
714
|
* Attaches one batch's full-text deltas; applied atomically with the publish. A second
|
|
@@ -390,82 +718,196 @@ export class DatabaseTransaction {
|
|
|
390
718
|
*/
|
|
391
719
|
setFtsChanges(changes) {
|
|
392
720
|
this.#assertActive();
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
721
|
+
const existingColumns = this.#ftsChanges.get(changes.tableId);
|
|
722
|
+
const tokenTotals = new Map();
|
|
723
|
+
for (const column of changes.columns) {
|
|
724
|
+
const prior = tokenTotals.get(column.columnId) ?? existingColumns?.get(column.columnId)?.totalTokens ?? 0;
|
|
725
|
+
tokenTotals.set(column.columnId, safeWholeNumberSum([prior, column.totalTokens], "Full-text transaction token count"));
|
|
726
|
+
}
|
|
727
|
+
this.#reserveCommitDelta([], [changes]);
|
|
728
|
+
let columns = this.#ftsChanges.get(changes.tableId);
|
|
729
|
+
if (columns === undefined) {
|
|
730
|
+
columns = new Map();
|
|
731
|
+
this.#ftsChanges.set(changes.tableId, columns);
|
|
397
732
|
}
|
|
398
|
-
const merged = new Map(existing.columns.map((column) => [column.columnId, column]));
|
|
399
733
|
for (const column of changes.columns) {
|
|
400
|
-
const present =
|
|
734
|
+
const present = columns.get(column.columnId);
|
|
401
735
|
if (present === undefined) {
|
|
402
|
-
|
|
736
|
+
columns.set(column.columnId, {
|
|
737
|
+
postings: new Map(column.postings.map((posting) => [
|
|
738
|
+
posting.term,
|
|
739
|
+
{
|
|
740
|
+
term: posting.term,
|
|
741
|
+
rowIds: [...posting.rowIds],
|
|
742
|
+
tf: [...posting.tf],
|
|
743
|
+
},
|
|
744
|
+
])),
|
|
745
|
+
totalTokens: column.totalTokens,
|
|
746
|
+
});
|
|
403
747
|
continue;
|
|
404
748
|
}
|
|
405
|
-
const byTerm = new Map(present.postings.map((posting) => [posting.term, posting]));
|
|
406
749
|
for (const posting of column.postings) {
|
|
407
|
-
const held =
|
|
750
|
+
const held = present.postings.get(posting.term);
|
|
408
751
|
if (held === undefined) {
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
byTerm.set(posting.term, {
|
|
752
|
+
present.postings.set(posting.term, {
|
|
413
753
|
term: posting.term,
|
|
414
|
-
rowIds: [...
|
|
415
|
-
tf: [...
|
|
754
|
+
rowIds: [...posting.rowIds],
|
|
755
|
+
tf: [...posting.tf],
|
|
416
756
|
});
|
|
417
757
|
}
|
|
758
|
+
else {
|
|
759
|
+
for (const rowId of posting.rowIds)
|
|
760
|
+
held.rowIds.push(rowId);
|
|
761
|
+
for (const tf of posting.tf)
|
|
762
|
+
held.tf.push(tf);
|
|
763
|
+
}
|
|
418
764
|
}
|
|
419
|
-
|
|
420
|
-
columnId: column.columnId,
|
|
421
|
-
postings: [...byTerm.values()].sort((left, right) => left.term < right.term ? -1 : left.term > right.term ? 1 : 0),
|
|
422
|
-
totalTokens: present.totalTokens + column.totalTokens,
|
|
423
|
-
});
|
|
765
|
+
present.totalTokens = tokenTotals.get(column.columnId) ?? present.totalTokens;
|
|
424
766
|
}
|
|
425
|
-
this.#ftsChanges[this.#ftsChanges.indexOf(existing)] = {
|
|
426
|
-
tableId: changes.tableId,
|
|
427
|
-
columns: [...merged.values()],
|
|
428
|
-
};
|
|
429
767
|
}
|
|
430
|
-
|
|
768
|
+
#materializedFtsChanges() {
|
|
769
|
+
return [...this.#ftsChanges].map(([tableId, columns]) => ({
|
|
770
|
+
tableId,
|
|
771
|
+
columns: [...columns].map(([columnId, change]) => ({
|
|
772
|
+
columnId,
|
|
773
|
+
postings: [...change.postings.values()].sort((left, right) => left.term < right.term ? -1 : left.term > right.term ? 1 : 0),
|
|
774
|
+
totalTokens: change.totalTokens,
|
|
775
|
+
})),
|
|
776
|
+
}));
|
|
777
|
+
}
|
|
778
|
+
#reserveCommitDelta(uniqueKeyChanges, ftsChanges) {
|
|
779
|
+
const added = transactionCommitDeltaRetainedBytes(uniqueKeyChanges, ftsChanges);
|
|
780
|
+
const bytes = this.#commitDeltaBytes + added.bytes;
|
|
781
|
+
const entries = this.#commitDeltaEntries + added.entries;
|
|
782
|
+
if (!Number.isSafeInteger(bytes) || bytes > MAX_TRANSACTION_COMMIT_DELTA_BYTES) {
|
|
783
|
+
throw new RangeError(`Transaction commit deltas exceed ${String(MAX_TRANSACTION_COMMIT_DELTA_BYTES)} retained bytes`);
|
|
784
|
+
}
|
|
785
|
+
if (!Number.isSafeInteger(entries) || entries > MAX_TRANSACTION_COMMIT_DELTA_ENTRIES) {
|
|
786
|
+
throw new RangeError(`Transaction commit deltas exceed ${String(MAX_TRANSACTION_COMMIT_DELTA_ENTRIES)} entries`);
|
|
787
|
+
}
|
|
788
|
+
this.#commitDeltaBytes = bytes;
|
|
789
|
+
this.#commitDeltaEntries = entries;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Binds a physical retirement to one persisted compaction job. Stores revalidate the job,
|
|
793
|
+
* source segments, current manifest, and aliases atomically with publication; this in-memory
|
|
794
|
+
* declaration is intent, never authority to hide arbitrary blocks.
|
|
795
|
+
*/
|
|
796
|
+
setCompactionIntent(compactionJobId, sourceBlockIds) {
|
|
431
797
|
this.#assertActive();
|
|
432
|
-
|
|
798
|
+
if (compactionJobId.length === 0)
|
|
799
|
+
throw new TypeError("Compaction job ID cannot be empty");
|
|
800
|
+
if (sourceBlockIds.length === 0) {
|
|
801
|
+
throw new TypeError("A compaction retirement must name at least one source block");
|
|
802
|
+
}
|
|
803
|
+
const next = new Set(sourceBlockIds);
|
|
804
|
+
if (next.size !== sourceBlockIds.length) {
|
|
805
|
+
throw new TypeError("Compaction source block IDs must be unique");
|
|
806
|
+
}
|
|
807
|
+
if (this.#compactionJobId !== null) {
|
|
808
|
+
if (this.#compactionJobId !== compactionJobId ||
|
|
809
|
+
next.size !== this.#compactionSourceBlockIds.size ||
|
|
810
|
+
[...next].some((id) => !this.#compactionSourceBlockIds.has(id))) {
|
|
811
|
+
throw new Error("A transaction cannot change its compaction retirement intent");
|
|
812
|
+
}
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
for (const id of next) {
|
|
433
816
|
if (id.length === 0)
|
|
434
|
-
throw new TypeError("
|
|
817
|
+
throw new TypeError("Compaction source block ID cannot be empty");
|
|
435
818
|
if (this.#record.pendingBlockIds.includes(id)) {
|
|
436
|
-
throw new Error(`A
|
|
819
|
+
throw new Error(`A compaction cannot retire its own pending block: ${id}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
this.#compactionJobId = compactionJobId;
|
|
823
|
+
for (const id of next)
|
|
824
|
+
this.#compactionSourceBlockIds.add(id);
|
|
825
|
+
}
|
|
826
|
+
/** Adds an adapter-enforced post-commit level-zero ceiling for one table. */
|
|
827
|
+
limitLevelZeroSegments(tableId, limit) {
|
|
828
|
+
this.#assertActive();
|
|
829
|
+
if (tableId.length === 0)
|
|
830
|
+
throw new TypeError("Table ID cannot be empty");
|
|
831
|
+
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
|
832
|
+
throw new RangeError("Level-zero segment limit must be a positive safe integer");
|
|
833
|
+
}
|
|
834
|
+
const existing = this.#levelZeroSegmentLimits.get(tableId);
|
|
835
|
+
this.#levelZeroSegmentLimits.set(tableId, existing === undefined ? limit : Math.min(existing, limit));
|
|
836
|
+
}
|
|
837
|
+
#registerLevelZeroSegments(segments) {
|
|
838
|
+
for (const segment of segments) {
|
|
839
|
+
if (segment.level !== 0)
|
|
840
|
+
continue;
|
|
841
|
+
const existing = this.#levelZeroSegmentLimits.get(segment.tableId);
|
|
842
|
+
if (existing === undefined) {
|
|
843
|
+
this.#levelZeroSegmentLimits.set(segment.tableId, MAX_LEVEL_ZERO_SEGMENTS);
|
|
437
844
|
}
|
|
438
|
-
this.#supersededBlockIds.add(id);
|
|
439
845
|
}
|
|
440
846
|
}
|
|
441
847
|
async commit() {
|
|
442
848
|
this.#assertActive();
|
|
849
|
+
if (this.#deferredBlocks.length > 0 || this.#deferredSegments.length > 0) {
|
|
850
|
+
if (this.store.writeTransaction === undefined) {
|
|
851
|
+
await this.#persistDeferredArtifacts();
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
const blocks = this.#deferredBlocks.splice(0);
|
|
855
|
+
const segments = this.#deferredSegments.splice(0);
|
|
856
|
+
try {
|
|
857
|
+
return await this.stageArtifactsAndCommit(blocks, segments);
|
|
858
|
+
}
|
|
859
|
+
catch (error) {
|
|
860
|
+
// A single-shot refusal mutates nothing. Keep the exact immutable artifacts available
|
|
861
|
+
// for the caller's ordinary rebase-and-retry path.
|
|
862
|
+
this.#deferredBlocks.push(...blocks);
|
|
863
|
+
this.#deferredSegments.push(...segments);
|
|
864
|
+
throw error;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
443
868
|
await this.#ensurePersisted();
|
|
869
|
+
await this.#renewOwnership();
|
|
444
870
|
// The store derives the published manifest from its stored base plus this delta, so the
|
|
445
871
|
// commit neither loads nor rebuilds the full block list.
|
|
446
|
-
const committedAt = this.now()
|
|
872
|
+
const committedAt = dateIsoString(this.now());
|
|
873
|
+
const committedRevision = advanceRevision(this.#record.revision, 1);
|
|
874
|
+
const compactionJobId = this.#compactionJobId;
|
|
875
|
+
const ftsChanges = this.#materializedFtsChanges();
|
|
447
876
|
try {
|
|
448
877
|
const manifest = await this.store.commitTransaction({
|
|
449
878
|
transactionId: this.id,
|
|
450
879
|
expectedTransactionRevision: this.#record.revision,
|
|
451
880
|
expectedManifestVersion: this.#record.snapshotVersion,
|
|
452
881
|
changedTableIds: this.#logicallyUnchanged ? [] : [...this.#changedTableIds],
|
|
453
|
-
...(this.#
|
|
882
|
+
...(this.#compactionSourceBlockIds.size === 0
|
|
454
883
|
? {}
|
|
455
|
-
: {
|
|
884
|
+
: {
|
|
885
|
+
compactionJobId: requiredCompactionJobId(compactionJobId),
|
|
886
|
+
removedBlockIds: [...this.#compactionSourceBlockIds],
|
|
887
|
+
}),
|
|
456
888
|
...(this.#uniqueKeyChanges.length === 0
|
|
457
889
|
? {}
|
|
458
890
|
: { uniqueKeyChanges: this.#uniqueKeyChanges }),
|
|
459
|
-
...(
|
|
891
|
+
...(ftsChanges.length === 0 ? {} : { ftsChanges }),
|
|
892
|
+
...(this.#levelZeroSegmentLimits.size === 0
|
|
893
|
+
? {}
|
|
894
|
+
: {
|
|
895
|
+
levelZeroSegmentLimits: [...this.#levelZeroSegmentLimits].map(([tableId, limit]) => ({
|
|
896
|
+
tableId,
|
|
897
|
+
limit,
|
|
898
|
+
})),
|
|
899
|
+
}),
|
|
460
900
|
committedAt,
|
|
461
901
|
});
|
|
462
902
|
this.#record = {
|
|
463
903
|
...this.#record,
|
|
464
904
|
status: "committed",
|
|
465
905
|
committedVersion: manifest.version,
|
|
466
|
-
revision:
|
|
906
|
+
revision: committedRevision,
|
|
467
907
|
updatedAt: committedAt,
|
|
468
908
|
};
|
|
909
|
+
this.#stopHeartbeat();
|
|
910
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
469
911
|
return manifest;
|
|
470
912
|
}
|
|
471
913
|
catch (error) {
|
|
@@ -475,22 +917,25 @@ export class DatabaseTransaction {
|
|
|
475
917
|
async rebase() {
|
|
476
918
|
this.#assertActive();
|
|
477
919
|
for (;;) {
|
|
478
|
-
const
|
|
920
|
+
const currentVersion = await this.store.getCurrentManifestVersion();
|
|
479
921
|
if (!this.#persisted) {
|
|
480
|
-
// Nothing is in the store yet, so the
|
|
922
|
+
// Nothing is in the store yet, so move the temporary durable pin before changing the
|
|
923
|
+
// local snapshot. The old version remains protected until the replacement is admitted.
|
|
924
|
+
await this.#moveSnapshotLease(currentVersion);
|
|
481
925
|
this.#record = {
|
|
482
926
|
...this.#record,
|
|
483
|
-
snapshotVersion:
|
|
484
|
-
updatedAt: this.now()
|
|
927
|
+
snapshotVersion: currentVersion,
|
|
928
|
+
updatedAt: dateIsoString(this.now()),
|
|
485
929
|
};
|
|
486
|
-
return new Snapshot(this.store,
|
|
930
|
+
return new Snapshot(this.store, currentVersion);
|
|
487
931
|
}
|
|
488
932
|
try {
|
|
933
|
+
await this.#renewOwnership();
|
|
489
934
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
490
|
-
snapshotVersion:
|
|
491
|
-
updatedAt: this.now()
|
|
935
|
+
snapshotVersion: currentVersion,
|
|
936
|
+
updatedAt: dateIsoString(this.now()),
|
|
492
937
|
});
|
|
493
|
-
return new Snapshot(this.store,
|
|
938
|
+
return new Snapshot(this.store, currentVersion);
|
|
494
939
|
}
|
|
495
940
|
catch (error) {
|
|
496
941
|
if (error instanceof SnapshotManifestMissingError) {
|
|
@@ -504,13 +949,21 @@ export class DatabaseTransaction {
|
|
|
504
949
|
this.#assertActive();
|
|
505
950
|
if (!this.#persisted) {
|
|
506
951
|
// Never written, so there is nothing to mark: the transaction simply ends here.
|
|
507
|
-
this.#
|
|
952
|
+
this.#deferredBlocks.length = 0;
|
|
953
|
+
this.#deferredSegments.length = 0;
|
|
954
|
+
this.#knownSegments.clear();
|
|
955
|
+
this.#record = { ...this.#record, status: "aborted", updatedAt: dateIsoString(this.now()) };
|
|
956
|
+
this.#stopHeartbeat();
|
|
957
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
508
958
|
return;
|
|
509
959
|
}
|
|
960
|
+
await this.#renewOwnership();
|
|
510
961
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
511
962
|
status: "aborted",
|
|
512
|
-
updatedAt: this.now()
|
|
963
|
+
updatedAt: dateIsoString(this.now()),
|
|
513
964
|
});
|
|
965
|
+
this.#stopHeartbeat();
|
|
966
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
514
967
|
}
|
|
515
968
|
#assertActive() {
|
|
516
969
|
if (this.#record.status !== "active")
|
|
@@ -524,6 +977,11 @@ export class DatabaseTransaction {
|
|
|
524
977
|
async #ensurePersisted() {
|
|
525
978
|
if (this.#persisted)
|
|
526
979
|
return;
|
|
980
|
+
this.#record = {
|
|
981
|
+
...this.#record,
|
|
982
|
+
expiresAt: this.#nextExpiry(),
|
|
983
|
+
updatedAt: dateIsoString(this.now()),
|
|
984
|
+
};
|
|
527
985
|
try {
|
|
528
986
|
await this.store.createTransaction(this.#record);
|
|
529
987
|
}
|
|
@@ -534,6 +992,19 @@ export class DatabaseTransaction {
|
|
|
534
992
|
throw error;
|
|
535
993
|
}
|
|
536
994
|
this.#persisted = true;
|
|
995
|
+
// The transaction record now owns the same durable pin and renews on this heartbeat. Keep
|
|
996
|
+
// only one record rooted; a failed cleanup remains bounded by the lease expiry.
|
|
997
|
+
void this.#releaseSnapshotLease().catch(() => undefined);
|
|
998
|
+
}
|
|
999
|
+
/** Flushes the bounded process-local batch before a second durable staging operation. */
|
|
1000
|
+
async #persistDeferredArtifacts() {
|
|
1001
|
+
if (this.#deferredBlocks.length === 0 && this.#deferredSegments.length === 0)
|
|
1002
|
+
return;
|
|
1003
|
+
const blocks = [...this.#deferredBlocks];
|
|
1004
|
+
const segments = [...this.#deferredSegments];
|
|
1005
|
+
await this.#stageArtifactBatch(blocks, segments);
|
|
1006
|
+
this.#deferredBlocks.length = 0;
|
|
1007
|
+
this.#deferredSegments.length = 0;
|
|
537
1008
|
}
|
|
538
1009
|
/** A commit whose acknowledgement was lost still committed: answer from the persisted record. */
|
|
539
1010
|
async #recoverCommitted(error) {
|
|
@@ -543,85 +1014,292 @@ export class DatabaseTransaction {
|
|
|
543
1014
|
if (manifest !== undefined) {
|
|
544
1015
|
this.#record = persisted;
|
|
545
1016
|
this.#persisted = true;
|
|
1017
|
+
this.#stopHeartbeat();
|
|
1018
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
546
1019
|
return manifest;
|
|
547
1020
|
}
|
|
548
1021
|
}
|
|
549
1022
|
throw error;
|
|
550
1023
|
}
|
|
1024
|
+
async #renewOwnership(force = false) {
|
|
1025
|
+
await this.#renewSnapshotLease(force);
|
|
1026
|
+
if (!this.#persisted || this.#record.status !== "active")
|
|
1027
|
+
return;
|
|
1028
|
+
const now = dateMilliseconds(this.now());
|
|
1029
|
+
if (!force && Date.parse(this.#record.expiresAt) - now > this.ttlMs / 3)
|
|
1030
|
+
return;
|
|
1031
|
+
const expiresAt = expiryAfter(new Date(now), this.ttlMs);
|
|
1032
|
+
const renewal = this.#renewal ??
|
|
1033
|
+
this.store
|
|
1034
|
+
.renewTransaction({
|
|
1035
|
+
transactionId: this.id,
|
|
1036
|
+
ownerId: this.#record.ownerId,
|
|
1037
|
+
expiresAtCutoff: dateIsoString(new Date(now)),
|
|
1038
|
+
expiresAt,
|
|
1039
|
+
})
|
|
1040
|
+
.then((owned) => ({ owned, expiresAt }));
|
|
1041
|
+
this.#renewal = renewal;
|
|
1042
|
+
let result;
|
|
1043
|
+
try {
|
|
1044
|
+
result = await renewal;
|
|
1045
|
+
}
|
|
1046
|
+
finally {
|
|
1047
|
+
if (this.#renewal === renewal)
|
|
1048
|
+
this.#renewal = undefined;
|
|
1049
|
+
}
|
|
1050
|
+
if (result.owned) {
|
|
1051
|
+
this.#record = { ...this.#record, expiresAt: result.expiresAt };
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const persisted = await this.store.getTransaction(this.id);
|
|
1055
|
+
if (persisted !== undefined)
|
|
1056
|
+
this.#record = persisted;
|
|
1057
|
+
this.#stopHeartbeat();
|
|
1058
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
1059
|
+
throw new TransactionClosedError(this.id);
|
|
1060
|
+
}
|
|
1061
|
+
async #renewSnapshotLease(force) {
|
|
1062
|
+
const lease = this.#snapshotLease;
|
|
1063
|
+
if (lease === undefined || this.#record.status !== "active")
|
|
1064
|
+
return;
|
|
1065
|
+
if (!force &&
|
|
1066
|
+
dateMilliseconds(lease.expiresAt) - dateMilliseconds(this.now()) > this.ttlMs / 3) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const renewal = this.#snapshotRenewal ??
|
|
1070
|
+
this.#serializeSnapshotLeaseOperation(async () => {
|
|
1071
|
+
const current = this.#snapshotLease;
|
|
1072
|
+
if (current === undefined || this.#record.status !== "active")
|
|
1073
|
+
return;
|
|
1074
|
+
if (!force &&
|
|
1075
|
+
dateMilliseconds(current.expiresAt) - dateMilliseconds(this.now()) > this.ttlMs / 3) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
await current.renew(this.ttlMs);
|
|
1079
|
+
});
|
|
1080
|
+
this.#snapshotRenewal = renewal;
|
|
1081
|
+
try {
|
|
1082
|
+
await renewal;
|
|
1083
|
+
}
|
|
1084
|
+
finally {
|
|
1085
|
+
if (this.#snapshotRenewal === renewal)
|
|
1086
|
+
this.#snapshotRenewal = undefined;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
async #moveSnapshotLease(version) {
|
|
1090
|
+
await this.#serializeSnapshotLeaseOperation(async () => {
|
|
1091
|
+
const current = this.#snapshotLease;
|
|
1092
|
+
if (current === undefined || current.version === version)
|
|
1093
|
+
return;
|
|
1094
|
+
const moved = await current.moveTo(version, this.ttlMs);
|
|
1095
|
+
if (moved !== undefined) {
|
|
1096
|
+
if (this.#snapshotLease === current) {
|
|
1097
|
+
this.#snapshotLease = moved;
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
// A terminal path detached the old lease while the adapter move was in flight. Do not
|
|
1101
|
+
// resurrect it in memory; retire the moved durable record as part of this operation.
|
|
1102
|
+
await moved.release().catch(() => undefined);
|
|
1103
|
+
}
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
// Custom stores may omit atomic moveLease. Admit a replacement before releasing the old
|
|
1107
|
+
// pin, so there is never a reclamation window between versions.
|
|
1108
|
+
const createdAt = dateIsoString(this.now());
|
|
1109
|
+
const record = {
|
|
1110
|
+
id: crypto.randomUUID(),
|
|
1111
|
+
kind: "reader",
|
|
1112
|
+
manifestVersion: version,
|
|
1113
|
+
ownerId: this.#record.ownerId,
|
|
1114
|
+
createdAt,
|
|
1115
|
+
expiresAt: expiryAfter(new Date(createdAt), this.ttlMs),
|
|
1116
|
+
revision: 0,
|
|
1117
|
+
};
|
|
1118
|
+
await this.store.createLease(record);
|
|
1119
|
+
const replacement = new LeasedSnapshot(this.store, version, record, this.now);
|
|
1120
|
+
if (this.#snapshotLease === current) {
|
|
1121
|
+
this.#snapshotLease = replacement;
|
|
1122
|
+
}
|
|
1123
|
+
else {
|
|
1124
|
+
await replacement.release().catch(() => undefined);
|
|
1125
|
+
}
|
|
1126
|
+
await current.release().catch(() => undefined);
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
async #releaseSnapshotLease() {
|
|
1130
|
+
const lease = this.#snapshotLease;
|
|
1131
|
+
if (lease === undefined)
|
|
1132
|
+
return;
|
|
1133
|
+
// Detach before the adapter call. Once a transaction record is persisted it is the durable
|
|
1134
|
+
// owner; a slow lease removal must not let the next ownership check renew the retiring pin
|
|
1135
|
+
// concurrently and turn a healthy handoff into a LeaseConflictError.
|
|
1136
|
+
this.#snapshotLease = undefined;
|
|
1137
|
+
await this.#serializeSnapshotLeaseOperation(() => lease.release());
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Lease revisions are compare-and-swap tokens, so renew, move, and release must observe one
|
|
1141
|
+
* another's returned record before issuing the next adapter operation. Keep the tail fulfilled
|
|
1142
|
+
* after a refusal: the caller still receives its error, while a terminal cleanup can proceed.
|
|
1143
|
+
*/
|
|
1144
|
+
#serializeSnapshotLeaseOperation(operation) {
|
|
1145
|
+
const run = this.#snapshotLeaseOperation.then(operation);
|
|
1146
|
+
this.#snapshotLeaseOperation = run.then(() => undefined, () => undefined);
|
|
1147
|
+
return run;
|
|
1148
|
+
}
|
|
1149
|
+
#nextExpiry() {
|
|
1150
|
+
return expiryAfter(this.now(), this.ttlMs);
|
|
1151
|
+
}
|
|
1152
|
+
#stopHeartbeat() {
|
|
1153
|
+
if (this.#heartbeatTimer !== undefined)
|
|
1154
|
+
clearInterval(this.#heartbeatTimer);
|
|
1155
|
+
this.#heartbeatTimer = undefined;
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* An adapter can durably stage the artifacts and lose the acknowledgement afterwards. Refresh
|
|
1159
|
+
* the local revision only when the persisted journal is exactly the one this call would have
|
|
1160
|
+
* produced, so the caller's abort/rollback can clean it up without masking the original error.
|
|
1161
|
+
*/
|
|
1162
|
+
async #recoverStagedAcknowledgement(error, blocks, segments) {
|
|
1163
|
+
try {
|
|
1164
|
+
const persisted = await this.store.getTransaction(this.id);
|
|
1165
|
+
const expectedBlocks = [...this.#record.pendingBlockIds, ...blocks.map((block) => block.id)];
|
|
1166
|
+
const expectedSegments = [
|
|
1167
|
+
...this.#record.pendingSegmentIds,
|
|
1168
|
+
...segments.map((segment) => segment.id),
|
|
1169
|
+
];
|
|
1170
|
+
if (persisted?.status === "active" &&
|
|
1171
|
+
persisted.revision === advanceRevision(this.#record.revision, 1) &&
|
|
1172
|
+
sameStrings(persisted.pendingBlockIds, expectedBlocks) &&
|
|
1173
|
+
sameStrings(persisted.pendingSegmentIds, expectedSegments)) {
|
|
1174
|
+
const [storedBlocks, storedSegments] = await Promise.all([
|
|
1175
|
+
Promise.all(blocks.map((block) => this.store.getBlock(block.id))),
|
|
1176
|
+
Promise.all(segments.map((segment) => this.store.getSegment(segment.id))),
|
|
1177
|
+
]);
|
|
1178
|
+
if (blocks.every((block, index) => sameBytes(block.bytes, storedBlocks[index])) &&
|
|
1179
|
+
segments.every((segment, index) => {
|
|
1180
|
+
const stored = storedSegments[index];
|
|
1181
|
+
return stored !== undefined && sameSegmentArtifacts(segment, stored);
|
|
1182
|
+
})) {
|
|
1183
|
+
this.#record = persisted;
|
|
1184
|
+
this.#persisted = true;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
// Preserve the operation error. A failed diagnostic read cannot prove a different outcome.
|
|
1190
|
+
}
|
|
1191
|
+
throw error;
|
|
1192
|
+
}
|
|
1193
|
+
async #assertRetainedArtifactsUnchanged(blocks, segments) {
|
|
1194
|
+
for (const block of blocks) {
|
|
1195
|
+
if (!sameBytes(block.bytes, await this.store.getBlock(block.id))) {
|
|
1196
|
+
throw new Error(`Retried block differs from the staged artifact: ${block.id}`);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
for (const segment of segments) {
|
|
1200
|
+
const stored = await this.store.getSegment(segment.id);
|
|
1201
|
+
if (stored === undefined ||
|
|
1202
|
+
!sameSegmentArtifacts(stored, { ...segment, commitOrdinal: stored.commitOrdinal })) {
|
|
1203
|
+
throw new Error(`Retried segment differs from the staged artifact: ${segment.id}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
function sameStrings(left, right) {
|
|
1209
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1210
|
+
}
|
|
1211
|
+
function sameBytes(left, right) {
|
|
1212
|
+
return (left.byteLength === right?.byteLength && left.every((byte, index) => byte === right[index]));
|
|
551
1213
|
}
|
|
552
1214
|
export class TransactionManager {
|
|
553
1215
|
store;
|
|
554
1216
|
#now;
|
|
555
1217
|
#createId;
|
|
1218
|
+
#transactionTtlMs;
|
|
1219
|
+
#leaseCleanupCursor = null;
|
|
1220
|
+
#createWeakRef;
|
|
556
1221
|
constructor(store, options = {}) {
|
|
557
1222
|
this.store = store;
|
|
558
1223
|
this.#now = options.now ?? (() => new Date());
|
|
559
1224
|
this.#createId = options.createId ?? (() => crypto.randomUUID());
|
|
1225
|
+
this.#transactionTtlMs = options.transactionTtlMs ?? DEFAULT_TRANSACTION_TTL_MS;
|
|
1226
|
+
validateTtl(this.#transactionTtlMs);
|
|
1227
|
+
this.#createWeakRef = options.createWeakRef ?? ((transaction) => new WeakRef(transaction));
|
|
560
1228
|
}
|
|
561
1229
|
async begin() {
|
|
562
1230
|
return (await this.beginWithReservation()).transaction;
|
|
563
1231
|
}
|
|
564
|
-
/**
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
1232
|
+
/** Atomically reserves an invisible catalog table for a create-and-populate transaction. */
|
|
1233
|
+
async beginWithPendingTable(record, expectedCatalogEpoch) {
|
|
1234
|
+
const id = this.#createId();
|
|
1235
|
+
const ownerId = `${id}/owner`;
|
|
1236
|
+
const timestamp = dateIsoString(this.#now());
|
|
1237
|
+
let begun;
|
|
1238
|
+
for (;;) {
|
|
1239
|
+
try {
|
|
1240
|
+
begun = await this.store.beginTransaction({
|
|
1241
|
+
record: {
|
|
1242
|
+
id,
|
|
1243
|
+
ownerId,
|
|
1244
|
+
expiresAt: this.#transactionExpiry(),
|
|
1245
|
+
pendingBlockIds: [],
|
|
1246
|
+
pendingSegmentIds: [],
|
|
1247
|
+
status: "active",
|
|
1248
|
+
revision: 0,
|
|
1249
|
+
startedAt: timestamp,
|
|
1250
|
+
updatedAt: timestamp,
|
|
1251
|
+
committedVersion: null,
|
|
1252
|
+
},
|
|
1253
|
+
pendingTable: { record, nextRowId: 1n, expectedCatalogEpoch },
|
|
1254
|
+
});
|
|
1255
|
+
break;
|
|
1256
|
+
}
|
|
1257
|
+
catch (error) {
|
|
1258
|
+
// Atomic begin writes nothing when its current-manifest pin loses a pruning race.
|
|
1259
|
+
if (error instanceof SnapshotManifestMissingError)
|
|
1260
|
+
continue;
|
|
1261
|
+
throw error;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return new DatabaseTransaction(this.store, begun.record, this.#now, this.#transactionTtlMs, this.#createWeakRef);
|
|
585
1265
|
}
|
|
586
1266
|
/**
|
|
587
|
-
* Begins a transaction
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
1267
|
+
* Begins without a transaction record. By default a renewable reader lease durably pins the
|
|
1268
|
+
* probed manifest until the first two-step stage hands ownership to a transaction record. The
|
|
1269
|
+
* record and lease are both omitted only for internal, uninterrupted single-shot writes that
|
|
1270
|
+
* opt out and publish through `writeTransaction`. For writes whose artifacts need no prior
|
|
1271
|
+
* reservation; `beginWithReservation` is the shape for the rest.
|
|
591
1272
|
*/
|
|
592
|
-
async
|
|
593
|
-
const
|
|
594
|
-
const batched = this.store.beginTransaction?.bind(this.store);
|
|
595
|
-
if (batched !== undefined) {
|
|
596
|
-
const timestamp = this.#now().toISOString();
|
|
597
|
-
const begun = await batched({
|
|
598
|
-
record: {
|
|
599
|
-
id,
|
|
600
|
-
pendingBlockIds: [],
|
|
601
|
-
pendingSegmentIds: [],
|
|
602
|
-
status: "active",
|
|
603
|
-
revision: 0,
|
|
604
|
-
startedAt: timestamp,
|
|
605
|
-
updatedAt: timestamp,
|
|
606
|
-
committedVersion: null,
|
|
607
|
-
},
|
|
608
|
-
...(reserveRowIds === undefined ? {} : { reserveRowIds }),
|
|
609
|
-
...(reserveAutoIncrement === undefined ? {} : { reserveAutoIncrement }),
|
|
610
|
-
});
|
|
611
|
-
return {
|
|
612
|
-
transaction: new DatabaseTransaction(this.store, begun.record, this.#now),
|
|
613
|
-
...(begun.rowIds === undefined ? {} : { rowIds: begun.rowIds }),
|
|
614
|
-
...(begun.autoIncrementValues === undefined
|
|
615
|
-
? {}
|
|
616
|
-
: { autoIncrementValues: begun.autoIncrementValues }),
|
|
617
|
-
};
|
|
618
|
-
}
|
|
1273
|
+
async beginDeferred(options = {}) {
|
|
1274
|
+
const durableSnapshot = options.durableSnapshot ?? true;
|
|
619
1275
|
for (;;) {
|
|
620
|
-
const
|
|
621
|
-
const timestamp = this.#now()
|
|
622
|
-
const
|
|
1276
|
+
const probe = await this.store.getCatalogProbe();
|
|
1277
|
+
const timestamp = dateIsoString(this.#now());
|
|
1278
|
+
const id = this.#createId();
|
|
1279
|
+
const ownerId = `${id}/owner`;
|
|
1280
|
+
let snapshotLease;
|
|
1281
|
+
if (durableSnapshot) {
|
|
1282
|
+
try {
|
|
1283
|
+
snapshotLease = await this.openLeasedSnapshot({
|
|
1284
|
+
id: `${id}/snapshot`,
|
|
1285
|
+
ownerId,
|
|
1286
|
+
ttlMs: this.#transactionTtlMs,
|
|
1287
|
+
version: probe.manifestVersion,
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
catch (error) {
|
|
1291
|
+
// The version was current at the probe but collection won before the durable pin.
|
|
1292
|
+
if (error instanceof SnapshotManifestMissingError)
|
|
1293
|
+
continue;
|
|
1294
|
+
throw error;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return new DatabaseTransaction(this.store, {
|
|
623
1298
|
id,
|
|
624
|
-
|
|
1299
|
+
ownerId,
|
|
1300
|
+
expiresAt: this.#transactionExpiry(),
|
|
1301
|
+
snapshotVersion: probe.manifestVersion,
|
|
1302
|
+
schemaEpochGuard: probe.schemaEpoch,
|
|
625
1303
|
pendingBlockIds: [],
|
|
626
1304
|
pendingSegmentIds: [],
|
|
627
1305
|
status: "active",
|
|
@@ -629,29 +1307,58 @@ export class TransactionManager {
|
|
|
629
1307
|
startedAt: timestamp,
|
|
630
1308
|
updatedAt: timestamp,
|
|
631
1309
|
committedVersion: null,
|
|
632
|
-
}
|
|
1310
|
+
}, this.#now, this.#transactionTtlMs, this.#createWeakRef, {
|
|
1311
|
+
persisted: false,
|
|
1312
|
+
initialCatalogProbe: probe,
|
|
1313
|
+
...(snapshotLease === undefined ? {} : { snapshotLease }),
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Begins a transaction at the current manifest version and optionally reserves row ids and
|
|
1319
|
+
* auto-increment values in the same step. Stores implementing the atomic `beginTransaction`
|
|
1320
|
+
* pay one storage round trip instead of the version-read/create/reserve sequence; the
|
|
1321
|
+
* read-then-create fallback retries when the version it read is pruned before the record lands.
|
|
1322
|
+
*/
|
|
1323
|
+
async beginWithReservation(reserveRowIds, reserveAutoIncrement) {
|
|
1324
|
+
const id = this.#createId();
|
|
1325
|
+
const ownerId = `${id}/owner`;
|
|
1326
|
+
const timestamp = dateIsoString(this.#now());
|
|
1327
|
+
let begun;
|
|
1328
|
+
for (;;) {
|
|
633
1329
|
try {
|
|
634
|
-
await this.store.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
1330
|
+
begun = await this.store.beginTransaction({
|
|
1331
|
+
record: {
|
|
1332
|
+
id,
|
|
1333
|
+
ownerId,
|
|
1334
|
+
expiresAt: this.#transactionExpiry(),
|
|
1335
|
+
pendingBlockIds: [],
|
|
1336
|
+
pendingSegmentIds: [],
|
|
1337
|
+
status: "active",
|
|
1338
|
+
revision: 0,
|
|
1339
|
+
startedAt: timestamp,
|
|
1340
|
+
updatedAt: timestamp,
|
|
1341
|
+
committedVersion: null,
|
|
1342
|
+
},
|
|
1343
|
+
...(reserveRowIds === undefined ? {} : { reserveRowIds }),
|
|
1344
|
+
...(reserveAutoIncrement === undefined ? {} : { reserveAutoIncrement }),
|
|
1345
|
+
});
|
|
1346
|
+
break;
|
|
647
1347
|
}
|
|
648
1348
|
catch (error) {
|
|
649
|
-
|
|
1349
|
+
// The store's atomic begin aborted in full, including any counter reservation.
|
|
1350
|
+
if (error instanceof SnapshotManifestMissingError)
|
|
650
1351
|
continue;
|
|
651
|
-
}
|
|
652
1352
|
throw error;
|
|
653
1353
|
}
|
|
654
1354
|
}
|
|
1355
|
+
return {
|
|
1356
|
+
transaction: new DatabaseTransaction(this.store, begun.record, this.#now, this.#transactionTtlMs, this.#createWeakRef),
|
|
1357
|
+
...(begun.rowIds === undefined ? {} : { rowIds: begun.rowIds }),
|
|
1358
|
+
...(begun.autoIncrementValues === undefined
|
|
1359
|
+
? {}
|
|
1360
|
+
: { autoIncrementValues: begun.autoIncrementValues }),
|
|
1361
|
+
};
|
|
655
1362
|
}
|
|
656
1363
|
async resume(transactionId) {
|
|
657
1364
|
const record = await this.store.getTransaction(transactionId);
|
|
@@ -659,15 +1366,25 @@ export class TransactionManager {
|
|
|
659
1366
|
throw new Error(`Transaction not found: ${transactionId}`);
|
|
660
1367
|
if (record.status !== "active")
|
|
661
1368
|
throw new TransactionClosedError(transactionId);
|
|
662
|
-
|
|
1369
|
+
const transaction = new DatabaseTransaction(this.store, record, this.#now, this.#transactionTtlMs, this.#createWeakRef);
|
|
1370
|
+
for (const segmentId of record.pendingSegmentIds) {
|
|
1371
|
+
const segment = await this.store.getSegment(segmentId);
|
|
1372
|
+
if (segment?.level === 0) {
|
|
1373
|
+
transaction.limitLevelZeroSegments(segment.tableId, MAX_LEVEL_ZERO_SEGMENTS);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
await transaction.renew();
|
|
1377
|
+
return transaction;
|
|
1378
|
+
}
|
|
1379
|
+
#transactionExpiry() {
|
|
1380
|
+
return expiryAfter(this.#now(), this.#transactionTtlMs);
|
|
663
1381
|
}
|
|
664
1382
|
async openSnapshot(version) {
|
|
665
1383
|
if (version === null)
|
|
666
|
-
return new Snapshot(this.store, null
|
|
1384
|
+
return new Snapshot(this.store, null);
|
|
667
1385
|
if (version !== undefined)
|
|
668
1386
|
return loadSnapshot(this.store, version);
|
|
669
|
-
|
|
670
|
-
return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
|
|
1387
|
+
return new Snapshot(this.store, await this.store.getCurrentManifestVersion());
|
|
671
1388
|
}
|
|
672
1389
|
async openLeasedSnapshot(options) {
|
|
673
1390
|
validateTtl(options.ttlMs);
|
|
@@ -678,18 +1395,22 @@ export class TransactionManager {
|
|
|
678
1395
|
}
|
|
679
1396
|
const id = options.id ?? this.#createId();
|
|
680
1397
|
for (;;) {
|
|
681
|
-
const snapshot =
|
|
1398
|
+
const snapshot = new Snapshot(this.store, options.version === undefined
|
|
1399
|
+
? await this.store.getCurrentManifestVersion()
|
|
1400
|
+
: options.version);
|
|
1401
|
+
const createdAt = dateIsoString(this.#now());
|
|
682
1402
|
const record = {
|
|
683
1403
|
id,
|
|
684
1404
|
kind: options.kind ?? "reader",
|
|
685
1405
|
manifestVersion: snapshot.version,
|
|
686
1406
|
ownerId: options.ownerId,
|
|
687
|
-
|
|
1407
|
+
createdAt,
|
|
1408
|
+
expiresAt: expiryAfter(new Date(createdAt), options.ttlMs),
|
|
688
1409
|
revision: 0,
|
|
689
1410
|
};
|
|
690
1411
|
try {
|
|
691
1412
|
await this.store.createLease(record);
|
|
692
|
-
return new LeasedSnapshot(this.store, snapshot.version,
|
|
1413
|
+
return new LeasedSnapshot(this.store, snapshot.version, record, this.#now);
|
|
693
1414
|
}
|
|
694
1415
|
catch (error) {
|
|
695
1416
|
if (options.version === undefined && error instanceof SnapshotManifestMissingError) {
|
|
@@ -715,8 +1436,9 @@ export class TransactionManager {
|
|
|
715
1436
|
return moved;
|
|
716
1437
|
}
|
|
717
1438
|
catch (error) {
|
|
718
|
-
if (!(error instanceof LeaseConflictError))
|
|
1439
|
+
if (!(error instanceof LeaseConflictError) && !(error instanceof LeaseExpiredError)) {
|
|
719
1440
|
throw error;
|
|
1441
|
+
}
|
|
720
1442
|
// Not ours to move any more — swept after expiring, most likely. A fresh lease takes its
|
|
721
1443
|
// place; whatever record remains is left to expire rather than deleted from under anyone.
|
|
722
1444
|
const replacement = await this.openLeasedSnapshot(options);
|
|
@@ -728,15 +1450,16 @@ export class TransactionManager {
|
|
|
728
1450
|
return replacement;
|
|
729
1451
|
}
|
|
730
1452
|
async removeExpiredLeases(at = this.#now()) {
|
|
731
|
-
const timestamp = at
|
|
1453
|
+
const timestamp = dateMilliseconds(at);
|
|
732
1454
|
if (!Number.isFinite(timestamp))
|
|
733
1455
|
throw new TypeError("Lease cutoff must be a valid date");
|
|
734
1456
|
const removed = [];
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1457
|
+
const cutoff = dateIsoString(at);
|
|
1458
|
+
const page = await this.store.listExpiredLeasePage(cutoff, this.#leaseCleanupCursor, 64);
|
|
1459
|
+
this.#leaseCleanupCursor = page.nextCursor;
|
|
1460
|
+
for (const lease of page.records) {
|
|
738
1461
|
try {
|
|
739
|
-
if (await this.store.removeLeaseIfExpired(lease.id, lease.revision,
|
|
1462
|
+
if (await this.store.removeLeaseIfExpired(lease.id, lease.revision, cutoff)) {
|
|
740
1463
|
removed.push(lease.id);
|
|
741
1464
|
}
|
|
742
1465
|
}
|
|
@@ -746,10 +1469,10 @@ export class TransactionManager {
|
|
|
746
1469
|
throw error;
|
|
747
1470
|
}
|
|
748
1471
|
}
|
|
749
|
-
return removed
|
|
1472
|
+
return removed;
|
|
750
1473
|
}
|
|
751
1474
|
async recover(options) {
|
|
752
|
-
const staleBefore = options.staleBefore
|
|
1475
|
+
const staleBefore = dateMilliseconds(options.staleBefore);
|
|
753
1476
|
if (!Number.isFinite(staleBefore))
|
|
754
1477
|
throw new TypeError("Recovery cutoff must be a valid date");
|
|
755
1478
|
const abortedTransactionIds = [];
|
|
@@ -760,16 +1483,22 @@ export class TransactionManager {
|
|
|
760
1483
|
do {
|
|
761
1484
|
const page = await this.store.listTransactionPage(transactionCursor, 64);
|
|
762
1485
|
for (const record of page.records) {
|
|
763
|
-
if (record.status !== "active" || Date.parse(record.
|
|
1486
|
+
if (record.status !== "active" || Date.parse(record.expiresAt) > staleBefore)
|
|
764
1487
|
continue;
|
|
765
1488
|
try {
|
|
766
|
-
await this.store.
|
|
767
|
-
|
|
768
|
-
|
|
1489
|
+
const aborted = await this.store.abortTransactionIfExpired({
|
|
1490
|
+
transactionId: record.id,
|
|
1491
|
+
expectedOwnerId: record.ownerId,
|
|
1492
|
+
expiresAtCutoff: dateIsoString(options.staleBefore),
|
|
1493
|
+
updatedAt: dateIsoString(this.#now()),
|
|
769
1494
|
});
|
|
1495
|
+
if (aborted === undefined) {
|
|
1496
|
+
skippedTransactionIds.push(record.id);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
770
1499
|
abortedTransactionIds.push(record.id);
|
|
771
|
-
|
|
772
|
-
|
|
1500
|
+
aborted.pendingBlockIds.forEach((id) => candidates.add(id));
|
|
1501
|
+
aborted.pendingSegmentIds.forEach((id) => segmentCandidates.add(id));
|
|
773
1502
|
}
|
|
774
1503
|
catch (error) {
|
|
775
1504
|
if (error instanceof TransactionRecordConflictError) {
|
|
@@ -790,7 +1519,7 @@ export class TransactionManager {
|
|
|
790
1519
|
retainedSegmentIds.push(...[...segmentCandidates].sort());
|
|
791
1520
|
}
|
|
792
1521
|
else if (candidates.size > 0 || segmentCandidates.size > 0) {
|
|
793
|
-
const timestamp = this.#now()
|
|
1522
|
+
const timestamp = dateIsoString(this.#now());
|
|
794
1523
|
const baseId = `recovery/${timestamp}/${this.#createId()}`;
|
|
795
1524
|
let suffix = 0;
|
|
796
1525
|
let job;
|
|
@@ -823,7 +1552,7 @@ export class TransactionManager {
|
|
|
823
1552
|
jobId: job.id,
|
|
824
1553
|
expectedRevision: job.revision,
|
|
825
1554
|
maxItems: 128,
|
|
826
|
-
updatedAt: this.#now()
|
|
1555
|
+
updatedAt: dateIsoString(this.#now()),
|
|
827
1556
|
});
|
|
828
1557
|
removedBlockIds.push(...step.reclaimedBlockIds);
|
|
829
1558
|
retainedBlockIds.push(...step.retainedBlockIds);
|
|
@@ -852,17 +1581,126 @@ export class TransactionManager {
|
|
|
852
1581
|
}
|
|
853
1582
|
}
|
|
854
1583
|
function validateTtl(ttlMs) {
|
|
855
|
-
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
856
|
-
throw new RangeError(
|
|
1584
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > MAX_LEASE_TTL_MS) {
|
|
1585
|
+
throw new RangeError(`Lease lifetime must be a positive number no greater than ${String(MAX_LEASE_TTL_MS)} milliseconds`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
function expiryAfter(now, ttlMs) {
|
|
1589
|
+
validateTtl(ttlMs);
|
|
1590
|
+
const expiresAt = dateMilliseconds(now) + ttlMs;
|
|
1591
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt > 8_640_000_000_000_000) {
|
|
1592
|
+
throw new RangeError("Lease expiry is outside the supported Date range");
|
|
1593
|
+
}
|
|
1594
|
+
return dateIsoString(new Date(expiresAt));
|
|
1595
|
+
}
|
|
1596
|
+
function advanceRevision(revision, amount) {
|
|
1597
|
+
if (!Number.isSafeInteger(revision) ||
|
|
1598
|
+
revision < 0 ||
|
|
1599
|
+
revision > Number.MAX_SAFE_INTEGER - amount) {
|
|
1600
|
+
throw new RangeError("Transaction revision is outside the safe integer range");
|
|
1601
|
+
}
|
|
1602
|
+
return revision + amount;
|
|
1603
|
+
}
|
|
1604
|
+
function checkpointByteSum(total, addition) {
|
|
1605
|
+
const next = total + addition;
|
|
1606
|
+
if (!Number.isSafeInteger(next)) {
|
|
1607
|
+
throw new RangeError("Transaction checkpoint size exceeds the safe integer range");
|
|
1608
|
+
}
|
|
1609
|
+
return next;
|
|
1610
|
+
}
|
|
1611
|
+
function safeWholeNumberSum(values, label) {
|
|
1612
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
1613
|
+
if (!Number.isSafeInteger(total) || total < 0) {
|
|
1614
|
+
throw new RangeError(`${label} exceeds the safe integer range`);
|
|
1615
|
+
}
|
|
1616
|
+
return total;
|
|
1617
|
+
}
|
|
1618
|
+
function requiredCompactionJobId(id) {
|
|
1619
|
+
if (id === null)
|
|
1620
|
+
throw new Error("Compaction source blocks require a compaction job");
|
|
1621
|
+
return id;
|
|
1622
|
+
}
|
|
1623
|
+
function sameSegmentArtifacts(left, right) {
|
|
1624
|
+
const leftColumns = Object.keys(left.columnBlockIds).sort();
|
|
1625
|
+
const rightColumns = Object.keys(right.columnBlockIds).sort();
|
|
1626
|
+
const leftSpans = left.rowIdSpans;
|
|
1627
|
+
const rightSpans = right.rowIdSpans;
|
|
1628
|
+
return (left.id === right.id &&
|
|
1629
|
+
left.tableId === right.tableId &&
|
|
1630
|
+
left.rowCount === right.rowCount &&
|
|
1631
|
+
left.rowIdStart === right.rowIdStart &&
|
|
1632
|
+
left.rowIdEndExclusive === right.rowIdEndExclusive &&
|
|
1633
|
+
left.kind === right.kind &&
|
|
1634
|
+
left.keyColumnId === right.keyColumnId &&
|
|
1635
|
+
left.level === right.level &&
|
|
1636
|
+
left.logicalOrder === right.logicalOrder &&
|
|
1637
|
+
left.commitOrdinal === right.commitOrdinal &&
|
|
1638
|
+
left.partitionOrdinal === right.partitionOrdinal &&
|
|
1639
|
+
left.createdAt === right.createdAt &&
|
|
1640
|
+
leftColumns.length === rightColumns.length &&
|
|
1641
|
+
leftColumns.every((columnId, index) => {
|
|
1642
|
+
if (columnId !== rightColumns[index])
|
|
1643
|
+
return false;
|
|
1644
|
+
const leftIds = left.columnBlockIds[columnId] ?? [];
|
|
1645
|
+
const rightIds = right.columnBlockIds[columnId] ?? [];
|
|
1646
|
+
return (leftIds.length === rightIds.length && leftIds.every((id, item) => id === rightIds[item]));
|
|
1647
|
+
}) &&
|
|
1648
|
+
leftSpans.length === rightSpans.length &&
|
|
1649
|
+
leftSpans.every((span, index) => {
|
|
1650
|
+
const other = rightSpans[index];
|
|
1651
|
+
return (span.rowStart === other?.rowStart &&
|
|
1652
|
+
span.rowCount === other.rowCount &&
|
|
1653
|
+
span.rowIdStart === other.rowIdStart);
|
|
1654
|
+
}));
|
|
1655
|
+
}
|
|
1656
|
+
function transactionBlockBatches(blocks) {
|
|
1657
|
+
const batches = [];
|
|
1658
|
+
let batch = [];
|
|
1659
|
+
let batchBytes = 0;
|
|
1660
|
+
for (const block of blocks) {
|
|
1661
|
+
assertTransactionArtifactBatchLimits([block], []);
|
|
1662
|
+
if (batch.length === MAX_TRANSACTION_STAGE_BLOCKS ||
|
|
1663
|
+
batchBytes + block.bytes.byteLength > MAX_TRANSACTION_STAGE_BYTES) {
|
|
1664
|
+
batches.push(batch);
|
|
1665
|
+
batch = [];
|
|
1666
|
+
batchBytes = 0;
|
|
1667
|
+
}
|
|
1668
|
+
batch.push(block);
|
|
1669
|
+
batchBytes += block.bytes.byteLength;
|
|
1670
|
+
}
|
|
1671
|
+
if (batch.length > 0)
|
|
1672
|
+
batches.push(batch);
|
|
1673
|
+
return batches;
|
|
1674
|
+
}
|
|
1675
|
+
function transactionArtifactBatches(blocks, segments) {
|
|
1676
|
+
let blockBytes = 0;
|
|
1677
|
+
for (const block of blocks) {
|
|
1678
|
+
assertTransactionArtifactBatchLimits([block], []);
|
|
1679
|
+
blockBytes += block.bytes.byteLength;
|
|
1680
|
+
}
|
|
1681
|
+
if (blocks.length <= MAX_TRANSACTION_STAGE_BLOCKS &&
|
|
1682
|
+
segments.length <= MAX_TRANSACTION_STAGE_SEGMENTS &&
|
|
1683
|
+
blockBytes <= MAX_TRANSACTION_STAGE_BYTES) {
|
|
1684
|
+
return [{ blocks, segments }];
|
|
1685
|
+
}
|
|
1686
|
+
const batches = transactionBlockBatches(blocks).map((batch) => ({
|
|
1687
|
+
blocks: batch,
|
|
1688
|
+
segments: [],
|
|
1689
|
+
}));
|
|
1690
|
+
for (let start = 0; start < segments.length; start += MAX_TRANSACTION_STAGE_SEGMENTS) {
|
|
1691
|
+
batches.push({
|
|
1692
|
+
blocks: [],
|
|
1693
|
+
segments: segments.slice(start, start + MAX_TRANSACTION_STAGE_SEGMENTS),
|
|
1694
|
+
});
|
|
857
1695
|
}
|
|
1696
|
+
return batches;
|
|
858
1697
|
}
|
|
859
1698
|
async function loadSnapshot(store, version) {
|
|
860
1699
|
if (version === null)
|
|
861
|
-
return new Snapshot(store, null
|
|
1700
|
+
return new Snapshot(store, null);
|
|
862
1701
|
const manifest = await store.getManifest(version);
|
|
863
1702
|
if (manifest === undefined || manifest.prunedAt !== undefined) {
|
|
864
1703
|
throw new SnapshotManifestMissingError(version);
|
|
865
1704
|
}
|
|
866
|
-
return new Snapshot(store, manifest.version
|
|
1705
|
+
return new Snapshot(store, manifest.version);
|
|
867
1706
|
}
|
|
868
|
-
//# sourceMappingURL=index.js.map
|