@minnowdb/core 0.3.0 → 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 +4 -2
- 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 +0 -1
- package/dist/engine/artifact-cache.js +0 -1
- package/dist/engine/batch.d.ts +5 -2
- package/dist/engine/batch.js +0 -1
- 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 +5 -6
- package/dist/engine/catalog.js +5 -5
- package/dist/engine/client.d.ts +27 -7
- package/dist/engine/client.js +197 -31
- package/dist/engine/database.d.ts +161 -38
- package/dist/engine/database.js +4320 -1523
- package/dist/engine/defaults.d.ts +0 -1
- package/dist/engine/defaults.js +2 -4
- 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 +39 -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 -11
- package/dist/engine/index.js +12 -10
- 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 +499 -129
- 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 +4 -4
- package/dist/engine/query-api.d.ts +3 -0
- package/dist/engine/query-api.js +3 -0
- package/dist/engine/query-cache.d.ts +0 -1
- package/dist/engine/query-cache.js +4 -4
- package/dist/engine/query.d.ts +4 -7
- package/dist/engine/query.js +348 -155
- package/dist/engine/result-wire.d.ts +0 -1
- package/dist/engine/result-wire.js +2 -2
- package/dist/engine/schema-wire.d.ts +1 -2
- package/dist/engine/schema-wire.js +0 -1
- package/dist/engine/schema.d.ts +3 -8
- package/dist/engine/schema.js +41 -24
- package/dist/engine/sort-keys.d.ts +0 -1
- package/dist/engine/sort-keys.js +2 -2
- package/dist/engine/sql-domains.d.ts +6 -1
- package/dist/engine/sql-domains.js +209 -45
- package/dist/engine/sql-driver.d.ts +2 -2
- package/dist/engine/sql-driver.js +0 -1
- package/dist/engine/sql-json.d.ts +0 -1
- package/dist/engine/sql-json.js +53 -7
- package/dist/engine/sql-semantics.d.ts +8 -5
- package/dist/engine/sql-semantics.js +427 -65
- package/dist/engine/typed-live.d.ts +67 -0
- package/dist/engine/typed-live.js +349 -0
- package/dist/engine/vector.d.ts +7 -1
- package/dist/engine/vector.js +202 -58
- package/dist/engine/worker-host.d.ts +8 -2
- package/dist/engine/worker-host.js +616 -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 +0 -1
- package/dist/plan/index.js +0 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/storage/indexeddb.d.ts +74 -65
- package/dist/storage/indexeddb.js +12301 -2076
- package/dist/storage/memory.d.ts +56 -66
- package/dist/storage/memory.js +1004 -139
- 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 +403 -91
- package/dist/storage/opfs/leader.js +4306 -602
- package/dist/storage/opfs/rpc.d.ts +15 -3
- package/dist/storage/opfs/rpc.js +223 -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 +26 -121
- package/dist/storage/opfs/store.js +436 -388
- 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 -99
- package/dist/storage/snapshot.js +33 -309
- package/dist/storage/toolkit/extents.d.ts +41 -7
- package/dist/storage/toolkit/extents.js +402 -39
- package/dist/storage/toolkit/index.d.ts +3 -4
- package/dist/storage/toolkit/index.js +27 -3
- package/dist/storage/toolkit/record-core.d.ts +174 -61
- package/dist/storage/toolkit/record-core.js +5401 -849
- 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 +4 -6
- package/dist/storage/toolkit/wire.js +80 -25
- package/dist/storage/types.d.ts +1271 -218
- package/dist/storage/types.js +1471 -138
- package/dist/testing/block-store-conformance.d.ts +0 -1
- package/dist/testing/block-store-conformance.js +877 -138
- package/dist/testing/index.d.ts +42 -31
- package/dist/testing/index.js +100 -68
- package/dist/testing/opfs-shim.d.ts +13 -3
- package/dist/testing/opfs-shim.js +40 -6
- package/dist/testing/simulator.d.ts +0 -1
- package/dist/testing/simulator.js +43 -12
- package/dist/testing/sqllogictest.d.ts +0 -1
- package/dist/testing/sqllogictest.js +0 -1
- package/dist/transactions/index.d.ts +81 -20
- package/dist/transactions/index.js +1050 -268
- package/dist/worker-protocol/index.d.ts +3 -2
- package/dist/worker-protocol/index.js +2 -5
- package/package.json +49 -1
- 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-domains.d.ts.map +0 -1
- package/dist/engine/sql-domains.js.map +0 -1
- package/dist/engine/sql-driver.d.ts.map +0 -1
- package/dist/engine/sql-driver.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/testing/simulator.d.ts.map +0 -1
- package/dist/testing/simulator.js.map +0 -1
- package/dist/testing/sqllogictest.d.ts.map +0 -1
- package/dist/testing/sqllogictest.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
|
/**
|
|
@@ -63,24 +71,27 @@ export class LeasedSnapshot extends Snapshot {
|
|
|
63
71
|
* (`SnapshotManifestMissingError`, `LeaseConflictError`) leave this snapshot open and pass
|
|
64
72
|
* through.
|
|
65
73
|
*/
|
|
66
|
-
async moveTo(version, ttlMs
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
});
|
|
77
88
|
this.#released = true;
|
|
78
|
-
return new LeasedSnapshot(this.#store, version,
|
|
89
|
+
return new LeasedSnapshot(this.#store, version, record, this.now);
|
|
79
90
|
}
|
|
80
91
|
async release() {
|
|
81
92
|
if (this.#released)
|
|
82
93
|
return;
|
|
83
|
-
await this.#store.removeLease(this.#record.id);
|
|
94
|
+
await this.#store.removeLease({ id: this.#record.id, ownerId: this.#record.ownerId });
|
|
84
95
|
this.#released = true;
|
|
85
96
|
}
|
|
86
97
|
/** Closes this snapshot without touching the store — for a lease the store no longer holds. */
|
|
@@ -95,12 +106,27 @@ export class LeasedSnapshot extends Snapshot {
|
|
|
95
106
|
export class DatabaseTransaction {
|
|
96
107
|
store;
|
|
97
108
|
now;
|
|
109
|
+
ttlMs;
|
|
98
110
|
#record;
|
|
99
111
|
#uniqueKeyChanges = [];
|
|
100
|
-
#ftsChanges =
|
|
101
|
-
#
|
|
112
|
+
#ftsChanges = new Map();
|
|
113
|
+
#commitDeltaBytes = 0;
|
|
114
|
+
#commitDeltaEntries = 0;
|
|
115
|
+
#compactionJobId = null;
|
|
116
|
+
#compactionSourceBlockIds = new Set();
|
|
102
117
|
#changedTableIds = new Set();
|
|
118
|
+
#levelZeroSegmentLimits = new Map();
|
|
103
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();
|
|
104
130
|
/**
|
|
105
131
|
* Whether the record exists in the store. A deferred transaction (`beginDeferred`) starts
|
|
106
132
|
* without one: it is written on the first two-step staging call, or folded into the
|
|
@@ -108,11 +134,33 @@ export class DatabaseTransaction {
|
|
|
108
134
|
* touches nothing.
|
|
109
135
|
*/
|
|
110
136
|
#persisted;
|
|
111
|
-
|
|
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 = {}) {
|
|
112
146
|
this.store = store;
|
|
113
147
|
this.now = now;
|
|
148
|
+
this.ttlMs = ttlMs;
|
|
114
149
|
this.#record = structuredClone(record);
|
|
115
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;
|
|
116
164
|
}
|
|
117
165
|
get id() {
|
|
118
166
|
return this.#record.id;
|
|
@@ -124,13 +172,43 @@ export class DatabaseTransaction {
|
|
|
124
172
|
return this.#record.snapshotVersion;
|
|
125
173
|
}
|
|
126
174
|
get pendingBlockIds() {
|
|
127
|
-
return [...this.#record.pendingBlockIds];
|
|
175
|
+
return [...this.#record.pendingBlockIds, ...this.#deferredBlocks.map((block) => block.id)];
|
|
128
176
|
}
|
|
129
177
|
get pendingSegmentIds() {
|
|
130
|
-
return [
|
|
178
|
+
return [
|
|
179
|
+
...this.#record.pendingSegmentIds,
|
|
180
|
+
...this.#deferredSegments.map((segment) => segment.id),
|
|
181
|
+
];
|
|
182
|
+
}
|
|
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();
|
|
131
207
|
}
|
|
132
|
-
|
|
133
|
-
|
|
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);
|
|
134
212
|
}
|
|
135
213
|
/**
|
|
136
214
|
* Counts every registration this transaction carries: staged blocks and segments, unique-key
|
|
@@ -140,26 +218,70 @@ export class DatabaseTransaction {
|
|
|
140
218
|
* since there is no statement-level rollback inside a transaction.
|
|
141
219
|
*/
|
|
142
220
|
get stagedWorkCount() {
|
|
143
|
-
return (this
|
|
144
|
-
this
|
|
221
|
+
return (this.pendingBlockIds.length +
|
|
222
|
+
this.pendingSegmentIds.length +
|
|
145
223
|
this.#uniqueKeyChanges.length +
|
|
146
|
-
this.#ftsChanges.
|
|
147
|
-
this.#
|
|
224
|
+
this.#ftsChanges.size +
|
|
225
|
+
this.#compactionSourceBlockIds.size);
|
|
148
226
|
}
|
|
149
227
|
/** Captures all publish-relevant state; row-id/autoincrement reservations deliberately burn. */
|
|
150
228
|
checkpoint() {
|
|
151
229
|
this.#assertActive();
|
|
152
230
|
return {
|
|
153
231
|
transactionId: this.id,
|
|
154
|
-
pendingBlockIds:
|
|
155
|
-
pendingSegmentIds:
|
|
232
|
+
pendingBlockIds: this.pendingBlockIds,
|
|
233
|
+
pendingSegmentIds: this.pendingSegmentIds,
|
|
156
234
|
uniqueKeyChanges: structuredClone(this.#uniqueKeyChanges),
|
|
157
|
-
ftsChanges: structuredClone(this.#
|
|
158
|
-
|
|
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
|
+
})),
|
|
159
242
|
changedTableIds: [...this.#changedTableIds],
|
|
160
243
|
logicallyUnchanged: this.#logicallyUnchanged,
|
|
161
244
|
};
|
|
162
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
|
+
}
|
|
163
285
|
/** Rewinds staged artifacts and every in-memory commit delta to one earlier checkpoint. */
|
|
164
286
|
async rollbackTo(checkpoint) {
|
|
165
287
|
this.#assertActive();
|
|
@@ -168,45 +290,113 @@ export class DatabaseTransaction {
|
|
|
168
290
|
}
|
|
169
291
|
const blockPrefix = checkpoint.pendingBlockIds;
|
|
170
292
|
const segmentPrefix = checkpoint.pendingSegmentIds;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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))) {
|
|
175
299
|
throw new TypeError("A transaction checkpoint is no longer reachable");
|
|
176
300
|
}
|
|
177
|
-
const removedBlockIds =
|
|
178
|
-
const removedSegmentIds =
|
|
301
|
+
const removedBlockIds = currentBlockIds.filter((id) => !retainedBlocks.has(id));
|
|
302
|
+
const removedSegmentIds = currentSegmentIds.filter((id) => !retainedSegments.has(id));
|
|
179
303
|
if (this.#persisted && (removedBlockIds.length > 0 || removedSegmentIds.length > 0)) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
304
|
+
await this.#renewOwnership();
|
|
305
|
+
this.#record = await this.store.rollbackTransactionArtifacts({
|
|
306
|
+
transactionId: this.id,
|
|
307
|
+
expectedRevision: this.#record.revision,
|
|
184
308
|
pendingBlockIds: blockPrefix,
|
|
185
309
|
pendingSegmentIds: segmentPrefix,
|
|
186
|
-
|
|
310
|
+
removeBlockIds: removedBlockIds,
|
|
311
|
+
removeSegmentIds: removedSegmentIds,
|
|
312
|
+
updatedAt: dateIsoString(this.now()),
|
|
187
313
|
});
|
|
188
314
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|
|
194
345
|
this.#changedTableIds.clear();
|
|
195
346
|
for (const id of checkpoint.changedTableIds)
|
|
196
347
|
this.#changedTableIds.add(id);
|
|
197
348
|
this.#logicallyUnchanged = checkpoint.logicallyUnchanged;
|
|
198
|
-
await Promise.all(removedSegmentIds.map((id) => this.store.removeSegment(id)));
|
|
199
|
-
await Promise.all(removedBlockIds.map((id) => this.store.removeBlock(id)));
|
|
200
349
|
}
|
|
201
350
|
async snapshot() {
|
|
202
351
|
return loadSnapshot(this.store, this.#record.snapshotVersion);
|
|
203
352
|
}
|
|
204
353
|
async getBlock(id) {
|
|
205
354
|
this.#assertActive();
|
|
355
|
+
const deferred = this.#deferredBlocks.find((block) => block.id === id);
|
|
356
|
+
if (deferred !== undefined)
|
|
357
|
+
return new Uint8Array(deferred.bytes);
|
|
206
358
|
if (this.#record.pendingBlockIds.includes(id))
|
|
207
359
|
return this.store.getBlock(id);
|
|
208
360
|
return (await this.snapshot()).getBlock(id);
|
|
209
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
|
+
}
|
|
210
400
|
async stageBlock(id, bytes) {
|
|
211
401
|
return this.stageBlocks([{ id, bytes }]);
|
|
212
402
|
}
|
|
@@ -214,12 +404,7 @@ export class DatabaseTransaction {
|
|
|
214
404
|
this.#assertActive();
|
|
215
405
|
if (blocks.length === 0)
|
|
216
406
|
return;
|
|
217
|
-
await this
|
|
218
|
-
await this.store.addBlocks(blocks);
|
|
219
|
-
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
220
|
-
pendingBlockIds: [...this.#record.pendingBlockIds, ...blocks.map((block) => block.id)],
|
|
221
|
-
updatedAt: this.now().toISOString(),
|
|
222
|
-
});
|
|
407
|
+
await this.stageArtifacts(blocks, []);
|
|
223
408
|
}
|
|
224
409
|
/**
|
|
225
410
|
* Stages blocks and segments in one journal step. When the store implements the atomic
|
|
@@ -234,31 +419,38 @@ export class DatabaseTransaction {
|
|
|
234
419
|
throw new Error(`Segment ${segment.id} belongs to another transaction`);
|
|
235
420
|
}
|
|
236
421
|
}
|
|
237
|
-
// The journal
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
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;
|
|
241
426
|
const ordered = segments.map((segment, index) => ({
|
|
242
427
|
...segment,
|
|
243
|
-
commitOrdinal:
|
|
428
|
+
commitOrdinal: ordinalBase + index,
|
|
244
429
|
}));
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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));
|
|
442
|
+
for (const segment of ordered)
|
|
443
|
+
this.#knownSegments.set(segment.id, structuredClone(segment));
|
|
248
444
|
for (const segment of ordered)
|
|
249
|
-
|
|
445
|
+
this.#changedTableIds.add(segment.tableId);
|
|
250
446
|
return;
|
|
251
447
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
blocks,
|
|
259
|
-
segments: ordered,
|
|
260
|
-
updatedAt: this.now().toISOString(),
|
|
261
|
-
});
|
|
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));
|
|
262
454
|
for (const segment of ordered)
|
|
263
455
|
this.#changedTableIds.add(segment.tableId);
|
|
264
456
|
}
|
|
@@ -280,62 +472,88 @@ export class DatabaseTransaction {
|
|
|
280
472
|
}
|
|
281
473
|
const pendingBlockIds = new Set(this.#record.pendingBlockIds);
|
|
282
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));
|
|
283
478
|
if (blocks.length + segments.length > 0 &&
|
|
284
|
-
|
|
285
|
-
|
|
479
|
+
unstagedBlocks.length === 0 &&
|
|
480
|
+
unstagedSegments.length === 0) {
|
|
286
481
|
return this.commit();
|
|
287
482
|
}
|
|
483
|
+
this.#assertProspectiveArtifactJournal(unstagedBlocks, unstagedSegments);
|
|
288
484
|
const single = this.store.writeTransaction?.bind(this.store);
|
|
289
|
-
|
|
290
|
-
|
|
485
|
+
const batches = transactionArtifactBatches(unstagedBlocks, unstagedSegments);
|
|
486
|
+
if (single === undefined || batches.length !== 1) {
|
|
487
|
+
await this.stageArtifacts(unstagedBlocks, unstagedSegments);
|
|
291
488
|
return this.commit();
|
|
292
489
|
}
|
|
490
|
+
await this.#renewOwnership();
|
|
293
491
|
const ordinalBase = this.#record.pendingSegmentIds.length;
|
|
294
|
-
const ordered =
|
|
492
|
+
const ordered = unstagedSegments.map((segment, index) => ({
|
|
295
493
|
...segment,
|
|
296
|
-
commitOrdinal:
|
|
494
|
+
commitOrdinal: ordinalBase + index,
|
|
297
495
|
}));
|
|
496
|
+
assertTransactionArtifactBatchLimits(unstagedBlocks, ordered);
|
|
497
|
+
this.#registerLevelZeroSegments(ordered);
|
|
298
498
|
const changedTableIds = new Set(this.#changedTableIds);
|
|
299
499
|
for (const segment of ordered)
|
|
300
500
|
changedTableIds.add(segment.tableId);
|
|
301
501
|
const { snapshotVersion: _pinned, ...fresh } = this.#record;
|
|
302
502
|
void _pinned;
|
|
303
|
-
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);
|
|
304
515
|
try {
|
|
305
516
|
const manifest = await single({
|
|
306
517
|
transaction: this.#persisted
|
|
307
518
|
? { id: this.id, expectedRevision: this.#record.revision }
|
|
308
519
|
: { record: fresh },
|
|
309
|
-
blocks,
|
|
520
|
+
blocks: unstagedBlocks,
|
|
310
521
|
segments: ordered,
|
|
311
522
|
expectedManifestVersion: this.#record.snapshotVersion,
|
|
312
523
|
changedTableIds: this.#logicallyUnchanged ? [] : [...changedTableIds],
|
|
313
|
-
...(this.#
|
|
524
|
+
...(this.#compactionSourceBlockIds.size === 0
|
|
314
525
|
? {}
|
|
315
|
-
: {
|
|
526
|
+
: {
|
|
527
|
+
compactionJobId: requiredCompactionJobId(compactionJobId),
|
|
528
|
+
removedBlockIds: [...this.#compactionSourceBlockIds],
|
|
529
|
+
}),
|
|
316
530
|
...(this.#uniqueKeyChanges.length === 0
|
|
317
531
|
? {}
|
|
318
532
|
: { uniqueKeyChanges: this.#uniqueKeyChanges }),
|
|
319
|
-
...(
|
|
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
|
+
}),
|
|
320
542
|
committedAt,
|
|
321
543
|
});
|
|
322
|
-
for (const segment of ordered)
|
|
323
|
-
this.#changedTableIds.add(segment.tableId);
|
|
324
544
|
this.#record = {
|
|
325
545
|
...this.#record,
|
|
326
|
-
pendingBlockIds: [
|
|
327
|
-
|
|
328
|
-
].sort(),
|
|
329
|
-
pendingSegmentIds: [
|
|
330
|
-
...new Set([...pendingSegmentIds, ...ordered.map((segment) => segment.id)]),
|
|
331
|
-
].sort(),
|
|
546
|
+
pendingBlockIds: [...pendingBlockIds, ...unstagedBlocks.map((block) => block.id)],
|
|
547
|
+
pendingSegmentIds: [...pendingSegmentIds, ...ordered.map((segment) => segment.id)],
|
|
332
548
|
status: "committed",
|
|
333
549
|
committedVersion: manifest.version,
|
|
334
550
|
// One journal step and one commit, as the two-step shape would have left it.
|
|
335
|
-
revision:
|
|
551
|
+
revision: committedRevision,
|
|
336
552
|
updatedAt: committedAt,
|
|
337
553
|
};
|
|
338
554
|
this.#persisted = true;
|
|
555
|
+
this.#stopHeartbeat();
|
|
556
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
339
557
|
return manifest;
|
|
340
558
|
}
|
|
341
559
|
catch (error) {
|
|
@@ -351,6 +569,7 @@ export class DatabaseTransaction {
|
|
|
351
569
|
*/
|
|
352
570
|
async stageExistingBlocks(blockIds) {
|
|
353
571
|
this.#assertActive();
|
|
572
|
+
await this.#persistDeferredArtifacts();
|
|
354
573
|
const pending = new Set(this.#record.pendingBlockIds);
|
|
355
574
|
const additions = [...new Set(blockIds)].filter((id) => !pending.has(id));
|
|
356
575
|
if (additions.length === 0)
|
|
@@ -358,15 +577,17 @@ export class DatabaseTransaction {
|
|
|
358
577
|
if (additions.some((id) => id.length === 0)) {
|
|
359
578
|
throw new TypeError("Block ID cannot be empty");
|
|
360
579
|
}
|
|
580
|
+
assertTransactionArtifactJournalLimits([...this.#record.pendingBlockIds, ...additions], this.#record.pendingSegmentIds);
|
|
361
581
|
for (const id of additions) {
|
|
362
582
|
if ((await this.store.getBlock(id)) === undefined) {
|
|
363
583
|
throw new Error(`Cannot stage a missing existing block: ${id}`);
|
|
364
584
|
}
|
|
365
585
|
}
|
|
366
586
|
await this.#ensurePersisted();
|
|
587
|
+
await this.#renewOwnership();
|
|
367
588
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
368
589
|
pendingBlockIds: [...this.#record.pendingBlockIds, ...additions],
|
|
369
|
-
updatedAt: this.now()
|
|
590
|
+
updatedAt: dateIsoString(this.now()),
|
|
370
591
|
});
|
|
371
592
|
}
|
|
372
593
|
async stageSegment(record) {
|
|
@@ -374,20 +595,12 @@ export class DatabaseTransaction {
|
|
|
374
595
|
if (record.transactionId !== this.id) {
|
|
375
596
|
throw new Error(`Segment ${record.id} belongs to another transaction`);
|
|
376
597
|
}
|
|
377
|
-
|
|
378
|
-
record = { ...record, commitOrdinal: this.#record.pendingSegmentIds.length };
|
|
379
|
-
}
|
|
380
|
-
this.#changedTableIds.add(record.tableId);
|
|
381
|
-
await this.#ensurePersisted();
|
|
382
|
-
await this.store.addSegment(record);
|
|
383
|
-
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
384
|
-
pendingSegmentIds: [...this.#record.pendingSegmentIds, record.id],
|
|
385
|
-
updatedAt: this.now().toISOString(),
|
|
386
|
-
});
|
|
598
|
+
await this.stageArtifacts([], [record]);
|
|
387
599
|
}
|
|
388
600
|
/** Reconciles a persisted segment whose journal update was interrupted. */
|
|
389
601
|
async stageExistingSegment(segmentId) {
|
|
390
602
|
this.#assertActive();
|
|
603
|
+
await this.#persistDeferredArtifacts();
|
|
391
604
|
if (this.#record.pendingSegmentIds.includes(segmentId))
|
|
392
605
|
return;
|
|
393
606
|
const record = await this.store.getSegment(segmentId);
|
|
@@ -396,13 +609,65 @@ export class DatabaseTransaction {
|
|
|
396
609
|
if (record.transactionId !== this.id) {
|
|
397
610
|
throw new Error(`Segment ${segmentId} belongs to another transaction`);
|
|
398
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]);
|
|
399
620
|
this.#changedTableIds.add(record.tableId);
|
|
400
621
|
await this.#ensurePersisted();
|
|
622
|
+
await this.#renewOwnership();
|
|
401
623
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
402
624
|
pendingSegmentIds: [...this.#record.pendingSegmentIds, segmentId],
|
|
403
|
-
updatedAt: this.now()
|
|
625
|
+
updatedAt: dateIsoString(this.now()),
|
|
404
626
|
});
|
|
405
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
|
+
}
|
|
406
671
|
/**
|
|
407
672
|
* Records that this commit changes a table's logical content without staging a segment for
|
|
408
673
|
* it — dropping the table, for instance, whose only publish is the retirement of its blocks.
|
|
@@ -427,14 +692,23 @@ export class DatabaseTransaction {
|
|
|
427
692
|
/** Appends one operation's key changes; entries commit in operation order. */
|
|
428
693
|
setUniqueKeyChanges(changes) {
|
|
429
694
|
this.#assertActive();
|
|
430
|
-
|
|
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 = {
|
|
431
703
|
tableId: changes.tableId,
|
|
432
704
|
// Deduplicated, not sorted: membership is all a store reads from these, and sorting fifty
|
|
433
705
|
// thousand tokens cost a bulk delete a fifth of its time.
|
|
434
706
|
keyTokens: [...new Set(changes.keyTokens)],
|
|
435
707
|
requireAbsent: changes.requireAbsent,
|
|
436
708
|
...(changes.remove === undefined ? {} : { remove: changes.remove }),
|
|
437
|
-
}
|
|
709
|
+
};
|
|
710
|
+
this.#reserveCommitDelta([normalized], []);
|
|
711
|
+
this.#uniqueKeyChanges.push(normalized);
|
|
438
712
|
}
|
|
439
713
|
/**
|
|
440
714
|
* Attaches one batch's full-text deltas; applied atomically with the publish. A second
|
|
@@ -444,82 +718,196 @@ export class DatabaseTransaction {
|
|
|
444
718
|
*/
|
|
445
719
|
setFtsChanges(changes) {
|
|
446
720
|
this.#assertActive();
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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);
|
|
451
732
|
}
|
|
452
|
-
const merged = new Map(existing.columns.map((column) => [column.columnId, column]));
|
|
453
733
|
for (const column of changes.columns) {
|
|
454
|
-
const present =
|
|
734
|
+
const present = columns.get(column.columnId);
|
|
455
735
|
if (present === undefined) {
|
|
456
|
-
|
|
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
|
+
});
|
|
457
747
|
continue;
|
|
458
748
|
}
|
|
459
|
-
const byTerm = new Map(present.postings.map((posting) => [posting.term, posting]));
|
|
460
749
|
for (const posting of column.postings) {
|
|
461
|
-
const held =
|
|
750
|
+
const held = present.postings.get(posting.term);
|
|
462
751
|
if (held === undefined) {
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
else {
|
|
466
|
-
byTerm.set(posting.term, {
|
|
752
|
+
present.postings.set(posting.term, {
|
|
467
753
|
term: posting.term,
|
|
468
|
-
rowIds: [...
|
|
469
|
-
tf: [...
|
|
754
|
+
rowIds: [...posting.rowIds],
|
|
755
|
+
tf: [...posting.tf],
|
|
470
756
|
});
|
|
471
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
|
+
}
|
|
472
764
|
}
|
|
473
|
-
|
|
474
|
-
columnId: column.columnId,
|
|
475
|
-
postings: [...byTerm.values()].sort((left, right) => left.term < right.term ? -1 : left.term > right.term ? 1 : 0),
|
|
476
|
-
totalTokens: present.totalTokens + column.totalTokens,
|
|
477
|
-
});
|
|
765
|
+
present.totalTokens = tokenTotals.get(column.columnId) ?? present.totalTokens;
|
|
478
766
|
}
|
|
479
|
-
this.#ftsChanges[this.#ftsChanges.indexOf(existing)] = {
|
|
480
|
-
tableId: changes.tableId,
|
|
481
|
-
columns: [...merged.values()],
|
|
482
|
-
};
|
|
483
767
|
}
|
|
484
|
-
|
|
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) {
|
|
485
797
|
this.#assertActive();
|
|
486
|
-
|
|
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) {
|
|
487
816
|
if (id.length === 0)
|
|
488
|
-
throw new TypeError("
|
|
817
|
+
throw new TypeError("Compaction source block ID cannot be empty");
|
|
489
818
|
if (this.#record.pendingBlockIds.includes(id)) {
|
|
490
|
-
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);
|
|
491
844
|
}
|
|
492
|
-
this.#supersededBlockIds.add(id);
|
|
493
845
|
}
|
|
494
846
|
}
|
|
495
847
|
async commit() {
|
|
496
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
|
+
}
|
|
497
868
|
await this.#ensurePersisted();
|
|
869
|
+
await this.#renewOwnership();
|
|
498
870
|
// The store derives the published manifest from its stored base plus this delta, so the
|
|
499
871
|
// commit neither loads nor rebuilds the full block list.
|
|
500
|
-
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();
|
|
501
876
|
try {
|
|
502
877
|
const manifest = await this.store.commitTransaction({
|
|
503
878
|
transactionId: this.id,
|
|
504
879
|
expectedTransactionRevision: this.#record.revision,
|
|
505
880
|
expectedManifestVersion: this.#record.snapshotVersion,
|
|
506
881
|
changedTableIds: this.#logicallyUnchanged ? [] : [...this.#changedTableIds],
|
|
507
|
-
...(this.#
|
|
882
|
+
...(this.#compactionSourceBlockIds.size === 0
|
|
508
883
|
? {}
|
|
509
|
-
: {
|
|
884
|
+
: {
|
|
885
|
+
compactionJobId: requiredCompactionJobId(compactionJobId),
|
|
886
|
+
removedBlockIds: [...this.#compactionSourceBlockIds],
|
|
887
|
+
}),
|
|
510
888
|
...(this.#uniqueKeyChanges.length === 0
|
|
511
889
|
? {}
|
|
512
890
|
: { uniqueKeyChanges: this.#uniqueKeyChanges }),
|
|
513
|
-
...(
|
|
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
|
+
}),
|
|
514
900
|
committedAt,
|
|
515
901
|
});
|
|
516
902
|
this.#record = {
|
|
517
903
|
...this.#record,
|
|
518
904
|
status: "committed",
|
|
519
905
|
committedVersion: manifest.version,
|
|
520
|
-
revision:
|
|
906
|
+
revision: committedRevision,
|
|
521
907
|
updatedAt: committedAt,
|
|
522
908
|
};
|
|
909
|
+
this.#stopHeartbeat();
|
|
910
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
523
911
|
return manifest;
|
|
524
912
|
}
|
|
525
913
|
catch (error) {
|
|
@@ -529,22 +917,25 @@ export class DatabaseTransaction {
|
|
|
529
917
|
async rebase() {
|
|
530
918
|
this.#assertActive();
|
|
531
919
|
for (;;) {
|
|
532
|
-
const
|
|
920
|
+
const currentVersion = await this.store.getCurrentManifestVersion();
|
|
533
921
|
if (!this.#persisted) {
|
|
534
|
-
// 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);
|
|
535
925
|
this.#record = {
|
|
536
926
|
...this.#record,
|
|
537
|
-
snapshotVersion:
|
|
538
|
-
updatedAt: this.now()
|
|
927
|
+
snapshotVersion: currentVersion,
|
|
928
|
+
updatedAt: dateIsoString(this.now()),
|
|
539
929
|
};
|
|
540
|
-
return new Snapshot(this.store,
|
|
930
|
+
return new Snapshot(this.store, currentVersion);
|
|
541
931
|
}
|
|
542
932
|
try {
|
|
933
|
+
await this.#renewOwnership();
|
|
543
934
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
544
|
-
snapshotVersion:
|
|
545
|
-
updatedAt: this.now()
|
|
935
|
+
snapshotVersion: currentVersion,
|
|
936
|
+
updatedAt: dateIsoString(this.now()),
|
|
546
937
|
});
|
|
547
|
-
return new Snapshot(this.store,
|
|
938
|
+
return new Snapshot(this.store, currentVersion);
|
|
548
939
|
}
|
|
549
940
|
catch (error) {
|
|
550
941
|
if (error instanceof SnapshotManifestMissingError) {
|
|
@@ -558,13 +949,21 @@ export class DatabaseTransaction {
|
|
|
558
949
|
this.#assertActive();
|
|
559
950
|
if (!this.#persisted) {
|
|
560
951
|
// Never written, so there is nothing to mark: the transaction simply ends here.
|
|
561
|
-
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);
|
|
562
958
|
return;
|
|
563
959
|
}
|
|
960
|
+
await this.#renewOwnership();
|
|
564
961
|
this.#record = await this.store.updateTransaction(this.id, this.#record.revision, {
|
|
565
962
|
status: "aborted",
|
|
566
|
-
updatedAt: this.now()
|
|
963
|
+
updatedAt: dateIsoString(this.now()),
|
|
567
964
|
});
|
|
965
|
+
this.#stopHeartbeat();
|
|
966
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
568
967
|
}
|
|
569
968
|
#assertActive() {
|
|
570
969
|
if (this.#record.status !== "active")
|
|
@@ -578,6 +977,11 @@ export class DatabaseTransaction {
|
|
|
578
977
|
async #ensurePersisted() {
|
|
579
978
|
if (this.#persisted)
|
|
580
979
|
return;
|
|
980
|
+
this.#record = {
|
|
981
|
+
...this.#record,
|
|
982
|
+
expiresAt: this.#nextExpiry(),
|
|
983
|
+
updatedAt: dateIsoString(this.now()),
|
|
984
|
+
};
|
|
581
985
|
try {
|
|
582
986
|
await this.store.createTransaction(this.#record);
|
|
583
987
|
}
|
|
@@ -588,6 +992,19 @@ export class DatabaseTransaction {
|
|
|
588
992
|
throw error;
|
|
589
993
|
}
|
|
590
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;
|
|
591
1008
|
}
|
|
592
1009
|
/** A commit whose acknowledgement was lost still committed: answer from the persisted record. */
|
|
593
1010
|
async #recoverCommitted(error) {
|
|
@@ -597,85 +1014,292 @@ export class DatabaseTransaction {
|
|
|
597
1014
|
if (manifest !== undefined) {
|
|
598
1015
|
this.#record = persisted;
|
|
599
1016
|
this.#persisted = true;
|
|
1017
|
+
this.#stopHeartbeat();
|
|
1018
|
+
await this.#releaseSnapshotLease().catch(() => undefined);
|
|
600
1019
|
return manifest;
|
|
601
1020
|
}
|
|
602
1021
|
}
|
|
603
1022
|
throw error;
|
|
604
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]));
|
|
605
1213
|
}
|
|
606
1214
|
export class TransactionManager {
|
|
607
1215
|
store;
|
|
608
1216
|
#now;
|
|
609
1217
|
#createId;
|
|
1218
|
+
#transactionTtlMs;
|
|
1219
|
+
#leaseCleanupCursor = null;
|
|
1220
|
+
#createWeakRef;
|
|
610
1221
|
constructor(store, options = {}) {
|
|
611
1222
|
this.store = store;
|
|
612
1223
|
this.#now = options.now ?? (() => new Date());
|
|
613
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));
|
|
614
1228
|
}
|
|
615
1229
|
async begin() {
|
|
616
1230
|
return (await this.beginWithReservation()).transaction;
|
|
617
1231
|
}
|
|
618
|
-
/**
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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);
|
|
639
1265
|
}
|
|
640
1266
|
/**
|
|
641
|
-
* Begins a transaction
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
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.
|
|
645
1272
|
*/
|
|
646
|
-
async
|
|
647
|
-
const
|
|
648
|
-
const batched = this.store.beginTransaction?.bind(this.store);
|
|
649
|
-
if (batched !== undefined) {
|
|
650
|
-
const timestamp = this.#now().toISOString();
|
|
651
|
-
const begun = await batched({
|
|
652
|
-
record: {
|
|
653
|
-
id,
|
|
654
|
-
pendingBlockIds: [],
|
|
655
|
-
pendingSegmentIds: [],
|
|
656
|
-
status: "active",
|
|
657
|
-
revision: 0,
|
|
658
|
-
startedAt: timestamp,
|
|
659
|
-
updatedAt: timestamp,
|
|
660
|
-
committedVersion: null,
|
|
661
|
-
},
|
|
662
|
-
...(reserveRowIds === undefined ? {} : { reserveRowIds }),
|
|
663
|
-
...(reserveAutoIncrement === undefined ? {} : { reserveAutoIncrement }),
|
|
664
|
-
});
|
|
665
|
-
return {
|
|
666
|
-
transaction: new DatabaseTransaction(this.store, begun.record, this.#now),
|
|
667
|
-
...(begun.rowIds === undefined ? {} : { rowIds: begun.rowIds }),
|
|
668
|
-
...(begun.autoIncrementValues === undefined
|
|
669
|
-
? {}
|
|
670
|
-
: { autoIncrementValues: begun.autoIncrementValues }),
|
|
671
|
-
};
|
|
672
|
-
}
|
|
1273
|
+
async beginDeferred(options = {}) {
|
|
1274
|
+
const durableSnapshot = options.durableSnapshot ?? true;
|
|
673
1275
|
for (;;) {
|
|
674
|
-
const
|
|
675
|
-
const timestamp = this.#now()
|
|
676
|
-
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, {
|
|
677
1298
|
id,
|
|
678
|
-
|
|
1299
|
+
ownerId,
|
|
1300
|
+
expiresAt: this.#transactionExpiry(),
|
|
1301
|
+
snapshotVersion: probe.manifestVersion,
|
|
1302
|
+
schemaEpochGuard: probe.schemaEpoch,
|
|
679
1303
|
pendingBlockIds: [],
|
|
680
1304
|
pendingSegmentIds: [],
|
|
681
1305
|
status: "active",
|
|
@@ -683,29 +1307,58 @@ export class TransactionManager {
|
|
|
683
1307
|
startedAt: timestamp,
|
|
684
1308
|
updatedAt: timestamp,
|
|
685
1309
|
committedVersion: null,
|
|
686
|
-
}
|
|
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 (;;) {
|
|
687
1329
|
try {
|
|
688
|
-
await this.store.
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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;
|
|
701
1347
|
}
|
|
702
1348
|
catch (error) {
|
|
703
|
-
|
|
1349
|
+
// The store's atomic begin aborted in full, including any counter reservation.
|
|
1350
|
+
if (error instanceof SnapshotManifestMissingError)
|
|
704
1351
|
continue;
|
|
705
|
-
}
|
|
706
1352
|
throw error;
|
|
707
1353
|
}
|
|
708
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
|
+
};
|
|
709
1362
|
}
|
|
710
1363
|
async resume(transactionId) {
|
|
711
1364
|
const record = await this.store.getTransaction(transactionId);
|
|
@@ -713,17 +1366,27 @@ export class TransactionManager {
|
|
|
713
1366
|
throw new Error(`Transaction not found: ${transactionId}`);
|
|
714
1367
|
if (record.status !== "active")
|
|
715
1368
|
throw new TransactionClosedError(transactionId);
|
|
716
|
-
|
|
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);
|
|
717
1381
|
}
|
|
718
1382
|
async openSnapshot(version) {
|
|
719
1383
|
if (version === null)
|
|
720
|
-
return new Snapshot(this.store, null
|
|
1384
|
+
return new Snapshot(this.store, null);
|
|
721
1385
|
if (version !== undefined)
|
|
722
1386
|
return loadSnapshot(this.store, version);
|
|
723
|
-
|
|
724
|
-
return new Snapshot(this.store, current?.version ?? null, current?.blockIds ?? []);
|
|
1387
|
+
return new Snapshot(this.store, await this.store.getCurrentManifestVersion());
|
|
725
1388
|
}
|
|
726
|
-
async openLeasedSnapshot(options
|
|
1389
|
+
async openLeasedSnapshot(options) {
|
|
727
1390
|
validateTtl(options.ttlMs);
|
|
728
1391
|
if (options.ownerId.trim().length === 0)
|
|
729
1392
|
throw new TypeError("Lease owner cannot be empty");
|
|
@@ -732,20 +1395,22 @@ export class TransactionManager {
|
|
|
732
1395
|
}
|
|
733
1396
|
const id = options.id ?? this.#createId();
|
|
734
1397
|
for (;;) {
|
|
735
|
-
const snapshot =
|
|
736
|
-
? await this.
|
|
737
|
-
:
|
|
1398
|
+
const snapshot = new Snapshot(this.store, options.version === undefined
|
|
1399
|
+
? await this.store.getCurrentManifestVersion()
|
|
1400
|
+
: options.version);
|
|
1401
|
+
const createdAt = dateIsoString(this.#now());
|
|
738
1402
|
const record = {
|
|
739
1403
|
id,
|
|
740
1404
|
kind: options.kind ?? "reader",
|
|
741
1405
|
manifestVersion: snapshot.version,
|
|
742
1406
|
ownerId: options.ownerId,
|
|
743
|
-
|
|
1407
|
+
createdAt,
|
|
1408
|
+
expiresAt: expiryAfter(new Date(createdAt), options.ttlMs),
|
|
744
1409
|
revision: 0,
|
|
745
1410
|
};
|
|
746
1411
|
try {
|
|
747
1412
|
await this.store.createLease(record);
|
|
748
|
-
return new LeasedSnapshot(this.store, snapshot.version,
|
|
1413
|
+
return new LeasedSnapshot(this.store, snapshot.version, record, this.#now);
|
|
749
1414
|
}
|
|
750
1415
|
catch (error) {
|
|
751
1416
|
if (options.version === undefined && error instanceof SnapshotManifestMissingError) {
|
|
@@ -763,36 +1428,38 @@ export class TransactionManager {
|
|
|
763
1428
|
* given snapshot is closed on return either way; a missing target manifest throws
|
|
764
1429
|
* `SnapshotManifestMissingError` and leaves it open.
|
|
765
1430
|
*/
|
|
766
|
-
async moveLeasedSnapshot(snapshot, options
|
|
1431
|
+
async moveLeasedSnapshot(snapshot, options) {
|
|
767
1432
|
validateTtl(options.ttlMs);
|
|
768
1433
|
try {
|
|
769
|
-
const moved = await snapshot.moveTo(options.version, options.ttlMs
|
|
1434
|
+
const moved = await snapshot.moveTo(options.version, options.ttlMs);
|
|
770
1435
|
if (moved !== undefined)
|
|
771
1436
|
return moved;
|
|
772
1437
|
}
|
|
773
1438
|
catch (error) {
|
|
774
|
-
if (!(error instanceof LeaseConflictError))
|
|
1439
|
+
if (!(error instanceof LeaseConflictError) && !(error instanceof LeaseExpiredError)) {
|
|
775
1440
|
throw error;
|
|
1441
|
+
}
|
|
776
1442
|
// Not ours to move any more — swept after expiring, most likely. A fresh lease takes its
|
|
777
1443
|
// place; whatever record remains is left to expire rather than deleted from under anyone.
|
|
778
|
-
const replacement = await this.openLeasedSnapshot(options
|
|
1444
|
+
const replacement = await this.openLeasedSnapshot(options);
|
|
779
1445
|
snapshot.discard();
|
|
780
1446
|
return replacement;
|
|
781
1447
|
}
|
|
782
|
-
const replacement = await this.openLeasedSnapshot(options
|
|
1448
|
+
const replacement = await this.openLeasedSnapshot(options);
|
|
783
1449
|
await snapshot.release();
|
|
784
1450
|
return replacement;
|
|
785
1451
|
}
|
|
786
1452
|
async removeExpiredLeases(at = this.#now()) {
|
|
787
|
-
const timestamp = at
|
|
1453
|
+
const timestamp = dateMilliseconds(at);
|
|
788
1454
|
if (!Number.isFinite(timestamp))
|
|
789
1455
|
throw new TypeError("Lease cutoff must be a valid date");
|
|
790
1456
|
const removed = [];
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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) {
|
|
794
1461
|
try {
|
|
795
|
-
if (await this.store.removeLeaseIfExpired(lease.id, lease.revision,
|
|
1462
|
+
if (await this.store.removeLeaseIfExpired(lease.id, lease.revision, cutoff)) {
|
|
796
1463
|
removed.push(lease.id);
|
|
797
1464
|
}
|
|
798
1465
|
}
|
|
@@ -802,10 +1469,10 @@ export class TransactionManager {
|
|
|
802
1469
|
throw error;
|
|
803
1470
|
}
|
|
804
1471
|
}
|
|
805
|
-
return removed
|
|
1472
|
+
return removed;
|
|
806
1473
|
}
|
|
807
1474
|
async recover(options) {
|
|
808
|
-
const staleBefore = options.staleBefore
|
|
1475
|
+
const staleBefore = dateMilliseconds(options.staleBefore);
|
|
809
1476
|
if (!Number.isFinite(staleBefore))
|
|
810
1477
|
throw new TypeError("Recovery cutoff must be a valid date");
|
|
811
1478
|
const abortedTransactionIds = [];
|
|
@@ -816,16 +1483,22 @@ export class TransactionManager {
|
|
|
816
1483
|
do {
|
|
817
1484
|
const page = await this.store.listTransactionPage(transactionCursor, 64);
|
|
818
1485
|
for (const record of page.records) {
|
|
819
|
-
if (record.status !== "active" || Date.parse(record.
|
|
1486
|
+
if (record.status !== "active" || Date.parse(record.expiresAt) > staleBefore)
|
|
820
1487
|
continue;
|
|
821
1488
|
try {
|
|
822
|
-
await this.store.
|
|
823
|
-
|
|
824
|
-
|
|
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()),
|
|
825
1494
|
});
|
|
1495
|
+
if (aborted === undefined) {
|
|
1496
|
+
skippedTransactionIds.push(record.id);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
826
1499
|
abortedTransactionIds.push(record.id);
|
|
827
|
-
|
|
828
|
-
|
|
1500
|
+
aborted.pendingBlockIds.forEach((id) => candidates.add(id));
|
|
1501
|
+
aborted.pendingSegmentIds.forEach((id) => segmentCandidates.add(id));
|
|
829
1502
|
}
|
|
830
1503
|
catch (error) {
|
|
831
1504
|
if (error instanceof TransactionRecordConflictError) {
|
|
@@ -846,7 +1519,7 @@ export class TransactionManager {
|
|
|
846
1519
|
retainedSegmentIds.push(...[...segmentCandidates].sort());
|
|
847
1520
|
}
|
|
848
1521
|
else if (candidates.size > 0 || segmentCandidates.size > 0) {
|
|
849
|
-
const timestamp = this.#now()
|
|
1522
|
+
const timestamp = dateIsoString(this.#now());
|
|
850
1523
|
const baseId = `recovery/${timestamp}/${this.#createId()}`;
|
|
851
1524
|
let suffix = 0;
|
|
852
1525
|
let job;
|
|
@@ -879,7 +1552,7 @@ export class TransactionManager {
|
|
|
879
1552
|
jobId: job.id,
|
|
880
1553
|
expectedRevision: job.revision,
|
|
881
1554
|
maxItems: 128,
|
|
882
|
-
updatedAt: this.#now()
|
|
1555
|
+
updatedAt: dateIsoString(this.#now()),
|
|
883
1556
|
});
|
|
884
1557
|
removedBlockIds.push(...step.reclaimedBlockIds);
|
|
885
1558
|
retainedBlockIds.push(...step.retainedBlockIds);
|
|
@@ -908,17 +1581,126 @@ export class TransactionManager {
|
|
|
908
1581
|
}
|
|
909
1582
|
}
|
|
910
1583
|
function validateTtl(ttlMs) {
|
|
911
|
-
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
912
|
-
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
|
+
});
|
|
913
1695
|
}
|
|
1696
|
+
return batches;
|
|
914
1697
|
}
|
|
915
1698
|
async function loadSnapshot(store, version) {
|
|
916
1699
|
if (version === null)
|
|
917
|
-
return new Snapshot(store, null
|
|
1700
|
+
return new Snapshot(store, null);
|
|
918
1701
|
const manifest = await store.getManifest(version);
|
|
919
1702
|
if (manifest === undefined || manifest.prunedAt !== undefined) {
|
|
920
1703
|
throw new SnapshotManifestMissingError(version);
|
|
921
1704
|
}
|
|
922
|
-
return new Snapshot(store, manifest.version
|
|
1705
|
+
return new Snapshot(store, manifest.version);
|
|
923
1706
|
}
|
|
924
|
-
//# sourceMappingURL=index.js.map
|