@minnowdb/core 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/dist/engine/client.d.ts +10 -1
- package/dist/engine/client.d.ts.map +1 -1
- package/dist/engine/client.js +30 -5
- package/dist/engine/client.js.map +1 -1
- package/dist/engine/database.d.ts +55 -6
- package/dist/engine/database.d.ts.map +1 -1
- package/dist/engine/database.js +2689 -898
- package/dist/engine/database.js.map +1 -1
- package/dist/engine/live.d.ts +6 -2
- package/dist/engine/live.d.ts.map +1 -1
- package/dist/engine/live.js +35 -11
- package/dist/engine/live.js.map +1 -1
- package/dist/engine/query-cache.d.ts +13 -1
- package/dist/engine/query-cache.d.ts.map +1 -1
- package/dist/engine/query-cache.js +34 -5
- package/dist/engine/query-cache.js.map +1 -1
- package/dist/engine/query.d.ts +13 -1
- package/dist/engine/query.d.ts.map +1 -1
- package/dist/engine/query.js +73 -46
- package/dist/engine/query.js.map +1 -1
- package/dist/engine/result-wire.d.ts +68 -0
- package/dist/engine/result-wire.d.ts.map +1 -0
- package/dist/engine/result-wire.js +264 -0
- package/dist/engine/result-wire.js.map +1 -0
- package/dist/engine/sort-keys.d.ts +50 -12
- package/dist/engine/sort-keys.d.ts.map +1 -1
- package/dist/engine/sort-keys.js +371 -25
- package/dist/engine/sort-keys.js.map +1 -1
- package/dist/engine/vector.d.ts +11 -0
- package/dist/engine/vector.d.ts.map +1 -1
- package/dist/engine/vector.js +99 -203
- package/dist/engine/vector.js.map +1 -1
- package/dist/engine/worker-host.d.ts +17 -1
- package/dist/engine/worker-host.d.ts.map +1 -1
- package/dist/engine/worker-host.js +87 -13
- package/dist/engine/worker-host.js.map +1 -1
- package/dist/storage/index.d.ts +13 -0
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +13 -0
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/indexeddb.d.ts +5 -1
- package/dist/storage/indexeddb.d.ts.map +1 -1
- package/dist/storage/indexeddb.js +484 -402
- package/dist/storage/indexeddb.js.map +1 -1
- package/dist/storage/memory.d.ts +24 -21
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +124 -1199
- package/dist/storage/memory.js.map +1 -1
- package/dist/storage/opfs/files.d.ts +64 -0
- package/dist/storage/opfs/files.d.ts.map +1 -0
- package/dist/storage/opfs/files.js +243 -0
- package/dist/storage/opfs/files.js.map +1 -0
- package/dist/storage/opfs/index.d.ts +3 -0
- package/dist/storage/opfs/index.d.ts.map +1 -0
- package/dist/storage/opfs/index.js +3 -0
- package/dist/storage/opfs/index.js.map +1 -0
- package/dist/storage/opfs/leader.d.ts +130 -0
- package/dist/storage/opfs/leader.d.ts.map +1 -0
- package/dist/storage/opfs/leader.js +1104 -0
- package/dist/storage/opfs/leader.js.map +1 -0
- package/dist/storage/opfs/rpc.d.ts +70 -0
- package/dist/storage/opfs/rpc.d.ts.map +1 -0
- package/dist/storage/opfs/rpc.js +58 -0
- package/dist/storage/opfs/rpc.js.map +1 -0
- package/dist/storage/opfs/store.d.ts +154 -0
- package/dist/storage/opfs/store.d.ts.map +1 -0
- package/dist/storage/opfs/store.js +958 -0
- package/dist/storage/opfs/store.js.map +1 -0
- package/dist/storage/toolkit/extents.d.ts +69 -0
- package/dist/storage/toolkit/extents.d.ts.map +1 -0
- package/dist/storage/toolkit/extents.js +175 -0
- package/dist/storage/toolkit/extents.js.map +1 -0
- package/dist/storage/toolkit/index.d.ts +30 -0
- package/dist/storage/toolkit/index.d.ts.map +1 -0
- package/dist/storage/toolkit/index.js +5 -0
- package/dist/storage/toolkit/index.js.map +1 -0
- package/dist/storage/toolkit/record-core.d.ts +252 -0
- package/dist/storage/toolkit/record-core.d.ts.map +1 -0
- package/dist/storage/toolkit/record-core.js +1670 -0
- package/dist/storage/toolkit/record-core.js.map +1 -0
- package/dist/storage/toolkit/sync-file.d.ts +29 -0
- package/dist/storage/toolkit/sync-file.d.ts.map +1 -0
- package/dist/storage/toolkit/sync-file.js +2 -0
- package/dist/storage/toolkit/sync-file.js.map +1 -0
- package/dist/storage/toolkit/wal.d.ts +22 -0
- package/dist/storage/toolkit/wal.d.ts.map +1 -0
- package/dist/storage/toolkit/wal.js +79 -0
- package/dist/storage/toolkit/wal.js.map +1 -0
- package/dist/storage/toolkit/wire.d.ts +25 -0
- package/dist/storage/toolkit/wire.d.ts.map +1 -0
- package/dist/storage/toolkit/wire.js +100 -0
- package/dist/storage/toolkit/wire.js.map +1 -0
- package/dist/storage/types.d.ts +343 -59
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +112 -11
- package/dist/storage/types.js.map +1 -1
- package/dist/testing/block-store-conformance.d.ts +51 -0
- package/dist/testing/block-store-conformance.d.ts.map +1 -0
- package/dist/testing/block-store-conformance.js +745 -0
- package/dist/testing/block-store-conformance.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -1
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/opfs-shim.d.ts +35 -0
- package/dist/testing/opfs-shim.d.ts.map +1 -0
- package/dist/testing/opfs-shim.js +295 -0
- package/dist/testing/opfs-shim.js.map +1 -0
- package/dist/transactions/index.d.ts +42 -1
- package/dist/transactions/index.d.ts.map +1 -1
- package/dist/transactions/index.js +219 -13
- package/dist/transactions/index.js.map +1 -1
- package/dist/worker-protocol/index.d.ts +2 -2
- package/dist/worker-protocol/index.d.ts.map +1 -1
- package/dist/worker-protocol/index.js +2 -1
- package/dist/worker-protocol/index.js.map +1 -1
- package/package.json +10 -2
- package/sql-feature-matrix.json +8 -0
package/dist/engine/database.js
CHANGED
|
@@ -9,7 +9,7 @@ import { cachedQueryTerms, FTS_TOKENIZER_VERSION, renderDocumentValue, tokenize
|
|
|
9
9
|
import { simpleDataTypes, floorWholeNumberProduct, validateColumnDefault, validateEnumValues, CompactionJobConflictError, GarbageCollectionJobConflictError, decodeSnapshot, encodeSnapshot, SnapshotManifestMissingError, TableRecordConflictError, TransactionRecordConflictError, UniqueKeyConflictError, WriteConflictError, } from "../storage/index.js";
|
|
10
10
|
import { Snapshot, TransactionManager, } from "../transactions/index.js";
|
|
11
11
|
import { applyWindowFunctions, bindPlanParameters, bindStatementParameters, DUAL_TABLE, dualTableRows, blockHasSubqueries, combineUnionResults, compileCheckExpression, compileQuery, hasAggregate, createRecursiveCteState, compileStatement, createPreparedColumnarQuery, evaluateJoinedRowExpression, evaluateRowExpression, expressionColumnNames, inferBlockSchema, referencedColumns, childExpressions, expandFtsColumns, expandNaturalJoins, expandSourceColumnAliases, expandViewSources, forEachBlockExpression, planContainsFts, planHasNaturalJoins, planHasSourceColumnAliases, planReadsViews, planReadsBeyondSingleScan, projectResultColumns, subqueryResolutionSteps, topLevelFtsMatchConjuncts, transparentProjectionSource, windowOutputType, } from "./query.js";
|
|
12
|
-
import { copyQueryResult, queryResultMemoKey, queryResultRetainedBytes, RESULT_MEMO_MAX_BYTES, } from "./query-cache.js";
|
|
12
|
+
import { copyQueryResult, planMemoKey, queryResultMemoKey, queryResultRetainedBytes, RESULT_MEMO_MAX_BYTES, } from "./query-cache.js";
|
|
13
13
|
import { QueryMemoryBudgetError, QueryMemoryContext, } from "./memory.js";
|
|
14
14
|
import { LiveQuerySet } from "./live.js";
|
|
15
15
|
import { chooseJoinOrder, renderPlan } from "./optimizer.js";
|
|
@@ -25,20 +25,60 @@ const FTS_FOLD_DELTA_CHUNKS = 16;
|
|
|
25
25
|
const DEFAULT_COMPACTION_TARGET_BLOCK_BYTES = 2 * 1024 * 1024;
|
|
26
26
|
const DEFAULT_COMPACTION_MEMORY_BUDGET_BYTES = 32 * 1024 * 1024;
|
|
27
27
|
const DEFAULT_COMPACTION_MINIMUM_LEVEL_ZERO_SEGMENTS = 2;
|
|
28
|
-
const DEFAULT_COMPACTION_MAXIMUM_LEVEL_ZERO_SEGMENTS =
|
|
28
|
+
const DEFAULT_COMPACTION_MAXIMUM_LEVEL_ZERO_SEGMENTS = 64;
|
|
29
29
|
const DEFAULT_COMPACTION_MAXIMUM_LEVEL_ZERO_STORED_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* Rows a keyed fold aims to keep in one level-one partition. A fold rewrites only the
|
|
32
|
+
* partitions its deltas touch, so this bounds how much one touched key costs to absorb; the
|
|
33
|
+
* table's partition count, and with it the per-query block count, grows as rows divided by it.
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_COMPACTION_PARTITION_ROWS = 16_384;
|
|
30
36
|
const DEFAULT_LEVEL_TWO_MAX_WRITE_AMPLIFICATION = 16;
|
|
31
37
|
const MAX_COMPACTION_TARGET_BLOCK_BYTES = 64 * 1024 * 1024;
|
|
32
38
|
const MAX_BLOCK_ENVELOPE_BYTES = 1024;
|
|
33
39
|
const INTERNAL_READ_LEASE_TTL_MS = 60_000;
|
|
40
|
+
/** Live proof windows kept resident; a sweep uses one, concurrent sets a few. */
|
|
41
|
+
const LIVE_PROOF_CONTEXT_LIMIT = 4;
|
|
34
42
|
/** Distinct table-name sets whose catalog state stays resident; entries are tiny (records only). */
|
|
35
43
|
const CATALOG_STATE_CACHE_LIMIT = 64;
|
|
36
44
|
/** Blocks fetched per round trip when a streamed scan window needs more data. */
|
|
37
45
|
const STREAMED_SCAN_LOOKAHEAD_BLOCKS = 8;
|
|
38
|
-
/** Visible segments per table at which a
|
|
46
|
+
/** Visible segments per table at which a scan or a commit schedules a compaction step. */
|
|
39
47
|
const AUTO_COMPACT_SCAN_SEGMENTS = 48;
|
|
40
|
-
/** Visible delete/update segments at which a
|
|
41
|
-
const AUTO_COMPACT_DELTA_SEGMENTS =
|
|
48
|
+
/** Visible delete/update segments at which a scan or a commit schedules a compaction step. */
|
|
49
|
+
const AUTO_COMPACT_DELTA_SEGMENTS = 32;
|
|
50
|
+
/** Commits to one table between auto-compaction checks on the write path. */
|
|
51
|
+
const AUTO_COMPACT_COMMIT_CHECK_INTERVAL = 8;
|
|
52
|
+
/** Quiet time after a write burst before checking its final, sub-interval tail. */
|
|
53
|
+
const AUTO_COMPACT_IDLE_CHECK_MS = 25;
|
|
54
|
+
/** Commits between background collection passes; each prunes the manifests they wrote. */
|
|
55
|
+
const AUTO_COLLECT_COMMIT_INTERVAL = 64;
|
|
56
|
+
/**
|
|
57
|
+
* Manifest versions background collection leaves readable behind the current one, and how
|
|
58
|
+
* old one may be before it is collected regardless. A version is kept only while both hold: the
|
|
59
|
+
* count serves a reader that names a version it was just handed, the age keeps a burst of
|
|
60
|
+
* commits from pinning everything it superseded until the next burst — an idle tab reclaims
|
|
61
|
+
* within a minute.
|
|
62
|
+
*/
|
|
63
|
+
const AUTO_COLLECT_RETAINED_VERSIONS = 64;
|
|
64
|
+
const AUTO_COLLECT_RETAINED_VERSION_MS = 60_000;
|
|
65
|
+
/** A commit this long after the last collection pass starts one, whatever the commit count. */
|
|
66
|
+
const AUTO_COLLECT_QUIET_MS = 60_000;
|
|
67
|
+
/** Candidates one background collection step examines before yielding to the event loop. */
|
|
68
|
+
const AUTO_COLLECT_STEP_ITEMS = 64;
|
|
69
|
+
/** Passes one background collection run makes before handing the rest to the next trigger. */
|
|
70
|
+
const AUTO_COLLECT_MAX_PASSES = 32;
|
|
71
|
+
/** Finished job records of each kind a background run leaves for inspection. */
|
|
72
|
+
const AUTO_COLLECT_RETAINED_JOB_RECORDS = 8;
|
|
73
|
+
/** Output blocks one background compaction step writes before yielding to the event loop. */
|
|
74
|
+
const AUTO_COMPACT_STEP_BLOCKS = 4;
|
|
75
|
+
/**
|
|
76
|
+
* Level-zero segments one background fold may absorb. A fold rewrites every partition its
|
|
77
|
+
* deltas touch, and a partition touched by several deltas is rewritten once, so absorbing
|
|
78
|
+
* everything pending in one pass costs one rewrite of those partitions where the default would
|
|
79
|
+
* cost several; the stored-bytes ceiling still bounds the pass.
|
|
80
|
+
*/
|
|
81
|
+
const AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS = 256;
|
|
42
82
|
/** Modeled retained bytes for one cached block description (header metadata, no payload). */
|
|
43
83
|
const ZONE_DESCRIPTION_CACHE_BYTES = 160;
|
|
44
84
|
/** Overlay logical order for a write scope's staged segments: after all committed data. */
|
|
@@ -70,8 +110,15 @@ const GZIP_WORTHWHILE_RATIO = 1.2;
|
|
|
70
110
|
* bounds how long a wrong observation can persist if the data changes underneath it.
|
|
71
111
|
*/
|
|
72
112
|
const GZIP_REPROBE_BLOCKS = 32;
|
|
73
|
-
/**
|
|
74
|
-
const
|
|
113
|
+
/** Failed per-column probes retained in one database session. */
|
|
114
|
+
const GZIP_VERDICT_CACHE_LIMIT = 256;
|
|
115
|
+
/**
|
|
116
|
+
* Below this many logical bytes a block is written raw: the compression pass on the write and
|
|
117
|
+
* the decompression pass on every read would cost more than the bytes they save, and a point
|
|
118
|
+
* update's or delete's one-row block is the common case — it used to pay a CompressionStream
|
|
119
|
+
* round trip to shrink a few dozen bytes.
|
|
120
|
+
*/
|
|
121
|
+
const GZIP_MINIMUM_INPUT_BYTES = 4 * 1024;
|
|
75
122
|
/** Bounds concurrent compression work without serializing independent column blocks. */
|
|
76
123
|
const WRITE_ENCODE_CONCURRENCY = 6;
|
|
77
124
|
export { CompactionJobCancelledError, CompactionMemoryBudgetError, CompactionWriteAmplificationError, MissingKeyError, SqlCompileError, UniqueConstraintError, };
|
|
@@ -87,11 +134,6 @@ class TransactionRollback extends Error {
|
|
|
87
134
|
this.name = "TransactionRollback";
|
|
88
135
|
}
|
|
89
136
|
}
|
|
90
|
-
/**
|
|
91
|
-
* Whether a statement may run inside a statement-level transaction. Reads and row writes stage
|
|
92
|
-
* into the scope; schema changes do not, because the catalog commits outside it and a rollback
|
|
93
|
-
* could not take them back.
|
|
94
|
-
*/
|
|
95
137
|
function isTransactionalStatement(statement) {
|
|
96
138
|
return (statement.kind === "insert" ||
|
|
97
139
|
statement.kind === "update" ||
|
|
@@ -109,19 +151,24 @@ function boundInsertValue(value) {
|
|
|
109
151
|
function quoteSqlIdentifier(identifier) {
|
|
110
152
|
return `"${identifier.replaceAll('"', '""')}"`;
|
|
111
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* Snapshots are optional members of `BlockStore` — a store can be a complete database backend
|
|
156
|
+
* without being able to copy itself out — so the database checks at the call and says plainly
|
|
157
|
+
* when the capability is absent rather than failing as a missing property.
|
|
158
|
+
*/
|
|
112
159
|
function exportingStore(store) {
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
160
|
+
const exportSnapshot = store.exportSnapshot?.bind(store);
|
|
161
|
+
if (exportSnapshot === undefined) {
|
|
115
162
|
throw new Error("This database's block store cannot export snapshots");
|
|
116
163
|
}
|
|
117
|
-
return
|
|
164
|
+
return { exportSnapshot };
|
|
118
165
|
}
|
|
119
166
|
function importingStore(store) {
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
167
|
+
const importSnapshot = store.importSnapshot?.bind(store);
|
|
168
|
+
if (importSnapshot === undefined) {
|
|
122
169
|
throw new Error("This database's block store cannot load snapshots");
|
|
123
170
|
}
|
|
124
|
-
return
|
|
171
|
+
return { importSnapshot };
|
|
125
172
|
}
|
|
126
173
|
export class MinnowDatabase {
|
|
127
174
|
store;
|
|
@@ -137,7 +184,7 @@ export class MinnowDatabase {
|
|
|
137
184
|
/** The scope a statement-level BEGIN opened, held until COMMIT, ROLLBACK, or the idle sweep. */
|
|
138
185
|
#openTransaction;
|
|
139
186
|
#compression;
|
|
140
|
-
/** Per-column
|
|
187
|
+
/** Per-column count since gzip last failed to repay itself; successful probes need no entry. */
|
|
141
188
|
#gzipVerdicts = new Map();
|
|
142
189
|
#rowsPerBlock;
|
|
143
190
|
#maxCommitRetries;
|
|
@@ -146,16 +193,51 @@ export class MinnowDatabase {
|
|
|
146
193
|
#createId;
|
|
147
194
|
#internalLeaseOwnerId = `minnow/${crypto.randomUUID()}`;
|
|
148
195
|
#liveSets = new Set();
|
|
196
|
+
/** Live proof inputs per commit window, keyed `after:until`; see #liveProofContext. */
|
|
197
|
+
#liveProofContexts = new Map();
|
|
149
198
|
#internalLeaseSequence = 0;
|
|
150
199
|
#artifactCache;
|
|
151
200
|
#ftsAutoIndexRows;
|
|
152
201
|
#autoCompact;
|
|
202
|
+
#compactionPartitionRows;
|
|
203
|
+
#autoCollect;
|
|
204
|
+
/** Data commits since the last background collection pass. */
|
|
205
|
+
#commitsSinceCollection = 0;
|
|
206
|
+
/**
|
|
207
|
+
* The highest manifest version below which everything is known to be collected — pruned,
|
|
208
|
+
* with no block left behind; a collection plan starts its walk there. In memory only: a
|
|
209
|
+
* fresh instance walks the whole history once and learns it again.
|
|
210
|
+
*/
|
|
211
|
+
#collectionWatermark = null;
|
|
212
|
+
#autoCollectionInFlight = false;
|
|
213
|
+
/** A trigger that arrived while a run was in flight; honoured when the run ends. */
|
|
214
|
+
#autoCollectionRequested = false;
|
|
215
|
+
#autoCollectionBackoffUntilCommit = 0;
|
|
216
|
+
/** When the last background collection pass started, by the database clock. */
|
|
217
|
+
#lastCollectionAt;
|
|
218
|
+
/** The idle pass scheduled after the last commit; reset by the next commit. */
|
|
219
|
+
#idleCollectionTimer;
|
|
220
|
+
/** The garbage-collection step in flight, so steps run one at a time: see #serializedCollectionStep. */
|
|
221
|
+
#collectionSteps = Promise.resolve();
|
|
153
222
|
/** One background build attempt per (table, column) per session; misses just stay scans. */
|
|
154
223
|
#ftsBuildsInFlight = new Set();
|
|
155
224
|
/** Tables with a fire-and-forget compaction step already running. */
|
|
156
225
|
#autoCompactionsInFlight = new Set();
|
|
226
|
+
/** Tables whose maintenance threshold was observed again while their fold was still running. */
|
|
227
|
+
#autoCompactionsRequested = new Set();
|
|
228
|
+
/** Changed tables awaiting the debounced check that closes a write burst. */
|
|
229
|
+
#idleCompactionTableIds = new Set();
|
|
230
|
+
#idleCompactionTimer;
|
|
231
|
+
/** Tables whose drop is retiring data; prevents a new background fold from starting. */
|
|
232
|
+
#droppingTables = new Set();
|
|
157
233
|
/** Per table: the visible segment count a failed auto-compaction must see before retrying. */
|
|
158
234
|
#autoCompactionBackoff = new Map();
|
|
235
|
+
/** Data commits per table since its last write-path auto-compaction check. */
|
|
236
|
+
#commitsSinceCompactionCheck = new Map();
|
|
237
|
+
/** The compaction step in flight per table, so steps on one table run one at a time. */
|
|
238
|
+
#compactionSteps = new Map();
|
|
239
|
+
/** The simple writes in flight, chained so they commit one after another: see #runWrite. */
|
|
240
|
+
#writeChain = Promise.resolve();
|
|
159
241
|
/**
|
|
160
242
|
* SQL text to optimized plan, LRU by insertion order. Compiled plans are never mutated after
|
|
161
243
|
* optimization — subquery resolution and CTE expansion clone before rewriting and join
|
|
@@ -169,6 +251,8 @@ export class MinnowDatabase {
|
|
|
169
251
|
#ftsCandidatesMemo = new WeakMap();
|
|
170
252
|
#sharedLease;
|
|
171
253
|
#sharedLeaseRenewal;
|
|
254
|
+
/** An in-flight re-pin of the shared lease; acquirers wait for it, never join it. */
|
|
255
|
+
#sharedLeaseMove;
|
|
172
256
|
/**
|
|
173
257
|
* Catalog states keyed by requested table-name set, valid only at #catalogStateEpoch.
|
|
174
258
|
* The (version, epoch) probe is the sole validity signal: a matched epoch proves a cached
|
|
@@ -200,6 +284,8 @@ export class MinnowDatabase {
|
|
|
200
284
|
this.#artifactCache = new ArtifactCache(options.bufferPoolBytes ?? 64 * 1024 * 1024);
|
|
201
285
|
this.#ftsAutoIndexRows = options.ftsAutoIndexRows ?? 4096;
|
|
202
286
|
this.#autoCompact = options.autoCompact ?? true;
|
|
287
|
+
this.#autoCollect = options.autoCollect ?? this.#autoCompact;
|
|
288
|
+
this.#compactionPartitionRows = positiveWholeNumber(options.compaction?.partitionRows ?? DEFAULT_COMPACTION_PARTITION_ROWS, "Compaction partition rows");
|
|
203
289
|
if (!Number.isSafeInteger(this.#ftsAutoIndexRows) || this.#ftsAutoIndexRows < 0) {
|
|
204
290
|
throw new RangeError("Full-text auto-index row threshold must be a non-negative whole number");
|
|
205
291
|
}
|
|
@@ -300,7 +386,6 @@ export class MinnowDatabase {
|
|
|
300
386
|
...(input.managed === true ? { managed: true } : {}),
|
|
301
387
|
...(uniqueKeyColumn === undefined ? {} : { uniqueKeyColumnId: uniqueKeyColumn.id }),
|
|
302
388
|
...(uniqueKeyColumn === undefined ? {} : { uniqueKeyLookupReady: true }),
|
|
303
|
-
...(uniqueKeyColumn === undefined ? {} : { uniqueKeyStorage: "chunks-v2" }),
|
|
304
389
|
createdAt: this.#now().toISOString(),
|
|
305
390
|
});
|
|
306
391
|
}
|
|
@@ -333,17 +418,42 @@ export class MinnowDatabase {
|
|
|
333
418
|
* conflict: unlike the plain rebase-and-retry, a restart re-reads pre-images and re-runs
|
|
334
419
|
* trigger bodies at the fresh state, so derivations can never publish stale values.
|
|
335
420
|
*/
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
421
|
+
/**
|
|
422
|
+
* Runs one simple write — insert, upsert, update, or delete — after every simple write this
|
|
423
|
+
* database already has in flight, restarting it when its trigger derivations went stale.
|
|
424
|
+
*
|
|
425
|
+
* Commits are optimistic: a writer reads the manifest version, stages, and publishes only if
|
|
426
|
+
* the version has not moved, rebasing and retrying otherwise up to `maxCommitRetries`.
|
|
427
|
+
* Writers issued concurrently from one database used to all read the same version and spend
|
|
428
|
+
* a retry per rival that landed first, so past `maxCommitRetries + 1` of them the rest failed
|
|
429
|
+
* for nothing — contention this database need not create, and the queue does not. Writers in
|
|
430
|
+
* other instances and other tabs still contend, and the retry loop is still what resolves
|
|
431
|
+
* them. Write scopes are not queued: a scope's callback may issue a plain write of its own,
|
|
432
|
+
* which must not wait on the scope that contains it.
|
|
433
|
+
*/
|
|
434
|
+
async #runWrite(run) {
|
|
435
|
+
const restarting = async () => {
|
|
436
|
+
for (let attempt = 0;; attempt += 1) {
|
|
437
|
+
try {
|
|
438
|
+
return await run();
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (!(error instanceof StaleTriggerDerivationsError))
|
|
442
|
+
throw error;
|
|
443
|
+
if (attempt >= this.#maxCommitRetries)
|
|
444
|
+
throw error.conflict;
|
|
445
|
+
}
|
|
346
446
|
}
|
|
447
|
+
};
|
|
448
|
+
const previous = this.#writeChain;
|
|
449
|
+
const current = previous.then(restarting, restarting);
|
|
450
|
+
this.#writeChain = current;
|
|
451
|
+
try {
|
|
452
|
+
return await current;
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
if (this.#writeChain === current)
|
|
456
|
+
this.#writeChain = Promise.resolve();
|
|
347
457
|
}
|
|
348
458
|
}
|
|
349
459
|
/**
|
|
@@ -449,39 +559,57 @@ export class MinnowDatabase {
|
|
|
449
559
|
}
|
|
450
560
|
}
|
|
451
561
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
562
|
+
this.#droppingTables.add(table.id);
|
|
563
|
+
try {
|
|
564
|
+
// Stop every fold already attached to the table before its catalog record disappears.
|
|
565
|
+
// Otherwise an unpublished job can no longer resume or be cancelled by table name, and
|
|
566
|
+
// its transaction and staged output become permanent roots.
|
|
567
|
+
await this.#cancelTableCompactions(table.id);
|
|
568
|
+
// Retiring the blocks is a commit like any other, and background compaction publishes
|
|
569
|
+
// underneath it: a block this table owned a moment ago can already have been rewritten. The
|
|
570
|
+
// list is therefore taken from the transaction's own snapshot — the manifest its commit will
|
|
571
|
+
// be validated against — and a scope that loses the race simply runs again.
|
|
572
|
+
for (let attempt = 0;; attempt += 1) {
|
|
573
|
+
const transaction = await this.#transactions.begin();
|
|
574
|
+
try {
|
|
575
|
+
const segments = await this.store.listSegments(table.id);
|
|
576
|
+
const snapshot = transaction.snapshotVersion === null
|
|
577
|
+
? undefined
|
|
578
|
+
: await this.store.getManifest(transaction.snapshotVersion);
|
|
579
|
+
const live = new Set(snapshot?.blockIds ?? []);
|
|
580
|
+
const blockIds = [
|
|
581
|
+
...new Set(segments.flatMap((segment) => Object.values(segment.columnBlockIds).flat())),
|
|
582
|
+
].filter((id) => live.has(id));
|
|
583
|
+
transaction.markTableChanged(table.id);
|
|
584
|
+
if (blockIds.length > 0)
|
|
585
|
+
transaction.supersedeBlocks(blockIds);
|
|
586
|
+
await transaction.commit();
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
catch (error) {
|
|
590
|
+
await transaction.abort();
|
|
591
|
+
if (!(error instanceof WriteConflictError) || attempt >= this.#maxCommitRetries)
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
477
594
|
}
|
|
595
|
+
// The catalog goes last: until it does, the table is merely empty of live blocks, and a
|
|
596
|
+
// crash in between leaves a table whose rows are gone rather than a segment pointing at a
|
|
597
|
+
// table that is not there.
|
|
598
|
+
// Catch a fold that was already between its scheduling check and job creation when the
|
|
599
|
+
// drop began. The dropping marker prevents another one from starting after this point.
|
|
600
|
+
await this.#cancelTableCompactions(table.id);
|
|
601
|
+
await this.store.removeTable(table.id, table.revision ?? 0);
|
|
602
|
+
for (const column of table.columns)
|
|
603
|
+
this.#gzipVerdicts.delete(column.id);
|
|
604
|
+
this.#autoCompactionBackoff.delete(table.id);
|
|
605
|
+
this.#commitsSinceCompactionCheck.delete(table.id);
|
|
606
|
+
this.#idleCompactionTableIds.delete(table.id);
|
|
607
|
+
this.#planCache.clear();
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
finally {
|
|
611
|
+
this.#droppingTables.delete(table.id);
|
|
478
612
|
}
|
|
479
|
-
// The catalog goes last: until it does, the table is merely empty of live blocks, and a
|
|
480
|
-
// crash in between leaves a table whose rows are gone rather than a segment pointing at a
|
|
481
|
-
// table that is not there.
|
|
482
|
-
await this.store.removeTable(table.id, table.revision ?? 0);
|
|
483
|
-
this.#planCache.clear();
|
|
484
|
-
return true;
|
|
485
613
|
}
|
|
486
614
|
async insertBatch(tableName, input) {
|
|
487
615
|
const table = await this.#findTable(tableName);
|
|
@@ -490,7 +618,7 @@ export class MinnowDatabase {
|
|
|
490
618
|
const keys = autoIncrement === undefined || autoIncrement.missingIndexes.length === 0
|
|
491
619
|
? batchKeys(table, batch)
|
|
492
620
|
: undefined;
|
|
493
|
-
const result = await this.#
|
|
621
|
+
const result = await this.#runWrite(() => this.#writeBatch(table, batch, "insert", keys, autoIncrement));
|
|
494
622
|
collectAutoIncrementGenerated(batch, generated, autoIncrement);
|
|
495
623
|
return {
|
|
496
624
|
tableName: result.tableName,
|
|
@@ -525,7 +653,7 @@ export class MinnowDatabase {
|
|
|
525
653
|
await this.#assertForeignKeysPresent(table, (column) => batch.columns[column] ?? [], (sql, params) => this.query(sql, { params, memoize: false }));
|
|
526
654
|
const deferred = autoIncrement !== undefined && autoIncrement.missingIndexes.length > 0;
|
|
527
655
|
const keys = deferred ? undefined : batchKeys(table, batch);
|
|
528
|
-
const result = await this.#
|
|
656
|
+
const result = await this.#runWrite(() => this.#writeBatch(table, batch, "upsert", keys, autoIncrement));
|
|
529
657
|
collectAutoIncrementGenerated(batch, generated, autoIncrement);
|
|
530
658
|
return {
|
|
531
659
|
...result,
|
|
@@ -544,7 +672,7 @@ export class MinnowDatabase {
|
|
|
544
672
|
}
|
|
545
673
|
const keys = validateUpdateBatch(table, keyColumn, input);
|
|
546
674
|
await this.#assertForeignKeysPresent(table, (column) => input.changes[column] ?? [], (sql, params) => this.query(sql, { params, memoize: false }));
|
|
547
|
-
return this.#
|
|
675
|
+
return this.#runWrite(() => this.#writeUpdateBatch(table, keyColumn, input, keys));
|
|
548
676
|
}
|
|
549
677
|
async update(tableName, key, changes) {
|
|
550
678
|
return this.updateBatch(tableName, {
|
|
@@ -555,7 +683,7 @@ export class MinnowDatabase {
|
|
|
555
683
|
async deleteBatch(tableName, input) {
|
|
556
684
|
const dependents = await this.#childForeignKeys(tableName);
|
|
557
685
|
if (dependents.length === 0) {
|
|
558
|
-
return this.#
|
|
686
|
+
return this.#runWrite(() => this.#deleteBatchOnce(tableName, input));
|
|
559
687
|
}
|
|
560
688
|
// E141-04: the referential actions and the delete itself publish as one commit, so no tab
|
|
561
689
|
// can observe a parent gone while its children still point at it.
|
|
@@ -606,14 +734,15 @@ export class MinnowDatabase {
|
|
|
606
734
|
keys.set(token, value);
|
|
607
735
|
});
|
|
608
736
|
const logicalBytes = estimateValuesBytes(input.keys);
|
|
609
|
-
|
|
737
|
+
// Deferred: the record is written only if something stages in two steps (trigger rows),
|
|
738
|
+
// and otherwise rides the single-shot commit below — or never exists, for a no-op delete.
|
|
739
|
+
const transaction = await this.#transactions.beginDeferred();
|
|
610
740
|
const segmentId = this.#createId();
|
|
611
741
|
transaction.setUniqueKeyChanges({
|
|
612
742
|
tableId: table.id,
|
|
613
743
|
keyTokens: [...keys.keys()],
|
|
614
744
|
requireAbsent: false,
|
|
615
745
|
remove: true,
|
|
616
|
-
...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
|
|
617
746
|
});
|
|
618
747
|
let deletedRowCount;
|
|
619
748
|
let storedBytes = 0;
|
|
@@ -674,11 +803,7 @@ export class MinnowDatabase {
|
|
|
674
803
|
const deletePreImages = (await this.#triggerPreImages(table, keyColumn, [...keys.values()], "delete")).filter((row) => row !== undefined);
|
|
675
804
|
const deleteValueAt = (source, column, rowIndex) => (source === "old" ? (deletePreImages[rowIndex]?.[column] ?? null) : null);
|
|
676
805
|
await this.#stageTriggerDerivedInserts(transaction, table, "delete", deletePreImages.length, deleteValueAt, "before");
|
|
677
|
-
|
|
678
|
-
await transaction.stageBlocks(blockWrites);
|
|
679
|
-
stageMs += performance.now() - stageStarted;
|
|
680
|
-
stageStarted = performance.now();
|
|
681
|
-
await transaction.stageSegment({
|
|
806
|
+
const segment = {
|
|
682
807
|
id: segmentId,
|
|
683
808
|
tableId: table.id,
|
|
684
809
|
transactionId: transaction.id,
|
|
@@ -690,15 +815,24 @@ export class MinnowDatabase {
|
|
|
690
815
|
keyColumnId: keyColumn.id,
|
|
691
816
|
level: 0,
|
|
692
817
|
createdAt: this.#now().toISOString(),
|
|
693
|
-
}
|
|
694
|
-
|
|
818
|
+
};
|
|
819
|
+
// AFTER triggers stage derived rows between the segment and the commit, which keeps the
|
|
820
|
+
// two apart; without them the stage and the commit collapse into one storage write.
|
|
821
|
+
const stagesAfter = firesAfterTriggers(table, "delete");
|
|
822
|
+
const stageStarted = performance.now();
|
|
823
|
+
if (stagesAfter) {
|
|
824
|
+
await transaction.stageArtifacts(blockWrites, [segment]);
|
|
825
|
+
await this.#stageTriggerDerivedInserts(transaction, table, "delete", deletePreImages.length, deleteValueAt, "after");
|
|
826
|
+
}
|
|
695
827
|
stageMs += performance.now() - stageStarted;
|
|
696
828
|
for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
|
|
697
829
|
const commitStarted = performance.now();
|
|
698
830
|
try {
|
|
699
|
-
const manifest =
|
|
831
|
+
const manifest = stagesAfter
|
|
832
|
+
? await transaction.commit()
|
|
833
|
+
: await transaction.stageArtifactsAndCommit(blockWrites, [segment]);
|
|
700
834
|
commitMs += performance.now() - commitStarted;
|
|
701
|
-
this.#
|
|
835
|
+
this.#afterCommit(manifest);
|
|
702
836
|
return {
|
|
703
837
|
tableName: table.name,
|
|
704
838
|
segmentId,
|
|
@@ -745,7 +879,8 @@ export class MinnowDatabase {
|
|
|
745
879
|
const started = performance.now();
|
|
746
880
|
const logicalBytes = estimateValuesBytes(input.keys) +
|
|
747
881
|
Object.values(input.changes).reduce((total, values) => total + estimateValuesBytes(values), 0);
|
|
748
|
-
|
|
882
|
+
// Deferred: the record rides the single-shot commit below unless trigger rows stage first.
|
|
883
|
+
const transaction = await this.#transactions.beginDeferred();
|
|
749
884
|
const segmentId = this.#createId();
|
|
750
885
|
const columnBlockIds = {};
|
|
751
886
|
const changedColumns = Object.keys(input.changes).sort();
|
|
@@ -810,30 +945,36 @@ export class MinnowDatabase {
|
|
|
810
945
|
}
|
|
811
946
|
}
|
|
812
947
|
await this.#stageTriggerDerivedInserts(transaction, table, "update", input.keys.length, updateValueAt, "before");
|
|
948
|
+
const segment = {
|
|
949
|
+
id: segmentId,
|
|
950
|
+
tableId: table.id,
|
|
951
|
+
transactionId: transaction.id,
|
|
952
|
+
rowCount: input.keys.length,
|
|
953
|
+
rowIdStart: 0n,
|
|
954
|
+
rowIdEndExclusive: 0n,
|
|
955
|
+
columnBlockIds,
|
|
956
|
+
kind: "update",
|
|
957
|
+
keyColumnId: keyColumn.id,
|
|
958
|
+
level: 0,
|
|
959
|
+
createdAt: this.#now().toISOString(),
|
|
960
|
+
};
|
|
961
|
+
// AFTER triggers stage derived rows between the segment and the commit, which keeps the
|
|
962
|
+
// two apart; without them the stage and the commit collapse into one storage write.
|
|
963
|
+
const stagesAfter = firesAfterTriggers(table, "update");
|
|
813
964
|
const stageStarted = performance.now();
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
transactionId: transaction.id,
|
|
819
|
-
rowCount: input.keys.length,
|
|
820
|
-
rowIdStart: 0n,
|
|
821
|
-
rowIdEndExclusive: 0n,
|
|
822
|
-
columnBlockIds,
|
|
823
|
-
kind: "update",
|
|
824
|
-
keyColumnId: keyColumn.id,
|
|
825
|
-
level: 0,
|
|
826
|
-
createdAt: this.#now().toISOString(),
|
|
827
|
-
},
|
|
828
|
-
]);
|
|
829
|
-
await this.#stageTriggerDerivedInserts(transaction, table, "update", input.keys.length, updateValueAt, "after");
|
|
965
|
+
if (stagesAfter) {
|
|
966
|
+
await transaction.stageArtifacts(batchBlockWrites, [segment]);
|
|
967
|
+
await this.#stageTriggerDerivedInserts(transaction, table, "update", input.keys.length, updateValueAt, "after");
|
|
968
|
+
}
|
|
830
969
|
stageMs += performance.now() - stageStarted;
|
|
831
970
|
for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
|
|
832
971
|
const commitStarted = performance.now();
|
|
833
972
|
try {
|
|
834
|
-
const manifest =
|
|
973
|
+
const manifest = stagesAfter
|
|
974
|
+
? await transaction.commit()
|
|
975
|
+
: await transaction.stageArtifactsAndCommit(batchBlockWrites, [segment]);
|
|
835
976
|
commitMs += performance.now() - commitStarted;
|
|
836
|
-
this.#
|
|
977
|
+
this.#afterCommit(manifest);
|
|
837
978
|
return {
|
|
838
979
|
tableName: table.name,
|
|
839
980
|
segmentId,
|
|
@@ -927,7 +1068,6 @@ export class MinnowDatabase {
|
|
|
927
1068
|
tableId: table.id,
|
|
928
1069
|
keyTokens: [...resolvedKeys.keys()],
|
|
929
1070
|
requireAbsent: kind === "insert",
|
|
930
|
-
...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
|
|
931
1071
|
});
|
|
932
1072
|
}
|
|
933
1073
|
counts =
|
|
@@ -1008,20 +1148,29 @@ export class MinnowDatabase {
|
|
|
1008
1148
|
else if (upsertFirings !== undefined) {
|
|
1009
1149
|
await this.#stageUpsertTriggerFirings(transaction, table, input, upsertFirings, "before");
|
|
1010
1150
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1151
|
+
// AFTER triggers stage derived rows between the segment and the commit, which keeps the
|
|
1152
|
+
// two apart; without them the stage and the commit collapse into one storage write.
|
|
1153
|
+
const stagesAfter = kind === "insert"
|
|
1154
|
+
? firesAfterTriggers(table, "insert")
|
|
1155
|
+
: upsertFirings !== undefined && firesAfterTriggers(table, "insert", "update");
|
|
1156
|
+
if (stagesAfter) {
|
|
1157
|
+
await transaction.stageArtifacts(batchBlockWrites, [segment]);
|
|
1158
|
+
if (kind === "insert") {
|
|
1159
|
+
await this.#stageTriggerDerivedInserts(transaction, table, "insert", rowCount, insertValueAt, "after");
|
|
1160
|
+
}
|
|
1161
|
+
else if (upsertFirings !== undefined) {
|
|
1162
|
+
await this.#stageUpsertTriggerFirings(transaction, table, input, upsertFirings, "after");
|
|
1163
|
+
}
|
|
1017
1164
|
}
|
|
1018
1165
|
stageMs += performance.now() - stageStarted;
|
|
1019
1166
|
for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
|
|
1020
1167
|
const commitStarted = performance.now();
|
|
1021
1168
|
try {
|
|
1022
|
-
const manifest =
|
|
1169
|
+
const manifest = stagesAfter
|
|
1170
|
+
? await transaction.commit()
|
|
1171
|
+
: await transaction.stageArtifactsAndCommit(batchBlockWrites, [segment]);
|
|
1023
1172
|
commitMs += performance.now() - commitStarted;
|
|
1024
|
-
this.#
|
|
1173
|
+
this.#afterCommit(manifest);
|
|
1025
1174
|
return {
|
|
1026
1175
|
tableName: table.name,
|
|
1027
1176
|
segmentId,
|
|
@@ -1125,13 +1274,13 @@ export class MinnowDatabase {
|
|
|
1125
1274
|
}
|
|
1126
1275
|
return plan;
|
|
1127
1276
|
}
|
|
1128
|
-
async #prepareCompiledPlan(plan, options = {}) {
|
|
1277
|
+
async #prepareCompiledPlan(plan, options = {}, probe) {
|
|
1129
1278
|
// The ORDER-BY-expression desugar's wrapper is projection-only: prepare the inner block
|
|
1130
1279
|
// directly (no derived materialization) and project each result to the visible aliases,
|
|
1131
1280
|
// so `.search()` costs the same whether or not the caller also selects the score.
|
|
1132
1281
|
const wrapper = transparentProjectionSource(plan);
|
|
1133
1282
|
if (wrapper !== undefined) {
|
|
1134
|
-
const prepared = await this.#prepareCompiledPlan(wrapper.inner, options);
|
|
1283
|
+
const prepared = await this.#prepareCompiledPlan(wrapper.inner, options, probe);
|
|
1135
1284
|
return {
|
|
1136
1285
|
sql: prepared.sql,
|
|
1137
1286
|
tables: prepared.tables,
|
|
@@ -1179,7 +1328,7 @@ export class MinnowDatabase {
|
|
|
1179
1328
|
});
|
|
1180
1329
|
}
|
|
1181
1330
|
else {
|
|
1182
|
-
await this.#withSharedCatalogSnapshot(collectRealTableNames(plan), prepareAtSnapshot);
|
|
1331
|
+
await this.#withSharedCatalogSnapshot(collectRealTableNames(plan), prepareAtSnapshot, probe);
|
|
1183
1332
|
}
|
|
1184
1333
|
return createPreparedColumnarQuery(chooseJoinOrder(resolvedPlan, columnarTables), columnarTables, memory, ftsStats === undefined ? {} : { ftsStats });
|
|
1185
1334
|
}
|
|
@@ -1196,9 +1345,9 @@ export class MinnowDatabase {
|
|
|
1196
1345
|
* write transactions. If the manifest is pruned between the read and the lease, the
|
|
1197
1346
|
* state is re-read.
|
|
1198
1347
|
*/
|
|
1199
|
-
async #withSharedCatalogSnapshot(names, action) {
|
|
1348
|
+
async #withSharedCatalogSnapshot(names, action, probe) {
|
|
1200
1349
|
for (;;) {
|
|
1201
|
-
const state = await this.#cachedCatalogState(names);
|
|
1350
|
+
const state = await this.#cachedCatalogState(names, probe);
|
|
1202
1351
|
const realTables = new Map();
|
|
1203
1352
|
names.forEach((name, index) => {
|
|
1204
1353
|
const table = state.tables[index];
|
|
@@ -1239,11 +1388,13 @@ export class MinnowDatabase {
|
|
|
1239
1388
|
* cached state was read, so reuse is exact, not heuristic. Stores without a probe are never
|
|
1240
1389
|
* cached. Entries key on the requested table-name set; a changed epoch clears them all.
|
|
1241
1390
|
*/
|
|
1242
|
-
async #cachedCatalogState(names) {
|
|
1243
|
-
|
|
1244
|
-
|
|
1391
|
+
async #cachedCatalogState(names, probe) {
|
|
1392
|
+
// A probe the caller read moments earlier in the same statement serves: a state read under
|
|
1393
|
+
// it is at least as fresh, and the cache is only consulted under its epoch.
|
|
1394
|
+
const read = this.store.getCatalogProbe?.bind(this.store);
|
|
1395
|
+
if (read === undefined)
|
|
1245
1396
|
return this.#queryCatalogState(names);
|
|
1246
|
-
const { catalogEpoch } = await
|
|
1397
|
+
const { catalogEpoch } = probe ?? (await read());
|
|
1247
1398
|
// Table names are only trimmed, never charset-restricted, so no join separator is
|
|
1248
1399
|
// collision-free; JSON encoding is.
|
|
1249
1400
|
const key = JSON.stringify(names);
|
|
@@ -1292,45 +1443,83 @@ export class MinnowDatabase {
|
|
|
1292
1443
|
}
|
|
1293
1444
|
/**
|
|
1294
1445
|
* Reuses the shared internal reader lease when it targets the requested version and has
|
|
1295
|
-
* not expired
|
|
1296
|
-
* one
|
|
1297
|
-
*
|
|
1446
|
+
* not expired. Otherwise the pin has to move: with no reader left on the old version the
|
|
1447
|
+
* one lease record is re-pinned in place (one storage write, instead of a create now and a
|
|
1448
|
+
* remove once the old one drains); while readers remain, a fresh lease opens at the exact
|
|
1449
|
+
* version and the old one retires as they finish. Returns undefined when the version's
|
|
1450
|
+
* manifest disappeared between the catalog read and the lease, so the caller can re-read.
|
|
1298
1451
|
*/
|
|
1299
1452
|
async #acquireSharedLease(version) {
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
return current;
|
|
1453
|
+
for (;;) {
|
|
1454
|
+
// A move in flight is closing the shared snapshot it re-pins; wait for it rather than
|
|
1455
|
+
// hand that snapshot out, then look again.
|
|
1456
|
+
if (this.#sharedLeaseMove !== undefined) {
|
|
1457
|
+
await this.#sharedLeaseMove;
|
|
1458
|
+
continue;
|
|
1307
1459
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1460
|
+
const current = this.#sharedLease;
|
|
1461
|
+
if (current?.version === version &&
|
|
1462
|
+
current.lease.expiresAt.getTime() - this.#now().getTime() > 0) {
|
|
1463
|
+
current.refCount += 1;
|
|
1464
|
+
try {
|
|
1465
|
+
await this.#renewInternalLeaseIfNeeded(current.lease);
|
|
1466
|
+
return current;
|
|
1467
|
+
}
|
|
1468
|
+
catch (error) {
|
|
1469
|
+
this.#releaseSharedLease(current);
|
|
1470
|
+
throw error;
|
|
1471
|
+
}
|
|
1311
1472
|
}
|
|
1312
|
-
|
|
1313
|
-
let lease;
|
|
1314
|
-
try {
|
|
1315
|
-
lease = await this.#transactions.openLeasedSnapshot({
|
|
1473
|
+
const options = {
|
|
1316
1474
|
id: `${this.#internalLeaseOwnerId}/${String(this.#internalLeaseSequence++)}`,
|
|
1317
1475
|
ownerId: this.#internalLeaseOwnerId,
|
|
1318
1476
|
ttlMs: INTERNAL_READ_LEASE_TTL_MS,
|
|
1319
1477
|
version,
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1478
|
+
};
|
|
1479
|
+
let lease;
|
|
1480
|
+
try {
|
|
1481
|
+
if (current?.refCount === 0) {
|
|
1482
|
+
const move = this.#transactions.moveLeasedSnapshot(current.lease, options);
|
|
1483
|
+
this.#sharedLeaseMove = move.then(() => undefined, () => undefined);
|
|
1484
|
+
try {
|
|
1485
|
+
lease = await move;
|
|
1486
|
+
}
|
|
1487
|
+
finally {
|
|
1488
|
+
this.#sharedLeaseMove = undefined;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
else {
|
|
1492
|
+
lease = await this.#transactions.openLeasedSnapshot(options);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
catch (error) {
|
|
1496
|
+
if (error instanceof SnapshotManifestMissingError)
|
|
1497
|
+
return undefined;
|
|
1498
|
+
throw error;
|
|
1499
|
+
}
|
|
1500
|
+
const entry = { lease, version, refCount: 1 };
|
|
1501
|
+
const previous = this.#sharedLease;
|
|
1502
|
+
this.#sharedLease = entry;
|
|
1503
|
+
if (previous?.refCount === 0) {
|
|
1504
|
+
// Already closed when it was the one just moved; a remove otherwise.
|
|
1505
|
+
void previous.lease.release().catch(() => undefined);
|
|
1506
|
+
}
|
|
1507
|
+
return entry;
|
|
1332
1508
|
}
|
|
1333
|
-
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Drops the shared reader lease when nothing holds it and it has fallen behind the current
|
|
1512
|
+
* version. The lease outlives the query that took it so the next query at the same version
|
|
1513
|
+
* reuses it — but after a burst of writes and a fold, an idle database's last lease sits at a
|
|
1514
|
+
* pre-fold version and roots every block that version referenced, which is exactly what a
|
|
1515
|
+
* collection pass is trying to reclaim. The next query simply takes a fresh lease.
|
|
1516
|
+
*/
|
|
1517
|
+
#releaseIdleSharedLease() {
|
|
1518
|
+
const current = this.#sharedLease;
|
|
1519
|
+
if (current?.refCount !== 0)
|
|
1520
|
+
return;
|
|
1521
|
+
this.#sharedLease = undefined;
|
|
1522
|
+
void current.lease.release().catch(() => undefined);
|
|
1334
1523
|
}
|
|
1335
1524
|
#releaseSharedLease(entry) {
|
|
1336
1525
|
entry.refCount -= 1;
|
|
@@ -1439,7 +1628,9 @@ export class MinnowDatabase {
|
|
|
1439
1628
|
}
|
|
1440
1629
|
}
|
|
1441
1630
|
const { result: inner, schema: innerSchema } = await this.#executeBlockWithSchemaCached(source.windowed.block, snapshot, visibility, memory, realTables, typedSchemas, cacheResults);
|
|
1442
|
-
const windowed = applyWindowFunctions(inner, source.windowed.windows
|
|
1631
|
+
const windowed = applyWindowFunctions(inner, source.windowed.windows, {
|
|
1632
|
+
copyRows: cacheResults,
|
|
1633
|
+
});
|
|
1443
1634
|
const schema = [
|
|
1444
1635
|
...innerSchema,
|
|
1445
1636
|
...source.windowed.windows.map((window) => ({
|
|
@@ -1522,12 +1713,20 @@ export class MinnowDatabase {
|
|
|
1522
1713
|
probe !== undefined;
|
|
1523
1714
|
if (probe === undefined || !memoizable)
|
|
1524
1715
|
return this.#queryCompiled(plan, options);
|
|
1525
|
-
|
|
1716
|
+
return this.#memoizedQuery(plan, `res ${queryResultMemoKey(sql, options.params ?? [])}`, options, probe);
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* The result memo: a pure cache over the freshness probe, keyed by the statement and the
|
|
1720
|
+
* catalog epoch it was answered at. The probe read before execution is handed down to the
|
|
1721
|
+
* execution itself — the view lookup and the catalog state would otherwise each probe again,
|
|
1722
|
+
* and on IndexedDB every probe is a read transaction, a floor under every small query.
|
|
1723
|
+
*/
|
|
1724
|
+
async #memoizedQuery(plan, key, options, probe) {
|
|
1526
1725
|
const before = await probe();
|
|
1527
1726
|
const cached = this.#cacheGet(`${key}\u0001${String(before.catalogEpoch)}`);
|
|
1528
1727
|
if (cached !== undefined)
|
|
1529
1728
|
return copyQueryResult(cached);
|
|
1530
|
-
const result = await this.#queryCompiled(plan, options);
|
|
1729
|
+
const result = await this.#queryCompiled(plan, options, before);
|
|
1531
1730
|
const bytes = queryResultRetainedBytes(result);
|
|
1532
1731
|
if (bytes <= RESULT_MEMO_MAX_BYTES) {
|
|
1533
1732
|
// Cache only when the epoch did not move during execution: the result is then exactly
|
|
@@ -1545,14 +1744,14 @@ export class MinnowDatabase {
|
|
|
1545
1744
|
* (E051-09), and a NATURAL join becomes the equality over the columns its sides share
|
|
1546
1745
|
* (F401-01). Reads the catalog only for the statements that ask for one of them.
|
|
1547
1746
|
*/
|
|
1548
|
-
async #applyCatalogRewrites(plan) {
|
|
1747
|
+
async #applyCatalogRewrites(plan, probe) {
|
|
1549
1748
|
const aliased = planHasSourceColumnAliases(plan);
|
|
1550
1749
|
const natural = planHasNaturalJoins(plan);
|
|
1551
1750
|
// Whether a name is a view cannot be read off the statement, so this is the one thing every
|
|
1552
1751
|
// read has to ask the catalog. It asks by epoch — an O(1) probe the store already serves for
|
|
1553
1752
|
// result memoization — and only re-reads the view set when the catalog has actually moved.
|
|
1554
1753
|
// A database with no views therefore pays one probe, not a catalog scan per query.
|
|
1555
|
-
const { views } = await this.#catalogFacts();
|
|
1754
|
+
const { views } = await this.#catalogFacts(probe);
|
|
1556
1755
|
let rewritten = plan;
|
|
1557
1756
|
if (views.size > 0 && planReadsViews(plan, (name) => views.has(name))) {
|
|
1558
1757
|
const bodies = new Map();
|
|
@@ -1587,8 +1786,8 @@ export class MinnowDatabase {
|
|
|
1587
1786
|
* the steady state has to be one epoch probe and no allocation; the facts are rebuilt only
|
|
1588
1787
|
* when a catalog mutation — anywhere, including another tab — moves the epoch.
|
|
1589
1788
|
*/
|
|
1590
|
-
async #catalogFacts() {
|
|
1591
|
-
|
|
1789
|
+
async #catalogFacts(probe) {
|
|
1790
|
+
probe ??= await this.store.getCatalogProbe?.();
|
|
1592
1791
|
const epoch = probe?.catalogEpoch;
|
|
1593
1792
|
const cached = this.#catalogCache;
|
|
1594
1793
|
if (cached !== undefined && epoch !== undefined && cached.epoch === epoch)
|
|
@@ -1620,13 +1819,16 @@ export class MinnowDatabase {
|
|
|
1620
1819
|
* re-runs — routes through the same streaming-first execution, so builder/SQL parity holds
|
|
1621
1820
|
* for the execution path as well as the plan.
|
|
1622
1821
|
*/
|
|
1623
|
-
async #queryCompiled(plan, options = {}) {
|
|
1624
|
-
|
|
1822
|
+
async #queryCompiled(plan, options = {}, probe) {
|
|
1823
|
+
// One freshness probe per query: read here unless the caller already has one, and handed
|
|
1824
|
+
// to the view lookup and the catalog state below, which would otherwise probe again each.
|
|
1825
|
+
probe ??= await this.store.getCatalogProbe?.();
|
|
1826
|
+
plan = await this.#applyCatalogRewrites(plan, probe);
|
|
1625
1827
|
const spillPageRows = options.spillPageRows === undefined
|
|
1626
1828
|
? undefined
|
|
1627
1829
|
: positiveWholeNumber(options.spillPageRows, "Query spill page rows");
|
|
1628
1830
|
if (this.#canStreamPlanShape(plan, options)) {
|
|
1629
|
-
const streamed = await this.#queryStreamed(plan, options, spillPageRows);
|
|
1831
|
+
const streamed = await this.#queryStreamed(plan, options, spillPageRows, probe);
|
|
1630
1832
|
if (streamed !== undefined)
|
|
1631
1833
|
return streamed;
|
|
1632
1834
|
}
|
|
@@ -1636,12 +1838,12 @@ export class MinnowDatabase {
|
|
|
1636
1838
|
// query its streaming eligibility.
|
|
1637
1839
|
const wrapper = transparentProjectionSource(plan);
|
|
1638
1840
|
if (wrapper !== undefined && this.#canStreamPlanShape(wrapper.inner, options)) {
|
|
1639
|
-
const streamed = await this.#queryStreamed(wrapper.inner, options, spillPageRows);
|
|
1841
|
+
const streamed = await this.#queryStreamed(wrapper.inner, options, spillPageRows, probe);
|
|
1640
1842
|
if (streamed !== undefined)
|
|
1641
1843
|
return projectResultColumns(streamed, wrapper.aliases);
|
|
1642
1844
|
}
|
|
1643
1845
|
}
|
|
1644
|
-
const prepared = await this.#prepareCompiledPlan(plan, options);
|
|
1846
|
+
const prepared = await this.#prepareCompiledPlan(plan, options, probe);
|
|
1645
1847
|
// Read the peak before close(): closing releases the context and zeroes what it tracked.
|
|
1646
1848
|
const report = (result) => {
|
|
1647
1849
|
options.onStats?.({ peakMemoryBytes: prepared.memoryUsage.peakBytes });
|
|
@@ -1695,11 +1897,22 @@ export class MinnowDatabase {
|
|
|
1695
1897
|
const renewed = await this.store.renewTempOwner(ownerId, lease.revision, new Date(expiresAtMs).toISOString());
|
|
1696
1898
|
leases.set(ownerId, { revision: renewed.revision, expiresAtMs });
|
|
1697
1899
|
};
|
|
1900
|
+
const batched = this.store.putTempRunPages?.bind(this.store);
|
|
1698
1901
|
return {
|
|
1699
1902
|
putPage: async (ownerId, runId, pageIndex, bytes) => {
|
|
1700
1903
|
await ensureLease(ownerId);
|
|
1701
1904
|
await this.store.putTempRunPage({ ownerId, runId, pageIndex, bytes });
|
|
1702
1905
|
},
|
|
1906
|
+
...(batched === undefined
|
|
1907
|
+
? {}
|
|
1908
|
+
: {
|
|
1909
|
+
putPages: async (pages) => {
|
|
1910
|
+
for (const owner of new Set(pages.map((page) => page.ownerId))) {
|
|
1911
|
+
await ensureLease(owner);
|
|
1912
|
+
}
|
|
1913
|
+
await batched(pages);
|
|
1914
|
+
},
|
|
1915
|
+
}),
|
|
1703
1916
|
getPage: async (ownerId, runId, pageIndex) => {
|
|
1704
1917
|
await ensureLease(ownerId);
|
|
1705
1918
|
return this.store.getTempRunPage(ownerId, runId, pageIndex);
|
|
@@ -1751,17 +1964,30 @@ export class MinnowDatabase {
|
|
|
1751
1964
|
* refresh but never produce a stale result. Subscriptions retain a result digest, not rows.
|
|
1752
1965
|
*/
|
|
1753
1966
|
liveQueries(options = {}) {
|
|
1967
|
+
const compileLiveQuery = (query) => {
|
|
1968
|
+
if (typeof query === "string")
|
|
1969
|
+
return this.#compileCached(query);
|
|
1970
|
+
if (query.kind === "typed-query")
|
|
1971
|
+
return query.plan;
|
|
1972
|
+
return bindPlanParameters(this.#compileCached(query.sql), query.params);
|
|
1973
|
+
};
|
|
1754
1974
|
const set = new LiveQuerySet({
|
|
1755
1975
|
currentVersion: () => this.store.getCurrentManifestVersion(),
|
|
1756
1976
|
manifestPage: (afterVersion, limit) => this.store.listManifestPage(afterVersion, limit),
|
|
1757
1977
|
dependencyTableIds: async (query) => {
|
|
1758
|
-
const compiled =
|
|
1978
|
+
const compiled = compileLiveQuery(query);
|
|
1759
1979
|
// A live query over a view depends on the tables behind it, not on the view's name.
|
|
1760
1980
|
const plan = await this.#applyCatalogRewrites(compiled);
|
|
1761
1981
|
const tables = await this.#findRealBlockTables(plan);
|
|
1762
1982
|
return new Set([...tables.values()].map((record) => record.id));
|
|
1763
1983
|
},
|
|
1764
|
-
execute: async (query) =>
|
|
1984
|
+
execute: async (query) => {
|
|
1985
|
+
if (typeof query === "string")
|
|
1986
|
+
return this.query(query);
|
|
1987
|
+
if (query.kind === "typed-query")
|
|
1988
|
+
return this.#queryCompiled(query.plan);
|
|
1989
|
+
return this.query(query.sql, { params: query.params });
|
|
1990
|
+
},
|
|
1765
1991
|
changeCanAffect: (query, tableIds, after, until) => this.#liveChangeCanAffect(query, tableIds, after, until),
|
|
1766
1992
|
}, {
|
|
1767
1993
|
...options,
|
|
@@ -1774,15 +2000,20 @@ export class MinnowDatabase {
|
|
|
1774
2000
|
return set;
|
|
1775
2001
|
}
|
|
1776
2002
|
/**
|
|
1777
|
-
*
|
|
1778
|
-
* (after, until] to
|
|
1779
|
-
*
|
|
1780
|
-
* reject the
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
2003
|
+
* The data-layer proof behind a live sweep's zone skips: whether the commits in
|
|
2004
|
+
* (after, until] to these tables can change this query's result. False only on proof —
|
|
2005
|
+
* every segment the window introduced to the table is a compaction rewrite or an insert
|
|
2006
|
+
* whose zone maps reject the query's predicates, and every version that changed the table
|
|
2007
|
+
* left a segment to inspect.
|
|
2008
|
+
*
|
|
2009
|
+
* Its inputs come from `#liveProofContext`, shared by every subscription in the sweep.
|
|
1783
2010
|
*/
|
|
1784
2011
|
async #liveChangeCanAffect(query, tableIds, after, until) {
|
|
1785
|
-
const plan = typeof query === "string"
|
|
2012
|
+
const plan = typeof query === "string"
|
|
2013
|
+
? this.#compileCached(query)
|
|
2014
|
+
: query.kind === "typed-query"
|
|
2015
|
+
? query.plan
|
|
2016
|
+
: bindPlanParameters(this.#compileCached(query.sql), query.params);
|
|
1786
2017
|
if (planContainsFts(plan))
|
|
1787
2018
|
return true;
|
|
1788
2019
|
// Base-scan zone proofs are unsound when the plan reads the table anywhere else — a
|
|
@@ -1790,44 +2021,13 @@ export class MinnowDatabase {
|
|
|
1790
2021
|
// predicates reject (e.g. `value > (SELECT AVG(value) FROM t)`).
|
|
1791
2022
|
if (planReadsBeyondSingleScan(plan))
|
|
1792
2023
|
return true;
|
|
1793
|
-
|
|
1794
|
-
// one of them to be accounted for by a surviving, inspected segment: garbage collection
|
|
1795
|
-
// deletes reclaimed segments outright, so "no segment in the window" is absence of
|
|
1796
|
-
// evidence, not evidence of neutrality.
|
|
1797
|
-
const changedVersions = new Map();
|
|
1798
|
-
{
|
|
1799
|
-
let cursor = after;
|
|
1800
|
-
pages: for (;;) {
|
|
1801
|
-
const page = await this.store.listManifestPage(cursor, 64);
|
|
1802
|
-
for (const manifest of page.records) {
|
|
1803
|
-
if (manifest.version > until)
|
|
1804
|
-
break pages;
|
|
1805
|
-
for (const tableId of manifest.changedTableIds ?? []) {
|
|
1806
|
-
const versions = changedVersions.get(tableId) ?? new Set();
|
|
1807
|
-
versions.add(manifest.version);
|
|
1808
|
-
changedVersions.set(tableId, versions);
|
|
1809
|
-
}
|
|
1810
|
-
if (manifest.version === until)
|
|
1811
|
-
break pages;
|
|
1812
|
-
}
|
|
1813
|
-
if (page.nextCursor === null)
|
|
1814
|
-
break;
|
|
1815
|
-
cursor = page.nextCursor;
|
|
1816
|
-
}
|
|
1817
|
-
}
|
|
2024
|
+
const context = await this.#liveProofContext(after, until);
|
|
1818
2025
|
for (const tableId of tableIds) {
|
|
1819
|
-
const
|
|
1820
|
-
if (
|
|
2026
|
+
const entry = await this.#liveProofTable(context, tableId);
|
|
2027
|
+
if (entry === undefined)
|
|
1821
2028
|
return true;
|
|
1822
|
-
const predicates = zonePredicates(plan, table);
|
|
1823
|
-
const
|
|
1824
|
-
const transactions = new Map((await this.#transactionRecordsForSegments(segments)).map((record) => [record.id, record]));
|
|
1825
|
-
const coveredVersions = new Set();
|
|
1826
|
-
for (const segment of segments) {
|
|
1827
|
-
const committed = transactions.get(segment.transactionId)?.committedVersion ?? null;
|
|
1828
|
-
if (committed === null || committed <= (after ?? -1) || committed > until)
|
|
1829
|
-
continue;
|
|
1830
|
-
coveredVersions.add(committed);
|
|
2029
|
+
const predicates = zonePredicates(plan, entry.table);
|
|
2030
|
+
for (const segment of entry.windowSegments) {
|
|
1831
2031
|
const kind = segment.kind ?? "insert";
|
|
1832
2032
|
// Compaction rewrites are visible-data-neutral by construction.
|
|
1833
2033
|
if (kind === "base")
|
|
@@ -1884,13 +2084,85 @@ export class MinnowDatabase {
|
|
|
1884
2084
|
}
|
|
1885
2085
|
// A version that changed this table but left no surviving segment to inspect (its
|
|
1886
2086
|
// segments were compacted away and reclaimed) cannot be proven neutral.
|
|
1887
|
-
for (const version of changedVersions.get(tableId) ?? []) {
|
|
1888
|
-
if (!coveredVersions.has(version))
|
|
2087
|
+
for (const version of context.changedVersions.get(tableId) ?? []) {
|
|
2088
|
+
if (!entry.coveredVersions.has(version))
|
|
1889
2089
|
return true;
|
|
1890
2090
|
}
|
|
1891
2091
|
}
|
|
1892
2092
|
return false;
|
|
1893
2093
|
}
|
|
2094
|
+
/**
|
|
2095
|
+
* The inputs every live proof over one commit window shares: the versions in (after, until]
|
|
2096
|
+
* that recorded a change to each table, and — filled in per table as proofs ask — the table
|
|
2097
|
+
* record, its segments committed in the window, and the versions those segments account
|
|
2098
|
+
* for. A sweep proves each subscription separately, and twenty subscriptions on one table
|
|
2099
|
+
* used to list its segments and transactions twenty times, a readonly transaction each on
|
|
2100
|
+
* IndexedDB and a cross-tab round trip each on an OPFS follower. A few recent windows stay
|
|
2101
|
+
* resident so concurrent sets sweeping different windows do not evict each other.
|
|
2102
|
+
*/
|
|
2103
|
+
async #liveProofContext(after, until) {
|
|
2104
|
+
const key = `${String(after)}:${String(until)}`;
|
|
2105
|
+
const cached = this.#liveProofContexts.get(key);
|
|
2106
|
+
if (cached !== undefined)
|
|
2107
|
+
return cached;
|
|
2108
|
+
const context = (async () => {
|
|
2109
|
+
// Proof requires every version to be accounted for by a surviving, inspected segment:
|
|
2110
|
+
// garbage collection deletes reclaimed segments outright, so "no segment in the window"
|
|
2111
|
+
// is absence of evidence, not evidence of neutrality.
|
|
2112
|
+
const changedVersions = new Map();
|
|
2113
|
+
let cursor = after;
|
|
2114
|
+
pages: for (;;) {
|
|
2115
|
+
const page = await this.store.listManifestPage(cursor, 64);
|
|
2116
|
+
for (const manifest of page.records) {
|
|
2117
|
+
if (manifest.version > until)
|
|
2118
|
+
break pages;
|
|
2119
|
+
for (const tableId of manifest.changedTableIds ?? []) {
|
|
2120
|
+
const versions = changedVersions.get(tableId) ?? new Set();
|
|
2121
|
+
versions.add(manifest.version);
|
|
2122
|
+
changedVersions.set(tableId, versions);
|
|
2123
|
+
}
|
|
2124
|
+
if (manifest.version === until)
|
|
2125
|
+
break pages;
|
|
2126
|
+
}
|
|
2127
|
+
if (page.nextCursor === null)
|
|
2128
|
+
break;
|
|
2129
|
+
cursor = page.nextCursor;
|
|
2130
|
+
}
|
|
2131
|
+
return { after, until, changedVersions, tables: new Map() };
|
|
2132
|
+
})();
|
|
2133
|
+
this.#liveProofContexts.set(key, context);
|
|
2134
|
+
if (this.#liveProofContexts.size > LIVE_PROOF_CONTEXT_LIMIT) {
|
|
2135
|
+
const oldest = this.#liveProofContexts.keys().next().value;
|
|
2136
|
+
if (oldest !== undefined)
|
|
2137
|
+
this.#liveProofContexts.delete(oldest);
|
|
2138
|
+
}
|
|
2139
|
+
return context;
|
|
2140
|
+
}
|
|
2141
|
+
#liveProofTable(context, tableId) {
|
|
2142
|
+
const cached = context.tables.get(tableId);
|
|
2143
|
+
if (cached !== undefined)
|
|
2144
|
+
return cached;
|
|
2145
|
+
const entry = (async () => {
|
|
2146
|
+
const table = await this.store.getTable(tableId);
|
|
2147
|
+
if (table === undefined)
|
|
2148
|
+
return undefined;
|
|
2149
|
+
const segments = await this.store.listSegments(tableId);
|
|
2150
|
+
const transactions = new Map((await this.#transactionRecordsForSegments(segments)).map((record) => [record.id, record]));
|
|
2151
|
+
const windowSegments = [];
|
|
2152
|
+
const coveredVersions = new Set();
|
|
2153
|
+
for (const segment of segments) {
|
|
2154
|
+
const committed = transactions.get(segment.transactionId)?.committedVersion ?? null;
|
|
2155
|
+
if (committed === null || committed <= (context.after ?? -1) || committed > context.until) {
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
coveredVersions.add(committed);
|
|
2159
|
+
windowSegments.push(segment);
|
|
2160
|
+
}
|
|
2161
|
+
return { table, windowSegments, coveredVersions };
|
|
2162
|
+
})();
|
|
2163
|
+
context.tables.set(tableId, entry);
|
|
2164
|
+
return entry;
|
|
2165
|
+
}
|
|
1894
2166
|
/**
|
|
1895
2167
|
* True when every manifest published in (after, until] changed no row in any table: the
|
|
1896
2168
|
* explicitly empty `changedTableIds` that compaction publishes through
|
|
@@ -1942,29 +2214,168 @@ export class MinnowDatabase {
|
|
|
1942
2214
|
#maybeScheduleAutoCompaction(table, segments) {
|
|
1943
2215
|
if (!this.#autoCompact)
|
|
1944
2216
|
return;
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
}).length;
|
|
1949
|
-
if (segments.length < AUTO_COMPACT_SCAN_SEGMENTS && deltas < AUTO_COMPACT_DELTA_SEGMENTS) {
|
|
2217
|
+
if (this.#droppingTables.has(table.id))
|
|
2218
|
+
return;
|
|
2219
|
+
if (!autoCompactionDue(segments))
|
|
1950
2220
|
return;
|
|
1951
|
-
}
|
|
1952
2221
|
if (segments.length < (this.#autoCompactionBackoff.get(table.id) ?? 0))
|
|
1953
2222
|
return;
|
|
1954
|
-
if (this.#autoCompactionsInFlight.has(table.id))
|
|
2223
|
+
if (this.#autoCompactionsInFlight.has(table.id)) {
|
|
2224
|
+
// A final burst can cross the threshold while the prior fold is still planning or
|
|
2225
|
+
// running. Remember it: otherwise no later commit or scan may arrive to trigger the fold
|
|
2226
|
+
// that the final state still needs.
|
|
2227
|
+
this.#autoCompactionsRequested.add(table.id);
|
|
1955
2228
|
return;
|
|
2229
|
+
}
|
|
1956
2230
|
this.#autoCompactionsInFlight.add(table.id);
|
|
1957
|
-
void this
|
|
1958
|
-
.then(() => {
|
|
1959
|
-
|
|
2231
|
+
void this.#runAutoCompaction(table)
|
|
2232
|
+
.then((folded) => {
|
|
2233
|
+
if (folded)
|
|
2234
|
+
this.#autoCompactionBackoff.delete(table.id);
|
|
2235
|
+
else {
|
|
2236
|
+
this.#autoCompactionBackoff.set(table.id, Math.min(AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS, Math.max(2, segments.length * 2)));
|
|
2237
|
+
}
|
|
1960
2238
|
})
|
|
1961
2239
|
.catch(() => {
|
|
1962
|
-
|
|
2240
|
+
// Back off deterministic failures, but never beyond the maximum L0 prefix a fold can
|
|
2241
|
+
// consume. A transient conflict near the end of a burst must not strand hundreds of
|
|
2242
|
+
// segments waiting for a segment count the idle database can never reach.
|
|
2243
|
+
this.#autoCompactionBackoff.set(table.id, Math.min(AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS, Math.max(2, segments.length * 2)));
|
|
1963
2244
|
})
|
|
1964
2245
|
.finally(() => {
|
|
1965
2246
|
this.#autoCompactionsInFlight.delete(table.id);
|
|
2247
|
+
if (this.#autoCompactionsRequested.delete(table.id)) {
|
|
2248
|
+
void yieldToEventLoop().then(() => this.#checkAutoCompaction(table.id));
|
|
2249
|
+
}
|
|
1966
2250
|
});
|
|
1967
2251
|
}
|
|
2252
|
+
/**
|
|
2253
|
+
* Plans a compaction job and drives it to publication in small steps, yielding to the event
|
|
2254
|
+
* loop between them so queries and writes interleave with the maintenance; then, while the
|
|
2255
|
+
* table is still due, plans the next. A job that only advanced when the next scan happened
|
|
2256
|
+
* to trigger it would sit half-written in an idle tab, its output staged and its sources
|
|
2257
|
+
* still read on every query, and the deltas that landed while it ran would wait for a scan
|
|
2258
|
+
* that may never come. Returns whether anything was folded: a table compaction cannot help
|
|
2259
|
+
* (an unsupported layout, keys living in published partitions) must not be re-planned on
|
|
2260
|
+
* every trigger, so the caller backs it off as it would a failure.
|
|
2261
|
+
*/
|
|
2262
|
+
async #runAutoCompaction(table) {
|
|
2263
|
+
const options = {
|
|
2264
|
+
maxBlocks: AUTO_COMPACT_STEP_BLOCKS,
|
|
2265
|
+
maxLevel0Segments: AUTO_COMPACT_MAX_LEVEL_ZERO_SEGMENTS,
|
|
2266
|
+
};
|
|
2267
|
+
let folded = false;
|
|
2268
|
+
for (;;) {
|
|
2269
|
+
if (this.#droppingTables.has(table.id))
|
|
2270
|
+
return folded;
|
|
2271
|
+
let progress = await this.compactTableStep(table.name, options);
|
|
2272
|
+
while (progress.result === null) {
|
|
2273
|
+
if (progress.jobId === null)
|
|
2274
|
+
throw new Error("Compaction progress lost its job ID");
|
|
2275
|
+
await yieldToEventLoop();
|
|
2276
|
+
progress = await this.resumeCompactionJob(progress.jobId, options);
|
|
2277
|
+
}
|
|
2278
|
+
if (!progress.result.compacted)
|
|
2279
|
+
return folded;
|
|
2280
|
+
folded = true;
|
|
2281
|
+
// The fold's sources are garbage now; collect before planning the next fold.
|
|
2282
|
+
this.#maybeScheduleAutoCollection();
|
|
2283
|
+
await yieldToEventLoop();
|
|
2284
|
+
const current = await this.store.getTable(table.id);
|
|
2285
|
+
if (current === undefined ||
|
|
2286
|
+
!autoCompactionDue(await this.#currentVisibleSegments(current))) {
|
|
2287
|
+
return folded;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
/** The table's visible segments at the current manifest. */
|
|
2292
|
+
async #currentVisibleSegments(table) {
|
|
2293
|
+
// This is an optimistic metadata read, not a user snapshot: taking a durable reader lease
|
|
2294
|
+
// here would add a readwrite transaction to whichever foreground write happened to trigger
|
|
2295
|
+
// maintenance. Verify the manifest did not move while its segment records were loaded; the
|
|
2296
|
+
// current manifest itself cannot be pruned, so a matching version is the same stability proof
|
|
2297
|
+
// without persistent state. Do not chase a busy writer forever: this probe is only a hint,
|
|
2298
|
+
// periodic checks keep arriving during the burst, and the quiet-tail check gets a stable view
|
|
2299
|
+
// once it ends.
|
|
2300
|
+
let segments = [];
|
|
2301
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2302
|
+
const manifest = await this.store.getCurrentManifest();
|
|
2303
|
+
segments = await this.#visibleSegmentRecords(table, new Snapshot(this.store, manifest?.version ?? null, manifest?.blockIds ?? []));
|
|
2304
|
+
if ((await this.store.getCurrentManifestVersion()) === (manifest?.version ?? null)) {
|
|
2305
|
+
return segments;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
return segments;
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* What every data commit shares: live sets learn of it, and the tables it changed count
|
|
2312
|
+
* toward their next write-path auto-compaction check.
|
|
2313
|
+
*/
|
|
2314
|
+
#afterCommit(manifest) {
|
|
2315
|
+
this.#notifyLiveCommit();
|
|
2316
|
+
this.#commitsSinceCollection += 1;
|
|
2317
|
+
const now = this.#now().getTime();
|
|
2318
|
+
if (this.#commitsSinceCollection >= AUTO_COLLECT_COMMIT_INTERVAL ||
|
|
2319
|
+
(this.#lastCollectionAt !== undefined &&
|
|
2320
|
+
now - this.#lastCollectionAt >= AUTO_COLLECT_QUIET_MS)) {
|
|
2321
|
+
this.#maybeScheduleAutoCollection();
|
|
2322
|
+
}
|
|
2323
|
+
this.#armIdleCollection();
|
|
2324
|
+
if (!this.#autoCompact)
|
|
2325
|
+
return;
|
|
2326
|
+
for (const tableId of manifest.changedTableIds ?? []) {
|
|
2327
|
+
this.#idleCompactionTableIds.add(tableId);
|
|
2328
|
+
const commits = (this.#commitsSinceCompactionCheck.get(tableId) ?? 0) + 1;
|
|
2329
|
+
if (commits < AUTO_COMPACT_COMMIT_CHECK_INTERVAL) {
|
|
2330
|
+
this.#commitsSinceCompactionCheck.set(tableId, commits);
|
|
2331
|
+
continue;
|
|
2332
|
+
}
|
|
2333
|
+
this.#commitsSinceCompactionCheck.delete(tableId);
|
|
2334
|
+
void this.#checkAutoCompaction(tableId);
|
|
2335
|
+
}
|
|
2336
|
+
this.#armIdleCompactionCheck();
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Debounces the final write-path check for a burst. Sampling every few commits keeps the hot
|
|
2340
|
+
* path cheap, but the last one through seven commits can be the ones that cross a fold
|
|
2341
|
+
* threshold. Without this check an idle table can remain due forever because no later write or
|
|
2342
|
+
* scan arrives to notice it.
|
|
2343
|
+
*/
|
|
2344
|
+
#armIdleCompactionCheck() {
|
|
2345
|
+
if (this.#idleCompactionTableIds.size === 0)
|
|
2346
|
+
return;
|
|
2347
|
+
if (this.#idleCompactionTimer !== undefined)
|
|
2348
|
+
clearTimeout(this.#idleCompactionTimer);
|
|
2349
|
+
const timer = setTimeout(() => {
|
|
2350
|
+
this.#idleCompactionTimer = undefined;
|
|
2351
|
+
const tableIds = [...this.#idleCompactionTableIds];
|
|
2352
|
+
this.#idleCompactionTableIds.clear();
|
|
2353
|
+
for (const tableId of tableIds) {
|
|
2354
|
+
this.#commitsSinceCompactionCheck.delete(tableId);
|
|
2355
|
+
void this.#checkAutoCompaction(tableId);
|
|
2356
|
+
}
|
|
2357
|
+
}, AUTO_COMPACT_IDLE_CHECK_MS);
|
|
2358
|
+
timer.unref?.();
|
|
2359
|
+
this.#idleCompactionTimer = timer;
|
|
2360
|
+
}
|
|
2361
|
+
/**
|
|
2362
|
+
* The write-path auto-compaction check: the table's visible segments at the current manifest,
|
|
2363
|
+
* judged by the same thresholds a streamed scan applies. Without it a write-heavy phase with
|
|
2364
|
+
* no reads in between piles deltas up unfolded, and the next query pays for all of them at
|
|
2365
|
+
* once. Background maintenance never surfaces through a write; a failed check waits for the
|
|
2366
|
+
* next one.
|
|
2367
|
+
*/
|
|
2368
|
+
async #checkAutoCompaction(tableId) {
|
|
2369
|
+
try {
|
|
2370
|
+
const table = await this.store.getTable(tableId);
|
|
2371
|
+
if (table === undefined)
|
|
2372
|
+
return;
|
|
2373
|
+
this.#maybeScheduleAutoCompaction(table, await this.#currentVisibleSegments(table));
|
|
2374
|
+
}
|
|
2375
|
+
catch {
|
|
2376
|
+
// Deliberately silent: the next commit or scan checks again.
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
1968
2379
|
/**
|
|
1969
2380
|
* Persists one AFTER trigger on its table record (compare-and-swap with retry, like
|
|
1970
2381
|
* migration). Validation is CREATE-time so firing can trust the record: events bind only
|
|
@@ -2429,7 +2840,25 @@ export class MinnowDatabase {
|
|
|
2429
2840
|
open();
|
|
2430
2841
|
return this.#sessionQuery(transaction, sql, options);
|
|
2431
2842
|
},
|
|
2432
|
-
|
|
2843
|
+
execute: async (sql, params) => {
|
|
2844
|
+
open();
|
|
2845
|
+
const compiled = compileStatement(sql);
|
|
2846
|
+
if (compiled.kind === "select") {
|
|
2847
|
+
return {
|
|
2848
|
+
kind: "rows",
|
|
2849
|
+
result: await this.#sessionQuery(transaction, compiled.sql, params === undefined ? {} : { params }),
|
|
2850
|
+
};
|
|
2851
|
+
}
|
|
2852
|
+
const statement = bindStatementParameters(compiled, params);
|
|
2853
|
+
if (!isTransactionalStatement(statement)) {
|
|
2854
|
+
throw new TypeError(`${statement.kind.toUpperCase().replace("-", " ")} is not allowed inside a write scope`);
|
|
2855
|
+
}
|
|
2856
|
+
// Guard the whole SQL statement as one stage. Some INSERT forms perform more than one
|
|
2857
|
+
// batch operation; if a later step fails after an earlier one staged work, the caller
|
|
2858
|
+
// must not be able to catch the error and commit only part of the statement.
|
|
2859
|
+
return writer.executeStatement(statement);
|
|
2860
|
+
},
|
|
2861
|
+
insertBatch: async (tableName, input) => {
|
|
2433
2862
|
open();
|
|
2434
2863
|
staged += 1;
|
|
2435
2864
|
return guarded(() => this.#sessionInsert(transaction, tableName, input, "insert"));
|
|
@@ -2450,8 +2879,16 @@ export class MinnowDatabase {
|
|
|
2450
2879
|
return guarded(() => this.#sessionDelete(transaction, tableName, input));
|
|
2451
2880
|
},
|
|
2452
2881
|
};
|
|
2882
|
+
const writer = {
|
|
2883
|
+
...session,
|
|
2884
|
+
queryPlan: (plan) => this.#sessionQueryPlan(transaction, plan),
|
|
2885
|
+
executeStatement: (statement) => {
|
|
2886
|
+
open();
|
|
2887
|
+
return guarded(() => this.runStatement(statement, { writer }));
|
|
2888
|
+
},
|
|
2889
|
+
};
|
|
2453
2890
|
try {
|
|
2454
|
-
const result = await action(session, transaction);
|
|
2891
|
+
const result = await action(session, transaction, writer);
|
|
2455
2892
|
closed = true;
|
|
2456
2893
|
if (poisoned !== undefined) {
|
|
2457
2894
|
// The outer catch aborts the transaction.
|
|
@@ -2464,7 +2901,7 @@ export class MinnowDatabase {
|
|
|
2464
2901
|
for (let attempt = 0; attempt <= this.#maxCommitRetries; attempt += 1) {
|
|
2465
2902
|
try {
|
|
2466
2903
|
const manifest = await transaction.commit();
|
|
2467
|
-
this.#
|
|
2904
|
+
this.#afterCommit(manifest);
|
|
2468
2905
|
return { result, version: manifest.version };
|
|
2469
2906
|
}
|
|
2470
2907
|
catch (error) {
|
|
@@ -2600,7 +3037,7 @@ export class MinnowDatabase {
|
|
|
2600
3037
|
}
|
|
2601
3038
|
async #sessionInsert(transaction, tableName, input, kind, cascadeBudget = 1) {
|
|
2602
3039
|
const table = await this.#findTable(tableName);
|
|
2603
|
-
const { batch, autoIncrement, rowCount } = this.#fillDefaults(table, input);
|
|
3040
|
+
const { batch, generated, autoIncrement, rowCount } = this.#fillDefaults(table, input);
|
|
2604
3041
|
await this.#assertForeignKeysPresent(table, (column) => batch.columns[column] ?? [], (sql, params) => this.#sessionQuery(transaction, sql, { params }), transaction);
|
|
2605
3042
|
if (autoIncrement !== undefined && autoIncrement.missingIndexes.length > 0) {
|
|
2606
3043
|
const values = await this.store.reserveAutoIncrement(table.id, autoIncrement.column.id, autoIncrement.missingIndexes.length, autoIncrement.atLeast);
|
|
@@ -2616,7 +3053,6 @@ export class MinnowDatabase {
|
|
|
2616
3053
|
tableId: table.id,
|
|
2617
3054
|
keyTokens: [...keys.keys()],
|
|
2618
3055
|
requireAbsent: kind === "insert",
|
|
2619
|
-
...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
|
|
2620
3056
|
});
|
|
2621
3057
|
}
|
|
2622
3058
|
const rowIds = await this.store.reserveRowIds(table.id, rowCount);
|
|
@@ -2646,7 +3082,13 @@ export class MinnowDatabase {
|
|
|
2646
3082
|
else if (sessionUpsertFirings !== undefined) {
|
|
2647
3083
|
await this.#stageUpsertTriggerFirings(transaction, table, batch, sessionUpsertFirings, "after", cascadeBudget);
|
|
2648
3084
|
}
|
|
2649
|
-
|
|
3085
|
+
collectAutoIncrementGenerated(batch, generated, autoIncrement);
|
|
3086
|
+
return {
|
|
3087
|
+
tableName: table.name,
|
|
3088
|
+
segmentId,
|
|
3089
|
+
rowCount,
|
|
3090
|
+
...(generated.size === 0 ? {} : { generatedColumns: Object.fromEntries(generated) }),
|
|
3091
|
+
};
|
|
2650
3092
|
}
|
|
2651
3093
|
async #sessionUpdate(transaction, tableName, input, cascadeBudget = 1) {
|
|
2652
3094
|
const table = await this.#findTable(tableName);
|
|
@@ -2765,7 +3207,6 @@ export class MinnowDatabase {
|
|
|
2765
3207
|
keyTokens: [...keys.keys()],
|
|
2766
3208
|
requireAbsent: false,
|
|
2767
3209
|
remove: true,
|
|
2768
|
-
...(table.uniqueKeyStorage === undefined ? {} : { storageMode: table.uniqueKeyStorage }),
|
|
2769
3210
|
});
|
|
2770
3211
|
// Fire only per existing row (session-visible state included): missing keys must not
|
|
2771
3212
|
// produce phantom all-null OLD images.
|
|
@@ -2815,7 +3256,14 @@ export class MinnowDatabase {
|
|
|
2815
3256
|
}
|
|
2816
3257
|
/** Executes a built ORM query through the same streaming-first pipeline as compiled SQL. */
|
|
2817
3258
|
async run(query) {
|
|
2818
|
-
|
|
3259
|
+
const probe = this.store.getCatalogProbe?.bind(this.store);
|
|
3260
|
+
// The same memo a SQL query gets, keyed by the plan: a typed query is compiled once by the
|
|
3261
|
+
// builder and run many times, and it used to re-execute on every run.
|
|
3262
|
+
if (probe === undefined || query.plan.usesStatementDatetime === true) {
|
|
3263
|
+
return (await this.#queryCompiled(query.plan)).rows;
|
|
3264
|
+
}
|
|
3265
|
+
return (await this.#memoizedQuery(query.plan, `typed ${planMemoKey(query.plan)}`, {}, probe))
|
|
3266
|
+
.rows;
|
|
2819
3267
|
}
|
|
2820
3268
|
/**
|
|
2821
3269
|
* Applies a schema definition to the catalog through metadata-only steps: creating missing
|
|
@@ -3209,10 +3657,11 @@ export class MinnowDatabase {
|
|
|
3209
3657
|
// together or not at all. Schema changes are refused rather than silently auto-committed:
|
|
3210
3658
|
// the catalog is not part of the scope, so a DDL statement inside one would land even if
|
|
3211
3659
|
// the transaction rolled back.
|
|
3212
|
-
|
|
3213
|
-
|
|
3660
|
+
const boundStatement = bindStatementParameters(statement, params);
|
|
3661
|
+
if (!isTransactionalStatement(boundStatement)) {
|
|
3662
|
+
throw new TypeError(`${boundStatement.kind.toUpperCase().replace("-", " ")} is not allowed inside a transaction`);
|
|
3214
3663
|
}
|
|
3215
|
-
return this.#duringTransaction(open, () =>
|
|
3664
|
+
return this.#duringTransaction(open, () => open.session.executeStatement(boundStatement));
|
|
3216
3665
|
}
|
|
3217
3666
|
return this.runStatement(bindStatementParameters(statement, params));
|
|
3218
3667
|
}
|
|
@@ -3367,11 +3816,8 @@ export class MinnowDatabase {
|
|
|
3367
3816
|
const decided = new Promise((resolve) => {
|
|
3368
3817
|
settle = resolve;
|
|
3369
3818
|
});
|
|
3370
|
-
const finished = this.#openWriteScope(async (
|
|
3371
|
-
start(
|
|
3372
|
-
...session,
|
|
3373
|
-
queryPlan: (plan) => this.#sessionQueryPlan(transaction, plan),
|
|
3374
|
-
});
|
|
3819
|
+
const finished = this.#openWriteScope(async (_session, _transaction, writer) => {
|
|
3820
|
+
start(writer);
|
|
3375
3821
|
if ((await decided) === "rollback")
|
|
3376
3822
|
throw new TransactionRollback();
|
|
3377
3823
|
return null;
|
|
@@ -3455,7 +3901,7 @@ export class MinnowDatabase {
|
|
|
3455
3901
|
}
|
|
3456
3902
|
const assigned = statement.onConflict.columns ?? [];
|
|
3457
3903
|
const keyToken = (value) => value instanceof Date ? `d${value.toISOString()}` : `${typeof value} ${String(value)}`;
|
|
3458
|
-
const
|
|
3904
|
+
const apply = async (transaction) => {
|
|
3459
3905
|
const existing = await this.#existingInsertKeys(table, keyColumn, statement, (sql, params) => transaction.query(sql, { params }));
|
|
3460
3906
|
const freshRows = [];
|
|
3461
3907
|
const conflictingRows = [];
|
|
@@ -3506,12 +3952,22 @@ export class MinnowDatabase {
|
|
|
3506
3952
|
const row = rowsByKey.get(keyToken(key)) ?? {};
|
|
3507
3953
|
return Object.fromEntries(returningColumns.map((name) => [name, row[name] ?? null]));
|
|
3508
3954
|
});
|
|
3509
|
-
}
|
|
3955
|
+
};
|
|
3956
|
+
let returnedRows;
|
|
3957
|
+
let version;
|
|
3958
|
+
if (options.writer === undefined) {
|
|
3959
|
+
const completed = await this.write(apply);
|
|
3960
|
+
returnedRows = completed.result;
|
|
3961
|
+
version = completed.version;
|
|
3962
|
+
}
|
|
3963
|
+
else {
|
|
3964
|
+
returnedRows = await apply(options.writer);
|
|
3965
|
+
}
|
|
3510
3966
|
return {
|
|
3511
3967
|
kind: "insert",
|
|
3512
3968
|
table: statement.table,
|
|
3513
3969
|
rowCount: statement.rows.length,
|
|
3514
|
-
...(version === null ? {} : { version }),
|
|
3970
|
+
...(version === null || version === undefined ? {} : { version }),
|
|
3515
3971
|
...(returnedRows === undefined ? {} : { returnedRows }),
|
|
3516
3972
|
};
|
|
3517
3973
|
}
|
|
@@ -3528,7 +3984,7 @@ export class MinnowDatabase {
|
|
|
3528
3984
|
return new Set(result.rows.map((row) => keyToken(row.key ?? null)));
|
|
3529
3985
|
}
|
|
3530
3986
|
/** ON CONFLICT DO NOTHING: drops insert rows whose unique key already exists at a snapshot. */
|
|
3531
|
-
async #filterConflictingInsertRows(statement) {
|
|
3987
|
+
async #filterConflictingInsertRows(statement, writer) {
|
|
3532
3988
|
const table = await this.#findTable(statement.table);
|
|
3533
3989
|
const keyColumn = getUniqueKeyColumn(table);
|
|
3534
3990
|
if (keyColumn === undefined || statement.onConflict?.column !== keyColumn.name) {
|
|
@@ -3554,17 +4010,13 @@ export class MinnowDatabase {
|
|
|
3554
4010
|
having: [],
|
|
3555
4011
|
orderBy: [],
|
|
3556
4012
|
};
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
}
|
|
3565
|
-
finally {
|
|
3566
|
-
prepared.close();
|
|
3567
|
-
}
|
|
4013
|
+
// The streaming-first pipeline: a keyed IN list narrows to the blocks that can hold the
|
|
4014
|
+
// keys, where the prepared path materialized the table's columns first.
|
|
4015
|
+
const existingRows = writer === undefined ? await this.#queryCompiled(plan) : await writer.queryPlan(plan);
|
|
4016
|
+
const existing = new Set(existingRows.rows.map((row) => {
|
|
4017
|
+
const value = row.key ?? null;
|
|
4018
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
4019
|
+
}));
|
|
3568
4020
|
return {
|
|
3569
4021
|
...statement,
|
|
3570
4022
|
rows: statement.rows.filter((row) => {
|
|
@@ -3657,16 +4109,22 @@ export class MinnowDatabase {
|
|
|
3657
4109
|
...(version === null ? {} : { version }),
|
|
3658
4110
|
};
|
|
3659
4111
|
}
|
|
3660
|
-
async #materializeInsertSelect(statement) {
|
|
4112
|
+
async #materializeInsertSelect(statement, writer) {
|
|
3661
4113
|
if (statement.query === undefined)
|
|
3662
4114
|
return statement;
|
|
3663
|
-
const prepared = await this.#prepareCompiledPlan(statement.query);
|
|
3664
4115
|
let result;
|
|
3665
|
-
|
|
3666
|
-
|
|
4116
|
+
if (writer === undefined) {
|
|
4117
|
+
const prepared = await this.#prepareCompiledPlan(statement.query);
|
|
4118
|
+
try {
|
|
4119
|
+
result = prepared.execute();
|
|
4120
|
+
}
|
|
4121
|
+
finally {
|
|
4122
|
+
prepared.close();
|
|
4123
|
+
}
|
|
3667
4124
|
}
|
|
3668
|
-
|
|
3669
|
-
|
|
4125
|
+
else {
|
|
4126
|
+
const plan = await this.#applyCatalogRewrites(statement.query);
|
|
4127
|
+
result = await writer.queryPlan(plan);
|
|
3670
4128
|
}
|
|
3671
4129
|
const { query, ...rest } = statement;
|
|
3672
4130
|
void query;
|
|
@@ -3789,10 +4247,10 @@ export class MinnowDatabase {
|
|
|
3789
4247
|
options = { ...options, returning: statement.returning };
|
|
3790
4248
|
}
|
|
3791
4249
|
if (statement.kind === "insert" && statement.query !== undefined) {
|
|
3792
|
-
statement = await this.#materializeInsertSelect(statement);
|
|
4250
|
+
statement = await this.#materializeInsertSelect(statement, options.writer);
|
|
3793
4251
|
}
|
|
3794
4252
|
if (statement.kind === "insert" && statement.onConflict?.action === "nothing") {
|
|
3795
|
-
statement = await this.#filterConflictingInsertRows(statement);
|
|
4253
|
+
statement = await this.#filterConflictingInsertRows(statement, options.writer);
|
|
3796
4254
|
}
|
|
3797
4255
|
if (statement.kind === "insert" && statement.onConflict?.action === "update") {
|
|
3798
4256
|
return this.#mergeConflictingInsertRows(statement, options);
|
|
@@ -3907,13 +4365,10 @@ export class MinnowDatabase {
|
|
|
3907
4365
|
rows = (await options.writer.queryPlan(plan)).rows;
|
|
3908
4366
|
}
|
|
3909
4367
|
else {
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
finally {
|
|
3915
|
-
prepared.close();
|
|
3916
|
-
}
|
|
4368
|
+
// The same streaming-first pipeline a SELECT takes: its zone pruning and ascending-range
|
|
4369
|
+
// narrowing find the rows to touch, where the prepared path materialized the table's
|
|
4370
|
+
// columns first — most of a bulk delete's cost, at 200k rows.
|
|
4371
|
+
rows = (await this.#queryCompiled(plan)).rows;
|
|
3917
4372
|
}
|
|
3918
4373
|
const keys = rows.map((row) => row[keyColumn.name]);
|
|
3919
4374
|
if (keys.some((key) => key === null || key === undefined)) {
|
|
@@ -4011,13 +4466,13 @@ export class MinnowDatabase {
|
|
|
4011
4466
|
* Returns undefined when the base table's visible shape is ineligible (keyed mutation replay),
|
|
4012
4467
|
* so the caller falls back to the materialized path.
|
|
4013
4468
|
*/
|
|
4014
|
-
async #queryStreamed(plan, options, spillPageRows) {
|
|
4469
|
+
async #queryStreamed(plan, options, spillPageRows, probe) {
|
|
4015
4470
|
const tableNames = [plan.base.table, ...plan.joins.map((join) => join.table)];
|
|
4016
4471
|
const uniqueTableNames = [...new Set(tableNames)];
|
|
4017
4472
|
if (options.version === undefined) {
|
|
4018
4473
|
// The common path shares the probe-gated catalog state and the shared reader lease
|
|
4019
4474
|
// with every other statement at the current version.
|
|
4020
|
-
return this.#withSharedCatalogSnapshot(uniqueTableNames, (snapshot, realTables, visibility) => this.#queryStreamedAtSnapshot(plan, options, spillPageRows, snapshot, [...realTables.values()], visibility));
|
|
4475
|
+
return this.#withSharedCatalogSnapshot(uniqueTableNames, (snapshot, realTables, visibility) => this.#queryStreamedAtSnapshot(plan, options, spillPageRows, snapshot, [...realTables.values()], visibility), probe);
|
|
4021
4476
|
}
|
|
4022
4477
|
// Explicit time travel keeps the per-call lease and version-anchored reads.
|
|
4023
4478
|
const tables = await Promise.all(uniqueTableNames.map((name) => this.#findTable(name)));
|
|
@@ -4620,19 +5075,6 @@ export class MinnowDatabase {
|
|
|
4620
5075
|
load,
|
|
4621
5076
|
};
|
|
4622
5077
|
}
|
|
4623
|
-
/**
|
|
4624
|
-
* Builds a streamed view of a keyed table whose visible history contains update and delete
|
|
4625
|
-
* segments (no upserts — those interleave new rows into slot order and keep the materialized
|
|
4626
|
-
* path). Mutation deltas are the small part of such a history, so they replay into resident
|
|
4627
|
-
* state — a dead-row bitmap over the base rows plus per-slot column patches referencing the
|
|
4628
|
-
* resident update vectors — while the base rows stream through the existing block-aligned
|
|
4629
|
-
* inner window. The outer view compacts dead rows and overlays patches per window, producing
|
|
4630
|
-
* exactly the materialized replay's rows in exactly its order.
|
|
4631
|
-
*
|
|
4632
|
-
* The replay tracks only mutation-touched key tokens, so its memory is bounded by the
|
|
4633
|
-
* mutation size, not the table; the duplicate-key corruption guard consequently only fires
|
|
4634
|
-
* for touched keys on this path.
|
|
4635
|
-
*/
|
|
4636
5078
|
/**
|
|
4637
5079
|
* Block header/metadata descriptions, cached and fetched in one round trip: no decompress and
|
|
4638
5080
|
* no payload validation. Descriptions are immutable per block id, so a repeated query pays
|
|
@@ -4664,24 +5106,232 @@ export class MinnowDatabase {
|
|
|
4664
5106
|
}
|
|
4665
5107
|
return descriptions;
|
|
4666
5108
|
}
|
|
5109
|
+
/**
|
|
5110
|
+
* Builds a streamed view of a keyed table whose visible history contains update and delete
|
|
5111
|
+
* segments (no upserts — those interleave new rows into slot order and keep the materialized
|
|
5112
|
+
* path). Mutation deltas are the small part of such a history, so they replay into resident
|
|
5113
|
+
* state — a dead-row bitmap over the base rows plus per-slot column patches referencing the
|
|
5114
|
+
* resident update vectors — while the base rows stream through the block-aligned inner
|
|
5115
|
+
* window. The outer view compacts dead rows and overlays patches per window, producing
|
|
5116
|
+
* exactly the materialized replay's rows in exactly its order.
|
|
5117
|
+
*
|
|
5118
|
+
* The replay is a pure function of the visible segment set, so it is built once per commit
|
|
5119
|
+
* and shared by every query until the next one (`#streamedOverlayState`). The outer loader
|
|
5120
|
+
* serves whole inner windows: one whose rows nothing touched is installed by reference, the
|
|
5121
|
+
* difference between a copy per window and none; one with a dead or patched row is compacted
|
|
5122
|
+
* once, in runs rather than cells.
|
|
5123
|
+
*
|
|
5124
|
+
* The replay tracks only mutation-touched keys, so its memory is bounded by the mutation
|
|
5125
|
+
* size, not the table; the duplicate-key corruption guard consequently only fires for
|
|
5126
|
+
* touched keys on this path.
|
|
5127
|
+
*/
|
|
4667
5128
|
async #createStreamedMutationTable(table, keyColumn, projectedColumns, baseSegments, snapshot, memory, zonePruned = false, storedBlocks) {
|
|
4668
5129
|
const scanSegments = baseSegments.filter((segment) => {
|
|
4669
5130
|
const kind = segment.kind ?? "insert";
|
|
4670
5131
|
return kind === "insert" || kind === "base";
|
|
4671
5132
|
});
|
|
4672
|
-
|
|
4673
|
-
|
|
5133
|
+
const overlay = await this.#streamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned);
|
|
5134
|
+
const { baseRows, dead, deadCount, patches, patchedSlots } = overlay;
|
|
5135
|
+
const hasPatches = patchedSlots.length > 0;
|
|
5136
|
+
const outputRows = baseRows - deadCount;
|
|
5137
|
+
const inner = this.#createStreamedTable(table, projectedColumns, scanSegments, snapshot, baseRows, memory, storedBlocks);
|
|
5138
|
+
// Deltas that touch no row this scan reads — every one of them eliminated with its row
|
|
5139
|
+
// group, or aimed at keys this table no longer holds — leave the scan exactly as it was.
|
|
5140
|
+
if (deadCount === 0 && !hasPatches)
|
|
5141
|
+
return inner;
|
|
5142
|
+
const states = projectedColumns.map((column) => ({
|
|
5143
|
+
column,
|
|
5144
|
+
vector: createStreamedColumnVector(column.type, outputRows),
|
|
5145
|
+
reservations: [],
|
|
5146
|
+
}));
|
|
5147
|
+
// Forward-only cursor: cursorOutput live rows exist strictly before base row cursorBase.
|
|
5148
|
+
let cursorOutput = 0;
|
|
5149
|
+
let cursorBase = 0;
|
|
5150
|
+
const load = async (start, length) => {
|
|
5151
|
+
const end = Math.min(start + length, outputRows);
|
|
5152
|
+
// COUNT(*) and friends project nothing: the replay already knows how many rows survive,
|
|
5153
|
+
// so there is no window to build and no reason to walk the base rows to build it.
|
|
5154
|
+
if (states.length === 0)
|
|
5155
|
+
return end;
|
|
5156
|
+
const window = states[0]?.vector.window;
|
|
5157
|
+
if (window !== undefined && start >= window.start && start < window.start + window.length) {
|
|
5158
|
+
return window.start + window.length;
|
|
5159
|
+
}
|
|
5160
|
+
if (window !== undefined && start < window.start) {
|
|
5161
|
+
throw new Error(`Streamed scan moved backward: ${table.name}`);
|
|
5162
|
+
}
|
|
5163
|
+
// The cursor stops at the end of the window it last served, which can sit past a start
|
|
5164
|
+
// that falls before it. Rewinding costs one pass over the dead-row bitmap.
|
|
5165
|
+
if (cursorOutput > start) {
|
|
5166
|
+
cursorOutput = 0;
|
|
5167
|
+
cursorBase = 0;
|
|
5168
|
+
}
|
|
5169
|
+
while (cursorOutput < start && cursorBase < baseRows) {
|
|
5170
|
+
if (!bitmapHasValue(dead, cursorBase))
|
|
5171
|
+
cursorOutput += 1;
|
|
5172
|
+
cursorBase += 1;
|
|
5173
|
+
}
|
|
5174
|
+
// Skip the dead rows in front of the first live one, so a window never starts dead.
|
|
5175
|
+
while (cursorBase < baseRows && bitmapHasValue(dead, cursorBase))
|
|
5176
|
+
cursorBase += 1;
|
|
5177
|
+
if (cursorOutput !== start || cursorBase >= baseRows) {
|
|
5178
|
+
throw new Error(`Column row count mismatch: ${table.name}`);
|
|
5179
|
+
}
|
|
5180
|
+
const baseStart = cursorBase;
|
|
5181
|
+
// The inner loader serves whole blocks; the outer window covers the suffix of the inner
|
|
5182
|
+
// window from baseStart, however long, and the caller clamps to what it asked for.
|
|
5183
|
+
const innerEnd = await inner.load(baseStart, baseRows - baseStart);
|
|
5184
|
+
const baseEnd = typeof innerEnd === "number" ? Math.min(innerEnd, baseRows) : baseRows;
|
|
5185
|
+
if (baseEnd <= baseStart)
|
|
5186
|
+
throw new Error(`Column row count mismatch: ${table.name}`);
|
|
5187
|
+
const deadInWindow = bitmapCountRange(dead, baseStart, baseEnd);
|
|
5188
|
+
const patchedInWindow = hasPatches ? sortedCountRange(patchedSlots, baseStart, baseEnd) : 0;
|
|
5189
|
+
const liveRows = baseEnd - baseStart - deadInWindow;
|
|
5190
|
+
const untouched = deadInWindow === 0 && patchedInWindow === 0;
|
|
5191
|
+
const runs = untouched
|
|
5192
|
+
? undefined
|
|
5193
|
+
: overlayWindowRuns(dead, patchedSlots, baseStart, baseEnd, patchedInWindow);
|
|
5194
|
+
const targets = [];
|
|
5195
|
+
try {
|
|
5196
|
+
for (const state of states) {
|
|
5197
|
+
const innerVector = inner.table.columns.get(state.column.name);
|
|
5198
|
+
const innerWindow = innerVector?.window;
|
|
5199
|
+
if (innerVector === undefined || innerWindow === undefined) {
|
|
5200
|
+
throw new Error(`Streamed column is missing: ${state.column.name}`);
|
|
5201
|
+
}
|
|
5202
|
+
const offset = baseStart - innerWindow.start;
|
|
5203
|
+
if (offset < 0 || offset + (baseEnd - baseStart) > innerWindow.length) {
|
|
5204
|
+
throw new Error(`Column row count mismatch: ${state.column.name}`);
|
|
5205
|
+
}
|
|
5206
|
+
const replacements = [];
|
|
5207
|
+
const fields = runs === undefined
|
|
5208
|
+
? overlayWindowView(innerVector, offset, liveRows, memory, state.column, replacements)
|
|
5209
|
+
: overlayWindowCompacted(innerVector, innerWindow.start, runs, liveRows, hasPatches ? patches : undefined, state.column, memory, replacements);
|
|
5210
|
+
fields.window = { start, length: liveRows };
|
|
5211
|
+
targets.push({ state, fields, replacements });
|
|
5212
|
+
}
|
|
5213
|
+
// Every fallible byte is reserved above; the installs below cannot throw, so a budget
|
|
5214
|
+
// overflow leaves every state's previous window and reservations intact.
|
|
5215
|
+
for (const { state, fields, replacements } of targets) {
|
|
5216
|
+
const mutable = state.vector;
|
|
5217
|
+
mutable.validity = fields.validity;
|
|
5218
|
+
if (fields.values !== undefined)
|
|
5219
|
+
mutable.values = fields.values;
|
|
5220
|
+
if (fields.codes !== undefined) {
|
|
5221
|
+
mutable.codes = fields.codes;
|
|
5222
|
+
mutable.dictionary = fields.dictionary ?? [];
|
|
5223
|
+
}
|
|
5224
|
+
mutable.window = fields.window;
|
|
5225
|
+
for (const previous of state.reservations)
|
|
5226
|
+
previous.release();
|
|
5227
|
+
state.reservations = replacements;
|
|
5228
|
+
}
|
|
5229
|
+
}
|
|
5230
|
+
catch (error) {
|
|
5231
|
+
for (const entry of targets) {
|
|
5232
|
+
for (const replacement of entry.replacements)
|
|
5233
|
+
replacement.release();
|
|
5234
|
+
}
|
|
5235
|
+
throw error;
|
|
5236
|
+
}
|
|
5237
|
+
cursorOutput = start + liveRows;
|
|
5238
|
+
cursorBase = baseEnd;
|
|
5239
|
+
return start + liveRows;
|
|
5240
|
+
};
|
|
5241
|
+
return {
|
|
5242
|
+
table: {
|
|
5243
|
+
name: table.name,
|
|
5244
|
+
rowCount: outputRows,
|
|
5245
|
+
columns: new Map(states.map((state) => [state.column.name, state.vector])),
|
|
5246
|
+
},
|
|
5247
|
+
load,
|
|
5248
|
+
};
|
|
5249
|
+
}
|
|
5250
|
+
/**
|
|
5251
|
+
* The replayed mutation state for one visible segment set: which base rows are dead, and
|
|
5252
|
+
* which columns of which slots an update replaced. Cached under the segment ids in the
|
|
5253
|
+
* artifact LRU, because nothing about it changes between commits — before this, every query
|
|
5254
|
+
* over a table with so much as one deleted row rebuilt it, which made COUNT(*) on such a
|
|
5255
|
+
* table cost twenty times what it costs on a clean one. A cache hit is charged to the
|
|
5256
|
+
* query's memory as a tally, the same bytes a build reserves.
|
|
5257
|
+
*/
|
|
5258
|
+
async #streamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned) {
|
|
5259
|
+
// A zone-pruned scan keeps a segment's id with a subset of its blocks, and the slots the
|
|
5260
|
+
// replay addresses are the key blocks' rows in order — so the key blocks, not the segment
|
|
5261
|
+
// ids alone, are what identify the state.
|
|
5262
|
+
const key = [
|
|
5263
|
+
"overlay",
|
|
5264
|
+
table.id,
|
|
5265
|
+
zonePruned ? "pruned" : "full",
|
|
5266
|
+
baseSegments
|
|
5267
|
+
.map((segment) => mutationSegmentKind(segment)
|
|
5268
|
+
? segment.id
|
|
5269
|
+
: `${segment.id}:${(segment.columnBlockIds[keyColumn.id] ?? []).join("+")}`)
|
|
5270
|
+
.join(","),
|
|
5271
|
+
].join(" ");
|
|
5272
|
+
const cached = this.#cacheGet(key);
|
|
5273
|
+
if (cached !== undefined) {
|
|
5274
|
+
memory.tally(cached.bytes, "Streamed mutation replay");
|
|
5275
|
+
return cached;
|
|
5276
|
+
}
|
|
5277
|
+
const state = await this.#buildStreamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned);
|
|
5278
|
+
this.#cachePut(key, state, state.bytes);
|
|
5279
|
+
return state;
|
|
5280
|
+
}
|
|
5281
|
+
async #buildStreamedOverlayState(table, keyColumn, baseSegments, scanSegments, snapshot, memory, zonePruned) {
|
|
5282
|
+
// Phase A: each mutation segment's key vector, and every column an update changed —
|
|
5283
|
+
// resident and reserved, bounded by the mutation history's size. All of an update's
|
|
5284
|
+
// columns, not only the ones this query projects: the state outlives the query. The
|
|
5285
|
+
// history's blocks come out of the buffer pool in one round trip, as block vectors: one
|
|
5286
|
+
// await per segment is what made a table with a few hundred deltas pay ten milliseconds
|
|
5287
|
+
// to rebuild this state after every commit.
|
|
5288
|
+
const deltaSegments = baseSegments.filter(mutationSegmentKind);
|
|
5289
|
+
const deltaBlockIds = new Set();
|
|
5290
|
+
for (const segment of deltaSegments) {
|
|
5291
|
+
for (const column of table.columns) {
|
|
5292
|
+
for (const blockId of segment.columnBlockIds[column.id] ?? [])
|
|
5293
|
+
deltaBlockIds.add(blockId);
|
|
5294
|
+
}
|
|
5295
|
+
}
|
|
5296
|
+
const decodedDeltaBlocks = new Map();
|
|
5297
|
+
if (deltaBlockIds.size > 0) {
|
|
5298
|
+
const ids = [...deltaBlockIds];
|
|
5299
|
+
const decoded = await this.#decodedBlocksThroughCache(ids, snapshot);
|
|
5300
|
+
ids.forEach((id, index) => {
|
|
5301
|
+
const block = decoded[index];
|
|
5302
|
+
if (block !== undefined)
|
|
5303
|
+
decodedDeltaBlocks.set(id, block);
|
|
5304
|
+
});
|
|
5305
|
+
}
|
|
5306
|
+
const deltaVector = async (column, segment) => {
|
|
5307
|
+
const blockIds = segment.columnBlockIds[column.id] ?? [];
|
|
5308
|
+
const blockId = blockIds[0];
|
|
5309
|
+
if (blockIds.length !== 1 || blockId === undefined) {
|
|
5310
|
+
// A delta written in more than one block — a bulk update past rowsPerBlock — concatenates.
|
|
5311
|
+
return this.#materializeAppendColumnVector(column, [segment], snapshot, segment.rowCount);
|
|
5312
|
+
}
|
|
5313
|
+
const decoded = decodedDeltaBlocks.get(blockId);
|
|
5314
|
+
if (decoded === undefined)
|
|
5315
|
+
throw new Error(`Visible block is missing: ${blockId}`);
|
|
5316
|
+
if (decoded.column.type !== column.type) {
|
|
5317
|
+
throw new Error(`Column type mismatch: ${column.name}`);
|
|
5318
|
+
}
|
|
5319
|
+
const vector = this.#blockColumnVector(blockId, decoded);
|
|
5320
|
+
if (vector.length !== segment.rowCount) {
|
|
5321
|
+
throw new Error(`Column row count mismatch: ${column.name}`);
|
|
5322
|
+
}
|
|
5323
|
+
return vector;
|
|
5324
|
+
};
|
|
4674
5325
|
const mutationKeyVectors = new Map();
|
|
4675
5326
|
const mutationChangedVectors = new Map();
|
|
4676
5327
|
// Keys as the primitives the vectors already hold. The replay used to build one string
|
|
4677
5328
|
// token per row on both sides, which made a single deleted row cost an allocation and a
|
|
4678
5329
|
// hash of every key in the table.
|
|
4679
5330
|
const touched = new Set();
|
|
4680
|
-
|
|
5331
|
+
let retainedBytes = 0;
|
|
5332
|
+
for (const segment of deltaSegments) {
|
|
4681
5333
|
const kind = segment.kind ?? "insert";
|
|
4682
|
-
|
|
4683
|
-
continue;
|
|
4684
|
-
const keyVector = await this.#materializeAppendColumnVector(keyColumn, [segment], snapshot, segment.rowCount);
|
|
5334
|
+
const keyVector = await deltaVector(keyColumn, segment);
|
|
4685
5335
|
memory.reserve(columnVectorRetainedBytes(keyVector), "Streamed mutation replay");
|
|
4686
5336
|
mutationKeyVectors.set(segment.id, keyVector);
|
|
4687
5337
|
const readMutationKey = requiredColumnVectorKeyReader(keyVector);
|
|
@@ -4690,20 +5340,22 @@ export class MinnowDatabase {
|
|
|
4690
5340
|
}
|
|
4691
5341
|
if (kind === "update") {
|
|
4692
5342
|
const changed = new Map();
|
|
4693
|
-
for (const column of
|
|
5343
|
+
for (const column of table.columns) {
|
|
4694
5344
|
if (column.id === keyColumn.id)
|
|
4695
5345
|
continue;
|
|
4696
5346
|
if ((segment.columnBlockIds[column.id]?.length ?? 0) === 0)
|
|
4697
5347
|
continue;
|
|
4698
|
-
const vector = await
|
|
4699
|
-
|
|
5348
|
+
const vector = await deltaVector(column, segment);
|
|
5349
|
+
const bytes = columnVectorRetainedBytes(vector);
|
|
5350
|
+
memory.reserve(bytes, "Streamed mutation replay");
|
|
5351
|
+
retainedBytes += bytes;
|
|
4700
5352
|
changed.set(column.id, vector);
|
|
4701
5353
|
}
|
|
4702
5354
|
mutationChangedVectors.set(segment.id, changed);
|
|
4703
5355
|
}
|
|
4704
5356
|
}
|
|
4705
5357
|
// Phase B: one bounded pass over the scan segments' key blocks — a single block resident
|
|
4706
|
-
// at a time — recording, per scan segment, the touched
|
|
5358
|
+
// at a time — recording, per scan segment, the touched keys and their absolute slots.
|
|
4707
5359
|
const touchedByScanSegment = new Map();
|
|
4708
5360
|
// A mutation history is small, and a unique key is usually written in order, so most key
|
|
4709
5361
|
// blocks cannot hold any touched key at all. Their zone maps say so from the header alone,
|
|
@@ -4734,26 +5386,10 @@ export class MinnowDatabase {
|
|
|
4734
5386
|
if (decoded.column.type !== keyColumn.type) {
|
|
4735
5387
|
throw new Error(`Column type mismatch: ${keyColumn.name}`);
|
|
4736
5388
|
}
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
const
|
|
4740
|
-
|
|
4741
|
-
: keyColumn.type === "string"
|
|
4742
|
-
? undefined
|
|
4743
|
-
: new Float64Array(rows);
|
|
4744
|
-
const codes = keyColumn.type === "string" ? new Uint32Array(rows) : undefined;
|
|
4745
|
-
codes?.fill(NULL_STRING_VECTOR_CODE);
|
|
4746
|
-
const builder = new StringDictionaryBuilder();
|
|
4747
|
-
appendPhysicalColumnToVector(decoded.column, 0, validity, values, codes, builder);
|
|
4748
|
-
const blockVector = (codes !== undefined
|
|
4749
|
-
? {
|
|
4750
|
-
kind: "string",
|
|
4751
|
-
length: rows,
|
|
4752
|
-
validity,
|
|
4753
|
-
codes,
|
|
4754
|
-
dictionary: builder.dictionary,
|
|
4755
|
-
}
|
|
4756
|
-
: { kind: keyColumn.type, length: rows, validity, values });
|
|
5389
|
+
// The block's vector form is what a scan of this table reads anyway, so it comes from
|
|
5390
|
+
// (and stays in) the buffer pool rather than being rebuilt for the replay.
|
|
5391
|
+
const blockVector = this.#blockColumnVector(blockId, decoded);
|
|
5392
|
+
const rows = blockVector.length;
|
|
4757
5393
|
const readBlockKey = requiredColumnVectorKeyReader(blockVector);
|
|
4758
5394
|
for (let row = 0; row < rows; row += 1) {
|
|
4759
5395
|
const key = readBlockKey(row);
|
|
@@ -4825,195 +5461,22 @@ export class MinnowDatabase {
|
|
|
4825
5461
|
slotPatches.set(columnId, { vector, row });
|
|
4826
5462
|
}
|
|
4827
5463
|
}
|
|
4828
|
-
|
|
4829
|
-
const
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
// group, or aimed at keys this table no longer holds — leave the scan exactly as it was.
|
|
4834
|
-
// The plain streamed scan hands decoded blocks straight through, so skipping the overlay
|
|
4835
|
-
// here is the difference between a copy per window and none.
|
|
4836
|
-
if (deadCount === 0 && !hasPatches)
|
|
4837
|
-
return inner;
|
|
4838
|
-
const states = projectedColumns.map((column) => ({
|
|
4839
|
-
column,
|
|
4840
|
-
vector: createStreamedColumnVector(column.type, outputRows),
|
|
4841
|
-
reservations: [],
|
|
4842
|
-
}));
|
|
4843
|
-
// Forward-only cursor: cursorOutput live rows exist strictly before base row cursorBase.
|
|
4844
|
-
let cursorOutput = 0;
|
|
4845
|
-
let cursorBase = 0;
|
|
4846
|
-
const load = async (start, length) => {
|
|
4847
|
-
const end = Math.min(start + length, outputRows);
|
|
4848
|
-
// COUNT(*) and friends project nothing: the replay already knows how many rows survive,
|
|
4849
|
-
// so there is no window to build and no reason to walk the base rows to build it.
|
|
4850
|
-
if (states.length === 0)
|
|
4851
|
-
return end;
|
|
4852
|
-
const window = states[0]?.vector.window;
|
|
4853
|
-
if (window !== undefined && start >= window.start && end <= window.start + window.length) {
|
|
4854
|
-
return window.start + window.length;
|
|
4855
|
-
}
|
|
4856
|
-
if (window !== undefined && start < window.start) {
|
|
4857
|
-
throw new Error(`Streamed scan moved backward: ${table.name}`);
|
|
4858
|
-
}
|
|
4859
|
-
// The cursor stops at the end of the window it last built, which can sit past a start
|
|
4860
|
-
// that falls inside that window. Rewinding costs one pass over the dead-row bitmap and
|
|
4861
|
-
// keeps the window aligned with what was asked for, rather than with where the cursor
|
|
4862
|
-
// happened to stop.
|
|
4863
|
-
if (cursorOutput > start) {
|
|
4864
|
-
cursorOutput = 0;
|
|
4865
|
-
cursorBase = 0;
|
|
4866
|
-
}
|
|
4867
|
-
while (cursorOutput < start && cursorBase < baseRows) {
|
|
4868
|
-
if (!bitmapHasValue(dead, cursorBase))
|
|
4869
|
-
cursorOutput += 1;
|
|
4870
|
-
cursorBase += 1;
|
|
4871
|
-
}
|
|
4872
|
-
let scanBase = cursorBase;
|
|
4873
|
-
let scanOutput = cursorOutput;
|
|
4874
|
-
const liveBaseRows = [];
|
|
4875
|
-
while (scanOutput < end && scanBase < baseRows) {
|
|
4876
|
-
if (!bitmapHasValue(dead, scanBase)) {
|
|
4877
|
-
liveBaseRows.push(scanBase);
|
|
4878
|
-
scanOutput += 1;
|
|
4879
|
-
}
|
|
4880
|
-
scanBase += 1;
|
|
4881
|
-
}
|
|
4882
|
-
if (scanOutput < end)
|
|
4883
|
-
throw new Error(`Column row count mismatch: ${table.name}`);
|
|
4884
|
-
const windowRows = end - start;
|
|
4885
|
-
const targets = [];
|
|
4886
|
-
try {
|
|
4887
|
-
for (const state of states) {
|
|
4888
|
-
const innerVector = inner.table.columns.get(state.column.name);
|
|
4889
|
-
if (innerVector === undefined) {
|
|
4890
|
-
throw new Error(`Streamed column is missing: ${state.column.name}`);
|
|
4891
|
-
}
|
|
4892
|
-
const validityBytes = Math.ceil(windowRows / 8);
|
|
4893
|
-
const typedBytes = validityBytes +
|
|
4894
|
-
(state.column.type === "boolean"
|
|
4895
|
-
? windowRows
|
|
4896
|
-
: state.column.type === "string"
|
|
4897
|
-
? windowRows * Uint32Array.BYTES_PER_ELEMENT
|
|
4898
|
-
: windowRows * Float64Array.BYTES_PER_ELEMENT);
|
|
4899
|
-
const replacements = [];
|
|
4900
|
-
replacements.push(memory.reserve(typedBytes, `Streamed window ${state.column.name}`));
|
|
4901
|
-
const validity = new Uint8Array(validityBytes);
|
|
4902
|
-
const values = state.column.type === "boolean"
|
|
4903
|
-
? new Uint8Array(windowRows)
|
|
4904
|
-
: state.column.type === "string"
|
|
4905
|
-
? undefined
|
|
4906
|
-
: new Float64Array(windowRows);
|
|
4907
|
-
const codes = state.column.type === "string" ? new Uint32Array(windowRows) : undefined;
|
|
4908
|
-
codes?.fill(NULL_STRING_VECTOR_CODE);
|
|
4909
|
-
const dictionary = [];
|
|
4910
|
-
const target = (codes !== undefined
|
|
4911
|
-
? { kind: "string", length: windowRows, validity, codes, dictionary }
|
|
4912
|
-
: { kind: state.column.type, length: windowRows, validity, values });
|
|
4913
|
-
targets.push({
|
|
4914
|
-
state,
|
|
4915
|
-
innerVector,
|
|
4916
|
-
validity,
|
|
4917
|
-
...(values === undefined ? {} : { values }),
|
|
4918
|
-
...(codes === undefined ? {} : { codes }),
|
|
4919
|
-
dictionary,
|
|
4920
|
-
dictionaryIndex: new Map(),
|
|
4921
|
-
replacements,
|
|
4922
|
-
target,
|
|
4923
|
-
});
|
|
4924
|
-
}
|
|
4925
|
-
// The inner loader serves whole blocks, so the copy walks live rows one inner window
|
|
4926
|
-
// at a time: patched slots read the resident mutation vectors, everything else reads
|
|
4927
|
-
// the inner window at its own offset.
|
|
4928
|
-
let index = 0;
|
|
4929
|
-
while (index < liveBaseRows.length) {
|
|
4930
|
-
const chunkStart = liveBaseRows[index] ?? 0;
|
|
4931
|
-
const innerEnd = await inner.load(chunkStart, scanBase - chunkStart);
|
|
4932
|
-
const usable = typeof innerEnd === "number" ? Math.min(innerEnd, scanBase) : scanBase;
|
|
4933
|
-
if (usable <= chunkStart) {
|
|
4934
|
-
throw new Error(`Column row count mismatch: ${table.name}`);
|
|
4935
|
-
}
|
|
4936
|
-
let chunkEndIndex = index;
|
|
4937
|
-
while (chunkEndIndex < liveBaseRows.length &&
|
|
4938
|
-
(liveBaseRows[chunkEndIndex] ?? 0) < usable) {
|
|
4939
|
-
chunkEndIndex += 1;
|
|
4940
|
-
}
|
|
4941
|
-
for (const entry of targets) {
|
|
4942
|
-
const innerWindow = entry.innerVector.window;
|
|
4943
|
-
if (innerWindow === undefined) {
|
|
4944
|
-
throw new Error(`Streamed column is missing: ${entry.state.column.name}`);
|
|
4945
|
-
}
|
|
4946
|
-
// Live rows are consecutive except where a delete cut them, so the copy walks runs:
|
|
4947
|
-
// one typed-array slice each, with patched slots taken out individually. Copying
|
|
4948
|
-
// cell by cell here is what made a table with one deleted row scan like a replay.
|
|
4949
|
-
const columnId = entry.state.column.id;
|
|
4950
|
-
const remap = entry.innerVector.kind === "string"
|
|
4951
|
-
? remapDictionary(entry.innerVector.dictionary, entry.dictionary, entry.dictionaryIndex)
|
|
4952
|
-
: undefined;
|
|
4953
|
-
let live = index;
|
|
4954
|
-
while (live < chunkEndIndex) {
|
|
4955
|
-
const baseRow = liveBaseRows[live] ?? 0;
|
|
4956
|
-
const patch = hasPatches ? patches.get(baseRow)?.get(columnId) : undefined;
|
|
4957
|
-
if (patch !== undefined) {
|
|
4958
|
-
copyColumnVectorValue(patch.vector, patch.row, entry.target, live, entry.dictionaryIndex);
|
|
4959
|
-
live += 1;
|
|
4960
|
-
continue;
|
|
4961
|
-
}
|
|
4962
|
-
let runEnd = live + 1;
|
|
4963
|
-
while (runEnd < chunkEndIndex &&
|
|
4964
|
-
(liveBaseRows[runEnd] ?? 0) === baseRow + (runEnd - live) &&
|
|
4965
|
-
(!hasPatches || patches.get(liveBaseRows[runEnd] ?? 0)?.get(columnId) === undefined)) {
|
|
4966
|
-
runEnd += 1;
|
|
4967
|
-
}
|
|
4968
|
-
copyVectorSpan(entry.innerVector, baseRow - innerWindow.start, runEnd - live, entry.target, live, remap);
|
|
4969
|
-
live = runEnd;
|
|
4970
|
-
}
|
|
4971
|
-
}
|
|
4972
|
-
index = chunkEndIndex;
|
|
4973
|
-
}
|
|
4974
|
-
// Reserve every fallible byte first; the installs below cannot throw, so a budget
|
|
4975
|
-
// overflow here leaves every state's previous window and reservations intact.
|
|
4976
|
-
for (const entry of targets) {
|
|
4977
|
-
let dictionaryBytes = 0;
|
|
4978
|
-
for (const value of entry.dictionary)
|
|
4979
|
-
dictionaryBytes += value.length;
|
|
4980
|
-
if (dictionaryBytes > 0) {
|
|
4981
|
-
entry.replacements.push(memory.reserve(dictionaryBytes, `Streamed window ${entry.state.column.name}`));
|
|
4982
|
-
}
|
|
4983
|
-
}
|
|
4984
|
-
for (const entry of targets) {
|
|
4985
|
-
const mutable = entry.state.vector;
|
|
4986
|
-
mutable.validity = entry.validity;
|
|
4987
|
-
if (entry.values !== undefined)
|
|
4988
|
-
mutable.values = entry.values;
|
|
4989
|
-
if (entry.codes !== undefined) {
|
|
4990
|
-
mutable.codes = entry.codes;
|
|
4991
|
-
mutable.dictionary = entry.dictionary;
|
|
4992
|
-
}
|
|
4993
|
-
mutable.window = { start, length: windowRows };
|
|
4994
|
-
for (const previous of entry.state.reservations)
|
|
4995
|
-
previous.release();
|
|
4996
|
-
entry.state.reservations = entry.replacements;
|
|
4997
|
-
}
|
|
4998
|
-
}
|
|
4999
|
-
catch (error) {
|
|
5000
|
-
for (const entry of targets) {
|
|
5001
|
-
for (const replacement of entry.replacements)
|
|
5002
|
-
replacement.release();
|
|
5003
|
-
}
|
|
5004
|
-
throw error;
|
|
5005
|
-
}
|
|
5006
|
-
cursorOutput = end;
|
|
5007
|
-
cursorBase = scanBase;
|
|
5008
|
-
return end;
|
|
5009
|
-
};
|
|
5464
|
+
let patchCells = 0;
|
|
5465
|
+
for (const slotPatches of patches.values())
|
|
5466
|
+
patchCells += slotPatches.size;
|
|
5467
|
+
memory.tally(patches.size * 96 + patchCells * 48, "Streamed mutation replay");
|
|
5468
|
+
const patchedSlots = Uint32Array.from(patches.keys()).sort();
|
|
5010
5469
|
return {
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5470
|
+
baseRows,
|
|
5471
|
+
deadCount,
|
|
5472
|
+
dead,
|
|
5473
|
+
patches,
|
|
5474
|
+
patchedSlots,
|
|
5475
|
+
bytes: dead.byteLength +
|
|
5476
|
+
retainedBytes +
|
|
5477
|
+
patchedSlots.byteLength +
|
|
5478
|
+
patches.size * 96 +
|
|
5479
|
+
patchCells * 48,
|
|
5017
5480
|
};
|
|
5018
5481
|
}
|
|
5019
5482
|
/** Prepares one block's inputs and executes it, returning the caller-owned result. */
|
|
@@ -5174,15 +5637,17 @@ export class MinnowDatabase {
|
|
|
5174
5637
|
/** Plans or advances one restart-safe physical compaction job. */
|
|
5175
5638
|
async compactTableStep(tableName, options = {}) {
|
|
5176
5639
|
const table = await this.#findTable(tableName);
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5640
|
+
return this.#serializedCompactionStep(table.id, async () => {
|
|
5641
|
+
const active = (await this.store.listCompactionJobs(table.id)).find((job) => isActiveCompactionState(job.state));
|
|
5642
|
+
let job = active;
|
|
5643
|
+
if (job === undefined) {
|
|
5644
|
+
const planned = await this.#planCompaction(table, options);
|
|
5645
|
+
if ("compacted" in planned)
|
|
5646
|
+
return compactionSkippedProgress(planned);
|
|
5647
|
+
job = planned;
|
|
5648
|
+
}
|
|
5649
|
+
return this.#runCompactionJob(table, job, positiveWholeNumber(options.maxBlocks ?? options.maxBlocksPerStep ?? 1, "Compaction step block limit"));
|
|
5650
|
+
});
|
|
5186
5651
|
}
|
|
5187
5652
|
/** Continues a persisted compaction job after a cooperative yield or restart. */
|
|
5188
5653
|
async resumeCompactionJob(jobId, options = {}) {
|
|
@@ -5192,7 +5657,26 @@ export class MinnowDatabase {
|
|
|
5192
5657
|
const table = await this.store.getTable(job.tableId);
|
|
5193
5658
|
if (table === undefined)
|
|
5194
5659
|
throw new Error(`Compaction table not found: ${job.tableId}`);
|
|
5195
|
-
return this.#runCompactionJob(table, job, positiveWholeNumber(options.maxBlocks ?? 1, "Compaction step block limit"));
|
|
5660
|
+
return this.#serializedCompactionStep(table.id, () => this.#runCompactionJob(table, job, positiveWholeNumber(options.maxBlocks ?? 1, "Compaction step block limit")));
|
|
5661
|
+
}
|
|
5662
|
+
/**
|
|
5663
|
+
* One compaction step at a time per table within this database: background compaction
|
|
5664
|
+
* drives a job in steps, and a caller stepping the same table explicitly must take turns with
|
|
5665
|
+
* it rather than advance the same job concurrently, which would write its output blocks
|
|
5666
|
+
* twice. Each step loads the job record fresh, so alternating drivers simply continue where
|
|
5667
|
+
* the other left off. Between instances and tabs the job's revision is the guard.
|
|
5668
|
+
*/
|
|
5669
|
+
async #serializedCompactionStep(tableId, step) {
|
|
5670
|
+
const previous = this.#compactionSteps.get(tableId) ?? Promise.resolve();
|
|
5671
|
+
const run = previous.then(step, step);
|
|
5672
|
+
this.#compactionSteps.set(tableId, run);
|
|
5673
|
+
try {
|
|
5674
|
+
return await run;
|
|
5675
|
+
}
|
|
5676
|
+
finally {
|
|
5677
|
+
if (this.#compactionSteps.get(tableId) === run)
|
|
5678
|
+
this.#compactionSteps.delete(tableId);
|
|
5679
|
+
}
|
|
5196
5680
|
}
|
|
5197
5681
|
async listCompactionJobs(tableName) {
|
|
5198
5682
|
if (tableName === undefined)
|
|
@@ -5219,6 +5703,13 @@ export class MinnowDatabase {
|
|
|
5219
5703
|
}
|
|
5220
5704
|
}
|
|
5221
5705
|
}
|
|
5706
|
+
async #cancelTableCompactions(tableId) {
|
|
5707
|
+
for (const job of await this.store.listCompactionJobs(tableId)) {
|
|
5708
|
+
if (job.state === "planned" || job.state === "running" || job.state === "ready") {
|
|
5709
|
+
await this.cancelCompactionJob(job.id);
|
|
5710
|
+
}
|
|
5711
|
+
}
|
|
5712
|
+
}
|
|
5222
5713
|
/** Runs restart-safe lease-aware reclamation to completion in bounded durable steps. */
|
|
5223
5714
|
async collectGarbage(options = {}) {
|
|
5224
5715
|
const maxItems = positiveWholeNumber(options.maxItemsPerStep ?? 64, "Garbage collection items per step");
|
|
@@ -5227,38 +5718,201 @@ export class MinnowDatabase {
|
|
|
5227
5718
|
...(options.maxPlanningItems === undefined
|
|
5228
5719
|
? {}
|
|
5229
5720
|
: { maxPlanningItems: options.maxPlanningItems }),
|
|
5721
|
+
...(options.retainRecentVersions === undefined
|
|
5722
|
+
? {}
|
|
5723
|
+
: { retainRecentVersions: options.retainRecentVersions }),
|
|
5230
5724
|
});
|
|
5231
5725
|
while (progress.result === null) {
|
|
5232
5726
|
progress = await this.resumeGarbageCollectionJob(progress.jobId, { maxItems });
|
|
5233
5727
|
}
|
|
5728
|
+
await this.#pruneFinishedJobRecords();
|
|
5234
5729
|
return progress.result;
|
|
5235
5730
|
}
|
|
5236
5731
|
/** Plans or advances one durable garbage-collection pass. */
|
|
5237
5732
|
async collectGarbageStep(options = {}) {
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5733
|
+
return this.#collectGarbageStep(options);
|
|
5734
|
+
}
|
|
5735
|
+
/** `collectGarbageStep`, with the age bound background collection adds to its retention. */
|
|
5736
|
+
async #collectGarbageStep(options, retainedVersionMaxAgeMs = Number.POSITIVE_INFINITY) {
|
|
5737
|
+
return this.#serializedCollectionStep(async () => {
|
|
5738
|
+
const active = (await this.store.listGarbageCollectionJobs()).find((job) => job.state === "planned" || job.state === "running");
|
|
5739
|
+
const job = active ??
|
|
5740
|
+
(await this.#planGarbageCollection(positiveWholeNumber(options.maxPlanningItems ?? 1_024, "Garbage collection planning limit"), nonNegativeWholeNumber(options.retainRecentVersions ?? 0, "Garbage collection retained versions"), retainedVersionMaxAgeMs));
|
|
5741
|
+
return this.#runGarbageCollectionJob(job, positiveWholeNumber(options.maxItems ?? 1, "Garbage collection item limit"));
|
|
5742
|
+
});
|
|
5242
5743
|
}
|
|
5243
5744
|
/** Continues a persisted reclamation pass after a cooperative yield or restart. */
|
|
5244
5745
|
async resumeGarbageCollectionJob(jobId, options = {}) {
|
|
5245
5746
|
const job = await this.store.getGarbageCollectionJob(jobId);
|
|
5246
5747
|
if (job === undefined)
|
|
5247
5748
|
throw new Error(`Garbage collection job not found: ${jobId}`);
|
|
5248
|
-
return this.#runGarbageCollectionJob(job, positiveWholeNumber(options.maxItems ?? 1, "Garbage collection item limit"));
|
|
5749
|
+
return this.#serializedCollectionStep(() => this.#runGarbageCollectionJob(job, positiveWholeNumber(options.maxItems ?? 1, "Garbage collection item limit")));
|
|
5249
5750
|
}
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5751
|
+
/**
|
|
5752
|
+
* One garbage-collection step at a time within this database, for the same reason
|
|
5753
|
+
* compaction steps take turns (`#serializedCompactionStep`): background collection drives
|
|
5754
|
+
* a job in steps, and a caller stepping collection explicitly continues the same job rather
|
|
5755
|
+
* than racing it.
|
|
5756
|
+
*/
|
|
5757
|
+
async #serializedCollectionStep(step) {
|
|
5758
|
+
const previous = this.#collectionSteps;
|
|
5759
|
+
const run = previous.then(step, step);
|
|
5760
|
+
this.#collectionSteps = run;
|
|
5761
|
+
try {
|
|
5762
|
+
return await run;
|
|
5763
|
+
}
|
|
5764
|
+
finally {
|
|
5765
|
+
if (this.#collectionSteps === run)
|
|
5766
|
+
this.#collectionSteps = Promise.resolve();
|
|
5767
|
+
}
|
|
5768
|
+
}
|
|
5769
|
+
/**
|
|
5770
|
+
* Background collection: plans one pass and drives it to completion in yielding steps.
|
|
5771
|
+
* Runs after a background fold, whose superseded blocks are what a pass reclaims, and every
|
|
5772
|
+
* AUTO_COLLECT_COMMIT_INTERVAL commits, since every commit writes a manifest that stays on
|
|
5773
|
+
* disk until pruned. Keeps the most recent versions readable. Never surfaces through a
|
|
5774
|
+
* write or a scan; a failed pass backs off for an interval of commits.
|
|
5775
|
+
*/
|
|
5776
|
+
#maybeScheduleAutoCollection() {
|
|
5777
|
+
if (!this.#autoCollect)
|
|
5778
|
+
return;
|
|
5779
|
+
if (this.#autoCollectionInFlight) {
|
|
5780
|
+
// A fold finishing or a quiet minute passing while a run is under way is a reason for
|
|
5781
|
+
// one more run once this one ends — a dropped trigger after the last commit of a burst
|
|
5782
|
+
// would otherwise leave the burst's leftovers until the next one.
|
|
5783
|
+
this.#autoCollectionRequested = true;
|
|
5784
|
+
return;
|
|
5785
|
+
}
|
|
5786
|
+
if (this.#commitsSinceCollection < this.#autoCollectionBackoffUntilCommit)
|
|
5787
|
+
return;
|
|
5788
|
+
this.#autoCollectionInFlight = true;
|
|
5789
|
+
this.#autoCollectionRequested = false;
|
|
5790
|
+
this.#commitsSinceCollection = 0;
|
|
5791
|
+
this.#lastCollectionAt = this.#now().getTime();
|
|
5792
|
+
void this.#runAutoCollection()
|
|
5793
|
+
.then(() => {
|
|
5794
|
+
this.#autoCollectionBackoffUntilCommit = 0;
|
|
5795
|
+
})
|
|
5796
|
+
.catch(() => {
|
|
5797
|
+
this.#autoCollectionBackoffUntilCommit = AUTO_COLLECT_COMMIT_INTERVAL * 2;
|
|
5798
|
+
})
|
|
5799
|
+
.finally(() => {
|
|
5800
|
+
this.#autoCollectionInFlight = false;
|
|
5801
|
+
if (this.#autoCollectionRequested) {
|
|
5802
|
+
this.#autoCollectionRequested = false;
|
|
5803
|
+
void yieldToEventLoop().then(() => {
|
|
5804
|
+
this.#maybeScheduleAutoCollection();
|
|
5805
|
+
});
|
|
5806
|
+
}
|
|
5807
|
+
});
|
|
5808
|
+
}
|
|
5809
|
+
/**
|
|
5810
|
+
* A pass a quiet period after the last commit, for a tab that stops writing: the retained
|
|
5811
|
+
* window's age bound lets that pass reclaim what the last burst superseded, which no commit
|
|
5812
|
+
* would otherwise arrive to trigger. Re-armed by every commit; unreferenced, so it never
|
|
5813
|
+
* keeps a process alive.
|
|
5814
|
+
*/
|
|
5815
|
+
#armIdleCollection() {
|
|
5816
|
+
if (!this.#autoCollect)
|
|
5817
|
+
return;
|
|
5818
|
+
if (this.#idleCollectionTimer !== undefined)
|
|
5819
|
+
clearTimeout(this.#idleCollectionTimer);
|
|
5820
|
+
const timer = setTimeout(() => {
|
|
5821
|
+
this.#idleCollectionTimer = undefined;
|
|
5822
|
+
this.#maybeScheduleAutoCollection();
|
|
5823
|
+
}, AUTO_COLLECT_QUIET_MS);
|
|
5824
|
+
timer.unref?.();
|
|
5825
|
+
this.#idleCollectionTimer = timer;
|
|
5826
|
+
}
|
|
5827
|
+
async #runAutoCollection() {
|
|
5828
|
+
// One pass plans a bounded number of candidates, so a backlog — a burst of commits that
|
|
5829
|
+
// outran the passes between them — takes several. Keep passing while a pass still finds
|
|
5830
|
+
// something, up to a ceiling that keeps a pathological store from pinning the loop.
|
|
5831
|
+
for (let pass = 0; pass < AUTO_COLLECT_MAX_PASSES; pass += 1) {
|
|
5832
|
+
this.#releaseIdleSharedLease();
|
|
5833
|
+
let progress = await this.#collectGarbageStep({
|
|
5834
|
+
maxItems: AUTO_COLLECT_STEP_ITEMS,
|
|
5835
|
+
retainRecentVersions: AUTO_COLLECT_RETAINED_VERSIONS,
|
|
5836
|
+
}, AUTO_COLLECT_RETAINED_VERSION_MS);
|
|
5837
|
+
while (progress.result === null) {
|
|
5838
|
+
await yieldToEventLoop();
|
|
5839
|
+
progress = await this.resumeGarbageCollectionJob(progress.jobId, {
|
|
5840
|
+
maxItems: AUTO_COLLECT_STEP_ITEMS,
|
|
5841
|
+
});
|
|
5842
|
+
}
|
|
5843
|
+
const result = progress.result;
|
|
5844
|
+
if (result.prunedManifestCount === 0 &&
|
|
5845
|
+
result.reclaimedBlockCount === 0 &&
|
|
5846
|
+
result.reclaimedSegmentCount === 0 &&
|
|
5847
|
+
result.reclaimedTransactionCount === 0) {
|
|
5848
|
+
break;
|
|
5849
|
+
}
|
|
5850
|
+
await yieldToEventLoop();
|
|
5851
|
+
}
|
|
5852
|
+
await this.#pruneFinishedJobRecords();
|
|
5853
|
+
}
|
|
5854
|
+
/** Drops finished maintenance records while preserving the state needed for safe L2 retries. */
|
|
5855
|
+
async #pruneFinishedJobRecords() {
|
|
5856
|
+
const newestFirst = (jobs) => jobs.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
5857
|
+
const terminalCompactions = newestFirst((await this.store.listCompactionJobs()).filter((job) => job.state === "published" || job.state === "cancelled" || job.state === "aborted"));
|
|
5858
|
+
const retainedCompactionIds = new Set(terminalCompactions.slice(0, AUTO_COLLECT_RETAINED_JOB_RECORDS).map((job) => job.id));
|
|
5859
|
+
// A retry persists the cumulative bytes written by earlier attempts. Keep only the newest
|
|
5860
|
+
// failure for each still-readable source snapshot; it carries the whole lifetime budget.
|
|
5861
|
+
// Once the source manifest is pruned, that exact retry can never be planned again.
|
|
5862
|
+
const retainedFailureBases = new Set();
|
|
5863
|
+
const manifestReadable = new Map();
|
|
5864
|
+
for (const job of terminalCompactions) {
|
|
5865
|
+
if (job.state !== "cancelled" && job.state !== "aborted")
|
|
5866
|
+
continue;
|
|
5867
|
+
const baseId = job.id.split("/retry/", 1)[0] ?? job.id;
|
|
5868
|
+
if (retainedFailureBases.has(baseId))
|
|
5869
|
+
continue;
|
|
5870
|
+
let readable = manifestReadable.get(job.sourceManifestVersion);
|
|
5871
|
+
if (readable === undefined) {
|
|
5872
|
+
const manifest = await this.store.getManifest(job.sourceManifestVersion);
|
|
5873
|
+
readable = manifest !== undefined && manifest.prunedAt === undefined;
|
|
5874
|
+
manifestReadable.set(job.sourceManifestVersion, readable);
|
|
5875
|
+
}
|
|
5876
|
+
if (!readable)
|
|
5877
|
+
continue;
|
|
5878
|
+
retainedFailureBases.add(baseId);
|
|
5879
|
+
retainedCompactionIds.add(job.id);
|
|
5880
|
+
}
|
|
5881
|
+
for (const job of terminalCompactions) {
|
|
5882
|
+
if (!retainedCompactionIds.has(job.id))
|
|
5883
|
+
await this.store.removeCompactionJob(job.id);
|
|
5884
|
+
}
|
|
5885
|
+
const collections = newestFirst((await this.store.listGarbageCollectionJobs()).filter((job) => job.state === "completed"));
|
|
5886
|
+
for (const job of collections.slice(AUTO_COLLECT_RETAINED_JOB_RECORDS)) {
|
|
5887
|
+
await this.store.removeGarbageCollectionJob(job.id);
|
|
5888
|
+
}
|
|
5889
|
+
}
|
|
5890
|
+
async listGarbageCollectionJobs() {
|
|
5891
|
+
return this.store.listGarbageCollectionJobs();
|
|
5892
|
+
}
|
|
5893
|
+
async #planGarbageCollection(maxPlanningItems, retainRecentVersions, retainedVersionMaxAgeMs = Number.POSITIVE_INFINITY) {
|
|
5894
|
+
// Crashed readers and query spill owners are metadata roots too. Sweep their expired
|
|
5895
|
+
// records as part of every explicit or background collection so callers never need a
|
|
5896
|
+
// separate maintenance loop to keep either family bounded.
|
|
5897
|
+
await this.#transactions.removeExpiredLeases(this.#now());
|
|
5898
|
+
await this.cleanupQuerySpill();
|
|
5899
|
+
const current = await this.store.getCurrentManifest();
|
|
5255
5900
|
const currentBlockIds = new Set(current?.blockIds ?? []);
|
|
5901
|
+
// Manifest versions are consecutive, so the retained window is a version floor; a version
|
|
5902
|
+
// inside it is still collected once it is older than the window's age.
|
|
5903
|
+
const retainAbove = (current?.version ?? 0) - retainRecentVersions;
|
|
5904
|
+
const retainAfter = this.#now().getTime() - retainedVersionMaxAgeMs;
|
|
5905
|
+
const retained = (manifest) => manifest.version > retainAbove && Date.parse(manifest.createdAt) > retainAfter;
|
|
5256
5906
|
const candidateManifestVersions = [];
|
|
5257
5907
|
const candidateBlockIds = [];
|
|
5258
5908
|
const candidateSegmentIds = [];
|
|
5909
|
+
const candidateTransactionIds = [];
|
|
5259
5910
|
const candidateBlockIdSet = new Set();
|
|
5260
5911
|
const candidateSegmentIdSet = new Set();
|
|
5261
|
-
const remaining = () => maxPlanningItems -
|
|
5912
|
+
const remaining = () => maxPlanningItems -
|
|
5913
|
+
candidateBlockIds.length -
|
|
5914
|
+
candidateSegmentIds.length -
|
|
5915
|
+
candidateTransactionIds.length;
|
|
5262
5916
|
const addBlocks = (ids) => {
|
|
5263
5917
|
for (const id of ids) {
|
|
5264
5918
|
if (remaining() <= 0)
|
|
@@ -5279,33 +5933,81 @@ export class MinnowDatabase {
|
|
|
5279
5933
|
candidateSegmentIds.push(id);
|
|
5280
5934
|
}
|
|
5281
5935
|
};
|
|
5282
|
-
|
|
5283
|
-
|
|
5936
|
+
// The walk starts past the prefix of history this database has already seen fully
|
|
5937
|
+
// collected — every manifest pruned and none of its blocks left — and extends that prefix
|
|
5938
|
+
// as it goes. A pruned manifest's blocks can only disappear, and a block it shares with a
|
|
5939
|
+
// later manifest is found through that manifest or the job that superseded it, so skipping
|
|
5940
|
+
// the dead prefix loses nothing; it is what keeps a pass proportional to the live history
|
|
5941
|
+
// rather than to everything the database ever committed.
|
|
5942
|
+
let manifestCursor = this.#collectionWatermark;
|
|
5943
|
+
let deadPrefixEnd = this.#collectionWatermark;
|
|
5944
|
+
let prefixContiguous = true;
|
|
5945
|
+
walk: do {
|
|
5284
5946
|
const page = await this.store.listManifestPage(manifestCursor, 64);
|
|
5285
5947
|
for (const manifest of page.records) {
|
|
5286
5948
|
if (manifest.version === current?.version)
|
|
5949
|
+
break walk;
|
|
5950
|
+
if (retained(manifest)) {
|
|
5951
|
+
prefixContiguous = false;
|
|
5287
5952
|
continue;
|
|
5953
|
+
}
|
|
5288
5954
|
const existing = await this.#existingGarbageBlockCandidates(manifest.blockIds, currentBlockIds, remaining());
|
|
5289
|
-
if (manifest.prunedAt === undefined
|
|
5290
|
-
|
|
5291
|
-
|
|
5955
|
+
if (manifest.prunedAt === undefined) {
|
|
5956
|
+
// Only an unpruned manifest is a pruning candidate: one already pruned would spend a
|
|
5957
|
+
// step's capacity confirming it, and with enough of them in front, the unpruned ones
|
|
5958
|
+
// behind them were never reached at all. Their leftover blocks still count.
|
|
5959
|
+
if (candidateManifestVersions.length < 64)
|
|
5960
|
+
candidateManifestVersions.push(manifest.version);
|
|
5961
|
+
prefixContiguous = false;
|
|
5292
5962
|
}
|
|
5293
|
-
if (
|
|
5294
|
-
|
|
5963
|
+
else if (prefixContiguous && existing.length === 0) {
|
|
5964
|
+
deadPrefixEnd = manifest.version;
|
|
5965
|
+
}
|
|
5966
|
+
else {
|
|
5967
|
+
prefixContiguous = false;
|
|
5968
|
+
}
|
|
5969
|
+
addBlocks(existing);
|
|
5970
|
+
if (remaining() <= 0)
|
|
5971
|
+
break walk;
|
|
5295
5972
|
}
|
|
5296
|
-
if (remaining() <= 0 || candidateManifestVersions.length === 64)
|
|
5297
|
-
break;
|
|
5298
5973
|
manifestCursor = page.nextCursor;
|
|
5299
5974
|
} while (manifestCursor !== null);
|
|
5975
|
+
this.#collectionWatermark = deadPrefixEnd;
|
|
5300
5976
|
if (remaining() > 0) {
|
|
5977
|
+
const segmentOwnerIds = new Set((await this.store.listSegments()).map((segment) => segment.transactionId));
|
|
5978
|
+
const manifestEligibility = new Map();
|
|
5979
|
+
const candidateManifestSet = new Set(candidateManifestVersions);
|
|
5301
5980
|
let transactionCursor = null;
|
|
5302
5981
|
do {
|
|
5303
5982
|
const page = await this.store.listTransactionPage(transactionCursor, 64);
|
|
5304
5983
|
for (const transaction of page.records) {
|
|
5305
|
-
if (transaction.status
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5984
|
+
if (transaction.status === "aborted") {
|
|
5985
|
+
const pendingBlocks = await this.#existingGarbageBlockCandidates(transaction.pendingBlockIds, currentBlockIds, remaining());
|
|
5986
|
+
addBlocks(pendingBlocks);
|
|
5987
|
+
const pendingSegments = await this.#existingGarbageSegmentCandidates(transaction.pendingSegmentIds, remaining());
|
|
5988
|
+
addSegments(pendingSegments);
|
|
5989
|
+
// The artifacts are deleted before transaction candidates within a job. A later
|
|
5990
|
+
// pass sees the empty journal and removes the aborted record itself.
|
|
5991
|
+
if (pendingBlocks.length === 0 && pendingSegments.length === 0 && remaining() > 0) {
|
|
5992
|
+
candidateTransactionIds.push(transaction.id);
|
|
5993
|
+
}
|
|
5994
|
+
}
|
|
5995
|
+
else if (transaction.status === "committed" &&
|
|
5996
|
+
transaction.committedVersion !== null &&
|
|
5997
|
+
!segmentOwnerIds.has(transaction.id) &&
|
|
5998
|
+
remaining() > 0) {
|
|
5999
|
+
let eligible = manifestEligibility.get(transaction.committedVersion);
|
|
6000
|
+
if (eligible === undefined) {
|
|
6001
|
+
const manifest = await this.store.getManifest(transaction.committedVersion);
|
|
6002
|
+
eligible =
|
|
6003
|
+
manifest === undefined ||
|
|
6004
|
+
manifest.prunedAt !== undefined ||
|
|
6005
|
+
candidateManifestSet.has(transaction.committedVersion);
|
|
6006
|
+
manifestEligibility.set(transaction.committedVersion, eligible);
|
|
6007
|
+
}
|
|
6008
|
+
if (eligible)
|
|
6009
|
+
candidateTransactionIds.push(transaction.id);
|
|
6010
|
+
}
|
|
5309
6011
|
if (remaining() <= 0)
|
|
5310
6012
|
break;
|
|
5311
6013
|
}
|
|
@@ -5323,10 +6025,7 @@ export class MinnowDatabase {
|
|
|
5323
6025
|
continue;
|
|
5324
6026
|
}
|
|
5325
6027
|
addBlocks(await this.#existingGarbageBlockCandidates([...job.sourceBlockIds, ...job.outputBlockIds], currentBlockIds, remaining()));
|
|
5326
|
-
addSegments(await this.#existingGarbageSegmentCandidates([
|
|
5327
|
-
...job.sourceSegmentIds,
|
|
5328
|
-
...(job.outputSegmentId === null ? [] : [job.outputSegmentId]),
|
|
5329
|
-
], remaining()));
|
|
6028
|
+
addSegments(await this.#existingGarbageSegmentCandidates([...job.sourceSegmentIds, ...compactionOutputSegmentIds(job)], remaining()));
|
|
5330
6029
|
if (remaining() <= 0)
|
|
5331
6030
|
break;
|
|
5332
6031
|
}
|
|
@@ -5341,22 +6040,35 @@ export class MinnowDatabase {
|
|
|
5341
6040
|
candidateManifestVersions,
|
|
5342
6041
|
candidateSegmentIds,
|
|
5343
6042
|
candidateBlockIds,
|
|
6043
|
+
candidateTransactionIds,
|
|
5344
6044
|
leaseCutoff: timestamp,
|
|
5345
6045
|
createdAt: timestamp,
|
|
5346
6046
|
});
|
|
5347
6047
|
}
|
|
5348
6048
|
async #existingGarbageBlockCandidates(ids, currentBlockIds, limit) {
|
|
5349
|
-
|
|
6049
|
+
if (limit <= 0)
|
|
6050
|
+
return [];
|
|
6051
|
+
// A block the current manifest still carries is not garbage, whatever else references it,
|
|
6052
|
+
// and an unpruned manifest shares nearly all of its blocks with the current one. Deciding
|
|
6053
|
+
// that from the set first leaves the store lookups to the few blocks that might be gone —
|
|
6054
|
+
// reading every block of every manifest to find them made a planning pass cost the table
|
|
6055
|
+
// times the history.
|
|
6056
|
+
const possible = [];
|
|
5350
6057
|
const seen = new Set();
|
|
5351
|
-
for (
|
|
5352
|
-
|
|
6058
|
+
for (const id of ids) {
|
|
6059
|
+
if (currentBlockIds.has(id) || seen.has(id))
|
|
6060
|
+
continue;
|
|
6061
|
+
seen.add(id);
|
|
6062
|
+
possible.push(id);
|
|
6063
|
+
}
|
|
6064
|
+
const candidates = [];
|
|
6065
|
+
for (let start = 0; start < possible.length && candidates.length < limit; start += 64) {
|
|
6066
|
+
const page = possible.slice(start, start + 64);
|
|
5353
6067
|
const blocks = await this.store.getBlocks(page);
|
|
5354
6068
|
for (let index = 0; index < page.length && candidates.length < limit; index += 1) {
|
|
5355
6069
|
const id = page[index] ?? "";
|
|
5356
|
-
if (blocks[index] !== undefined
|
|
5357
|
-
seen.add(id);
|
|
6070
|
+
if (blocks[index] !== undefined)
|
|
5358
6071
|
candidates.push(id);
|
|
5359
|
-
}
|
|
5360
6072
|
}
|
|
5361
6073
|
}
|
|
5362
6074
|
return candidates;
|
|
@@ -5377,8 +6089,10 @@ export class MinnowDatabase {
|
|
|
5377
6089
|
async #runGarbageCollectionJob(initialJob, maxItems) {
|
|
5378
6090
|
let job = initialJob;
|
|
5379
6091
|
for (;;) {
|
|
5380
|
-
if (job.state === "completed")
|
|
6092
|
+
if (job.state === "completed") {
|
|
6093
|
+
await this.store.removePrunedManifestRecords();
|
|
5381
6094
|
return garbageCollectionProgress(job);
|
|
6095
|
+
}
|
|
5382
6096
|
try {
|
|
5383
6097
|
const step = await this.store.runGarbageCollectionStep({
|
|
5384
6098
|
jobId: job.id,
|
|
@@ -5386,6 +6100,8 @@ export class MinnowDatabase {
|
|
|
5386
6100
|
maxItems,
|
|
5387
6101
|
updatedAt: this.#now().toISOString(),
|
|
5388
6102
|
});
|
|
6103
|
+
if (step.job.state === "completed")
|
|
6104
|
+
await this.store.removePrunedManifestRecords();
|
|
5389
6105
|
return garbageCollectionProgress(step.job);
|
|
5390
6106
|
}
|
|
5391
6107
|
catch (error) {
|
|
@@ -5440,31 +6156,52 @@ export class MinnowDatabase {
|
|
|
5440
6156
|
const levelTwoMaxWriteAmplification = targetLevel === 2
|
|
5441
6157
|
? positiveFiniteNumber(options.maxWriteAmplification ?? DEFAULT_LEVEL_TWO_MAX_WRITE_AMPLIFICATION, "Compaction maximum write amplification")
|
|
5442
6158
|
: undefined;
|
|
5443
|
-
|
|
6159
|
+
const targetBlockBytes = positiveWholeNumber(options.targetBlockBytes ?? DEFAULT_COMPACTION_TARGET_BLOCK_BYTES, "Compaction target block bytes");
|
|
6160
|
+
if (targetBlockBytes > MAX_COMPACTION_TARGET_BLOCK_BYTES) {
|
|
6161
|
+
throw new RangeError(`Compaction target block bytes cannot exceed ${String(MAX_COMPACTION_TARGET_BLOCK_BYTES)}`);
|
|
6162
|
+
}
|
|
6163
|
+
const outputCompression = validateCompression(options.outputCompression ?? "gzip", "Compaction output compression");
|
|
6164
|
+
if (getCompressionMemoryBound(outputCompression, targetBlockBytes).maximumOutputBytes >
|
|
6165
|
+
MAX_COMPACTION_TARGET_BLOCK_BYTES) {
|
|
6166
|
+
throw new RangeError(`Compaction target block bytes exceed the ${outputCompression} worst-case format limit`);
|
|
6167
|
+
}
|
|
6168
|
+
const memoryBudgetBytes = positiveWholeNumber(options.memoryBudgetBytes ?? DEFAULT_COMPACTION_MEMORY_BUDGET_BYTES, "Compaction memory budget");
|
|
6169
|
+
const partitionRows = positiveWholeNumber(options.partitionRows ?? this.#compactionPartitionRows, "Compaction partition rows");
|
|
6170
|
+
let anchors = [];
|
|
5444
6171
|
let level0Segments;
|
|
5445
6172
|
let effectiveMinimumLevel0Segments;
|
|
5446
6173
|
let outputPartitionOrdinal;
|
|
5447
6174
|
let keyedLevelTwo = false;
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
6175
|
+
let keyedLevelOne;
|
|
6176
|
+
let keylessLevelOne;
|
|
6177
|
+
if (targetLevel === 1 && table.uniqueKeyColumnId !== undefined) {
|
|
6178
|
+
// Keyed L1: a prefix of level-one partitions, then level-zero history. A fold rewrites
|
|
6179
|
+
// only the partitions the selected deltas touch (and the tail partition new rows join),
|
|
6180
|
+
// so which partitions it sources is decided after the level-zero selection below.
|
|
6181
|
+
const layout = keyedLevelOneLayout(visibleSegments);
|
|
6182
|
+
if (layout === null) {
|
|
6183
|
+
return compactTableSkipped(table.name, "unsupported-level-layout", visibleSegments, visibleBlockIds, version);
|
|
6184
|
+
}
|
|
6185
|
+
keyedLevelOne = layout;
|
|
6186
|
+
level0Segments = layout.level0Segments;
|
|
6187
|
+
effectiveMinimumLevel0Segments =
|
|
6188
|
+
layout.partitions.length > 0 ? minimumLevel0Segments : Math.max(2, minimumLevel0Segments);
|
|
6189
|
+
}
|
|
6190
|
+
else if (targetLevel === 1) {
|
|
6191
|
+
const layout = keylessLevelOneLayout(visibleSegments);
|
|
6192
|
+
if (layout === null) {
|
|
5455
6193
|
return compactTableSkipped(table.name, "unsupported-level-layout", visibleSegments, visibleBlockIds, version);
|
|
5456
6194
|
}
|
|
5457
|
-
|
|
5458
|
-
level0Segments =
|
|
5459
|
-
effectiveMinimumLevel0Segments =
|
|
5460
|
-
? minimumLevel0Segments
|
|
5461
|
-
: Math.max(2, minimumLevel0Segments);
|
|
6195
|
+
keylessLevelOne = layout;
|
|
6196
|
+
level0Segments = layout.level0Segments;
|
|
6197
|
+
effectiveMinimumLevel0Segments =
|
|
6198
|
+
layout.partitions.length > 0 ? minimumLevel0Segments : Math.max(2, minimumLevel0Segments);
|
|
5462
6199
|
}
|
|
5463
6200
|
else if (table.uniqueKeyColumnId !== undefined ||
|
|
5464
6201
|
visibleSegments.some((segment) => (segment.kind ?? "insert") !== "insert" || segment.rowIdSpans !== undefined)) {
|
|
5465
|
-
// Keyed multi-range L2: merge (
|
|
5466
|
-
// span-carrying partition. Published partitions are never rewritten; mutation
|
|
5467
|
-
// without a unique key cannot merge and keep the materialized skip.
|
|
6202
|
+
// Keyed multi-range L2: merge (the level-one partitions + oldest level-zero prefix) into
|
|
6203
|
+
// a new span-carrying partition. Published partitions are never rewritten; mutation
|
|
6204
|
+
// kinds without a unique key cannot merge and keep the materialized skip.
|
|
5468
6205
|
if (table.uniqueKeyColumnId === undefined) {
|
|
5469
6206
|
return compactTableSkipped(table.name, "contains-mutation-segments", visibleSegments, visibleBlockIds, version);
|
|
5470
6207
|
}
|
|
@@ -5472,7 +6209,7 @@ export class MinnowDatabase {
|
|
|
5472
6209
|
if (layout === null) {
|
|
5473
6210
|
return compactTableSkipped(table.name, "unsupported-level-layout", visibleSegments, visibleBlockIds, version);
|
|
5474
6211
|
}
|
|
5475
|
-
|
|
6212
|
+
anchors = layout.anchors;
|
|
5476
6213
|
level0Segments = layout.level0Segments;
|
|
5477
6214
|
effectiveMinimumLevel0Segments = minimumLevel0Segments;
|
|
5478
6215
|
outputPartitionOrdinal = layout.levelTwoSegments.length;
|
|
@@ -5490,38 +6227,70 @@ export class MinnowDatabase {
|
|
|
5490
6227
|
if (level0Segments.length < effectiveMinimumLevel0Segments) {
|
|
5491
6228
|
return compactTableSkipped(table.name, "below-segment-threshold", visibleSegments, visibleBlockIds, version);
|
|
5492
6229
|
}
|
|
5493
|
-
|
|
6230
|
+
if (version === null)
|
|
6231
|
+
throw new Error("Visible compaction segments require a manifest");
|
|
6232
|
+
const level0Selection = await this.#selectLevelZeroSources(level0Segments, effectiveMinimumLevel0Segments, maxLevel0Segments, maxLevel0StoredBytes, snapshot);
|
|
6233
|
+
let partitioning;
|
|
6234
|
+
let rechunkPartitioning;
|
|
6235
|
+
if (keyedLevelOne !== undefined) {
|
|
6236
|
+
const keyColumn = getUniqueKeyColumn(table);
|
|
6237
|
+
if (keyColumn === undefined) {
|
|
6238
|
+
throw new Error(`Mutation compaction requires a unique key: ${table.name}`);
|
|
6239
|
+
}
|
|
6240
|
+
const touched = await this.#touchedPartitionIds(keyColumn, keyedLevelOne.partitions, level0Selection.segments, memoryBudgetBytes, snapshot);
|
|
6241
|
+
// New rows join the last partition while it is small, or when it is being rewritten
|
|
6242
|
+
// anyway; otherwise they open a new partition behind it and it stays untouched.
|
|
6243
|
+
const last = keyedLevelOne.partitions[keyedLevelOne.partitions.length - 1];
|
|
6244
|
+
const bearsNewRows = level0Selection.segments.some((segment) => mergeSourceBearsRows(segment.kind ?? "insert"));
|
|
6245
|
+
const absorbsTail = last !== undefined &&
|
|
6246
|
+
bearsNewRows &&
|
|
6247
|
+
(touched.has(last.id) || last.rowCount < partitionRows);
|
|
6248
|
+
anchors = keyedLevelOne.partitions.filter((partition) => partition.rowCount > partitionRows ||
|
|
6249
|
+
touched.has(partition.id) ||
|
|
6250
|
+
(absorbsTail && partition.id === last.id));
|
|
6251
|
+
partitioning = {
|
|
6252
|
+
partitions: keyedLevelOne.partitions,
|
|
6253
|
+
partitionRows,
|
|
6254
|
+
absorbsTail,
|
|
6255
|
+
nextLevelZeroOrder: level0Selection.nextLogicalOrder ?? version + 1,
|
|
6256
|
+
};
|
|
6257
|
+
}
|
|
6258
|
+
else if (keylessLevelOne !== undefined) {
|
|
6259
|
+
const last = keylessLevelOne.partitions.at(-1);
|
|
6260
|
+
// A partial tail is extended. An oversized legacy anchor is included once so this fold
|
|
6261
|
+
// heals it into bounded partitions; a full tail stays immutable and new rows start after it.
|
|
6262
|
+
const absorbsTail = last !== undefined && (last.rowCount < partitionRows || last.rowCount > partitionRows);
|
|
6263
|
+
anchors = absorbsTail ? [last] : [];
|
|
6264
|
+
rechunkPartitioning = {
|
|
6265
|
+
partitionRows,
|
|
6266
|
+
nextLevelZeroOrder: level0Selection.nextLogicalOrder ?? version + 1,
|
|
6267
|
+
};
|
|
6268
|
+
}
|
|
6269
|
+
const anchorMeasurement = await this.#measureCompactionSources(anchors, level0Selection.blockIds, snapshot);
|
|
6270
|
+
const selection = {
|
|
6271
|
+
sourceSegments: [...anchors, ...level0Selection.segments],
|
|
6272
|
+
level0SourceStoredBytes: level0Selection.storedBytes,
|
|
6273
|
+
anchorSourceStoredBytes: anchorMeasurement.storedBytes,
|
|
6274
|
+
};
|
|
5494
6275
|
const sourceSegments = selection.sourceSegments;
|
|
5495
6276
|
const sourceBlockIds = uniqueSegmentBlockIds(sourceSegments);
|
|
5496
6277
|
const hasContiguousSourceRowIds = hasContiguousRowIds(sourceSegments);
|
|
5497
6278
|
const hasPositiveSourceRowIds = (sourceSegments[0]?.rowIdStart ?? 0n) > 0n;
|
|
5498
|
-
// A keyed
|
|
5499
|
-
//
|
|
5500
|
-
|
|
6279
|
+
// A keyed fold always merges: one uniform partition shape (a full-row base with row-ID
|
|
6280
|
+
// spans, bounded by `partitionRows`) regardless of whether the selected prefix happens to
|
|
6281
|
+
// be pure inserts.
|
|
6282
|
+
const requiresMerge = keyedLevelTwo || keyedLevelOne !== undefined
|
|
5501
6283
|
? true
|
|
5502
6284
|
: targetLevel === 1 &&
|
|
5503
|
-
|
|
5504
|
-
(!hasContiguousSourceRowIds && table.uniqueKeyColumnId !== undefined));
|
|
6285
|
+
sourceSegments.some((segment) => (segment.kind ?? "insert") !== "insert" || segment.rowIdSpans !== undefined);
|
|
5505
6286
|
if (!requiresMerge &&
|
|
5506
6287
|
(!hasContiguousSourceRowIds || (targetLevel === 2 && !hasPositiveSourceRowIds))) {
|
|
5507
6288
|
return compactTableSkipped(table.name, "non-contiguous-row-ids", sourceSegments, sourceBlockIds, version);
|
|
5508
6289
|
}
|
|
5509
|
-
if (version === null)
|
|
5510
|
-
throw new Error("Visible compaction segments require a manifest");
|
|
5511
|
-
const targetBlockBytes = positiveWholeNumber(options.targetBlockBytes ?? DEFAULT_COMPACTION_TARGET_BLOCK_BYTES, "Compaction target block bytes");
|
|
5512
|
-
if (targetBlockBytes > MAX_COMPACTION_TARGET_BLOCK_BYTES) {
|
|
5513
|
-
throw new RangeError(`Compaction target block bytes cannot exceed ${String(MAX_COMPACTION_TARGET_BLOCK_BYTES)}`);
|
|
5514
|
-
}
|
|
5515
|
-
const outputCompression = validateCompression(options.outputCompression ?? "gzip", "Compaction output compression");
|
|
5516
|
-
if (getCompressionMemoryBound(outputCompression, targetBlockBytes).maximumOutputBytes >
|
|
5517
|
-
MAX_COMPACTION_TARGET_BLOCK_BYTES) {
|
|
5518
|
-
throw new RangeError(`Compaction target block bytes exceed the ${outputCompression} worst-case format limit`);
|
|
5519
|
-
}
|
|
5520
|
-
const memoryBudgetBytes = positiveWholeNumber(options.memoryBudgetBytes ?? DEFAULT_COMPACTION_MEMORY_BUDGET_BYTES, "Compaction memory budget");
|
|
5521
6290
|
let mergePlan;
|
|
5522
6291
|
if (requiresMerge) {
|
|
5523
6292
|
try {
|
|
5524
|
-
mergePlan = await this.#createMergeCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot);
|
|
6293
|
+
mergePlan = await this.#createMergeCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, partitioning);
|
|
5525
6294
|
}
|
|
5526
6295
|
catch (error) {
|
|
5527
6296
|
// A keyed L2 prefix whose mutations reference keys living in already-published
|
|
@@ -5534,7 +6303,7 @@ export class MinnowDatabase {
|
|
|
5534
6303
|
}
|
|
5535
6304
|
}
|
|
5536
6305
|
const rewritePlan = mergePlan ??
|
|
5537
|
-
(await this.#createRechunkCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot));
|
|
6306
|
+
(await this.#createRechunkCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, rechunkPartitioning));
|
|
5538
6307
|
const minimumMemoryBytes = compactionMinimumMemoryBytes(rewritePlan);
|
|
5539
6308
|
if (minimumMemoryBytes > memoryBudgetBytes) {
|
|
5540
6309
|
throw new CompactionMemoryBudgetError(memoryBudgetBytes, minimumMemoryBytes);
|
|
@@ -5560,7 +6329,7 @@ export class MinnowDatabase {
|
|
|
5560
6329
|
const priorAttemptOutputStoredBytes = (await this.store.listCompactionJobs(table.id))
|
|
5561
6330
|
.filter((candidate) => (candidate.state === "cancelled" || candidate.state === "aborted") &&
|
|
5562
6331
|
(candidate.id === baseJobId || candidate.id.startsWith(`${baseJobId}/retry/`)))
|
|
5563
|
-
.reduce((
|
|
6332
|
+
.reduce((largest, candidate) => Math.max(largest, safeWholeNumberSum([candidate.priorAttemptOutputStoredBytes ?? 0, candidate.outputStoredBytes], "Compaction prior-attempt output stored bytes")), 0);
|
|
5564
6333
|
const maximumOutputStoredBytes = Math.max(0, floorWholeNumberProduct(selection.level0SourceStoredBytes, maxWriteAmplification, "Compaction maximum output stored bytes") - priorAttemptOutputStoredBytes);
|
|
5565
6334
|
const plannedOutputStoredBytesUpperBound = await this.#plannedPhysicalOutputStoredBytesUpperBound(rewritePlan, snapshot);
|
|
5566
6335
|
levelTwoBudget = {
|
|
@@ -5646,11 +6415,40 @@ export class MinnowDatabase {
|
|
|
5646
6415
|
throw error;
|
|
5647
6416
|
}
|
|
5648
6417
|
}
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
6418
|
+
/**
|
|
6419
|
+
* Sums the stored bytes of the given segments' blocks, refusing a block that appears twice
|
|
6420
|
+
* among them or in `seenBlockIds` — a source block may only be superseded once.
|
|
6421
|
+
*/
|
|
6422
|
+
async #measureCompactionSources(segments, seenBlockIds, snapshot) {
|
|
6423
|
+
let total = 0;
|
|
6424
|
+
const blockIds = [];
|
|
6425
|
+
const measuredBlockIds = new Set();
|
|
6426
|
+
for (const segment of segments) {
|
|
6427
|
+
for (const blockId of Object.values(segment.columnBlockIds).flat()) {
|
|
6428
|
+
if (seenBlockIds.has(blockId) || measuredBlockIds.has(blockId)) {
|
|
6429
|
+
throw new Error(`Compaction source block is referenced more than once: ${blockId}`);
|
|
6430
|
+
}
|
|
6431
|
+
measuredBlockIds.add(blockId);
|
|
6432
|
+
blockIds.push(blockId);
|
|
6433
|
+
await this.#renewInternalLeaseIfNeeded(snapshot);
|
|
6434
|
+
const bytes = await this.store.getBlock(blockId);
|
|
6435
|
+
if (bytes === undefined)
|
|
6436
|
+
throw new Error(`Compaction source block is missing: ${blockId}`);
|
|
6437
|
+
total = safeWholeNumberSum([total, bytes.byteLength], "Compaction selected stored bytes");
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
return { storedBytes: total, blockIds };
|
|
6441
|
+
}
|
|
6442
|
+
/**
|
|
6443
|
+
* The oldest level-zero prefix one job promotes: whole equal-order groups, at least the
|
|
6444
|
+
* minimum, and past it no more than the segment and stored-byte ceilings allow. Also reports
|
|
6445
|
+
* the order of the first segment left behind, which bounds the orders a fold may publish.
|
|
6446
|
+
*/
|
|
6447
|
+
async #selectLevelZeroSources(level0Segments, minimumLevel0Segments, maxLevel0Segments, maxLevel0StoredBytes, snapshot) {
|
|
6448
|
+
const transactions = new Map((await this.#transactionRecordsForSegments(level0Segments)).map((record) => [
|
|
6449
|
+
record.id,
|
|
6450
|
+
record,
|
|
6451
|
+
]));
|
|
5654
6452
|
const logicalOrder = (segment) => {
|
|
5655
6453
|
const owner = transactions.get(segment.transactionId);
|
|
5656
6454
|
if (owner?.status !== "committed" || owner.committedVersion === null) {
|
|
@@ -5659,42 +6457,10 @@ export class MinnowDatabase {
|
|
|
5659
6457
|
return segment.logicalOrder ?? owner.committedVersion;
|
|
5660
6458
|
};
|
|
5661
6459
|
const seenBlockIds = new Set();
|
|
5662
|
-
const
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
let duplicateBlockId = null;
|
|
5667
|
-
for (const segment of segments) {
|
|
5668
|
-
for (const blockId of Object.values(segment.columnBlockIds).flat()) {
|
|
5669
|
-
if (seenBlockIds.has(blockId) || measuredBlockIds.has(blockId)) {
|
|
5670
|
-
duplicateBlockId ??= blockId;
|
|
5671
|
-
}
|
|
5672
|
-
measuredBlockIds.add(blockId);
|
|
5673
|
-
blockIds.push(blockId);
|
|
5674
|
-
await this.#renewInternalLeaseIfNeeded(snapshot);
|
|
5675
|
-
const bytes = await this.store.getBlock(blockId);
|
|
5676
|
-
if (bytes === undefined)
|
|
5677
|
-
throw new Error(`Compaction source block is missing: ${blockId}`);
|
|
5678
|
-
total = safeWholeNumberSum([total, bytes.byteLength], "Compaction selected stored bytes");
|
|
5679
|
-
}
|
|
5680
|
-
}
|
|
5681
|
-
return { storedBytes: total, blockIds, duplicateBlockId };
|
|
5682
|
-
};
|
|
5683
|
-
const acceptMeasurement = (measurement) => {
|
|
5684
|
-
if (measurement.duplicateBlockId !== null) {
|
|
5685
|
-
throw new Error(`Compaction source block is referenced more than once: ${measurement.duplicateBlockId}`);
|
|
5686
|
-
}
|
|
5687
|
-
measurement.blockIds.forEach((blockId) => seenBlockIds.add(blockId));
|
|
5688
|
-
};
|
|
5689
|
-
let anchorSourceStoredBytes = 0;
|
|
5690
|
-
if (anchor !== undefined) {
|
|
5691
|
-
const anchorMeasurement = await measureStoredBytes([anchor]);
|
|
5692
|
-
acceptMeasurement(anchorMeasurement);
|
|
5693
|
-
anchorSourceStoredBytes = anchorMeasurement.storedBytes;
|
|
5694
|
-
}
|
|
5695
|
-
const selectedLevel0 = [];
|
|
5696
|
-
let level0SourceStoredBytes = 0;
|
|
5697
|
-
for (let start = 0; start < level0Segments.length;) {
|
|
6460
|
+
const selected = [];
|
|
6461
|
+
let storedBytes = 0;
|
|
6462
|
+
let start = 0;
|
|
6463
|
+
while (start < level0Segments.length) {
|
|
5698
6464
|
const first = level0Segments[start];
|
|
5699
6465
|
if (first === undefined)
|
|
5700
6466
|
throw new Error("Compaction L0 source selection is unavailable");
|
|
@@ -5707,30 +6473,109 @@ export class MinnowDatabase {
|
|
|
5707
6473
|
end += 1;
|
|
5708
6474
|
}
|
|
5709
6475
|
const group = level0Segments.slice(start, end);
|
|
5710
|
-
if (
|
|
5711
|
-
|
|
6476
|
+
if (selected.length >= minimumLevel0Segments &&
|
|
6477
|
+
selected.length + group.length > maxLevel0Segments) {
|
|
5712
6478
|
break;
|
|
5713
6479
|
}
|
|
5714
|
-
const
|
|
5715
|
-
if (
|
|
5716
|
-
|
|
6480
|
+
const measurement = await this.#measureCompactionSources(group, seenBlockIds, snapshot);
|
|
6481
|
+
if (selected.length >= minimumLevel0Segments &&
|
|
6482
|
+
measurement.storedBytes > maxLevel0StoredBytes - storedBytes) {
|
|
5717
6483
|
break;
|
|
5718
6484
|
}
|
|
5719
|
-
|
|
5720
|
-
|
|
5721
|
-
|
|
6485
|
+
measurement.blockIds.forEach((blockId) => seenBlockIds.add(blockId));
|
|
6486
|
+
selected.push(...group);
|
|
6487
|
+
storedBytes = safeWholeNumberSum([storedBytes, measurement.storedBytes], "Compaction selected L0 stored bytes");
|
|
5722
6488
|
start = end;
|
|
5723
6489
|
}
|
|
5724
|
-
if (
|
|
6490
|
+
if (selected.length < minimumLevel0Segments) {
|
|
5725
6491
|
throw new Error("Compaction source selection did not satisfy its minimum L0 segment count");
|
|
5726
6492
|
}
|
|
6493
|
+
const next = level0Segments[start];
|
|
5727
6494
|
return {
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
6495
|
+
segments: selected,
|
|
6496
|
+
storedBytes,
|
|
6497
|
+
blockIds: seenBlockIds,
|
|
6498
|
+
nextLogicalOrder: next === undefined ? null : logicalOrder(next),
|
|
5731
6499
|
};
|
|
5732
6500
|
}
|
|
5733
|
-
|
|
6501
|
+
/**
|
|
6502
|
+
* Which level-one partitions the selected deltas reach into: those holding a key that some
|
|
6503
|
+
* delete, update, or upsert among them names. Inserts and upserts of new keys touch nothing;
|
|
6504
|
+
* their rows join the tail. Key blocks whose zone map rules every referenced key out are
|
|
6505
|
+
* skipped from the header, so a sorted key costs one decoded block per partition at most and
|
|
6506
|
+
* usually none; the referenced-key set is the same size the merge planner's is.
|
|
6507
|
+
*/
|
|
6508
|
+
async #touchedPartitionIds(keyColumn, partitions, level0Segments, memoryBudgetBytes, snapshot) {
|
|
6509
|
+
const touched = new Set();
|
|
6510
|
+
if (partitions.length === 0)
|
|
6511
|
+
return touched;
|
|
6512
|
+
const deltas = level0Segments.filter((segment) => mergeSourceReferencesKeys(segment.kind ?? "insert"));
|
|
6513
|
+
if (deltas.length === 0)
|
|
6514
|
+
return touched;
|
|
6515
|
+
const referencedBytes = safeWholeNumberProduct(deltas.reduce((total, segment) => total + segment.rowCount, 0), MERGE_PLANNER_KEY_BYTES, "Compaction referenced keys");
|
|
6516
|
+
if (referencedBytes > memoryBudgetBytes) {
|
|
6517
|
+
throw new CompactionMemoryBudgetError(memoryBudgetBytes, referencedBytes);
|
|
6518
|
+
}
|
|
6519
|
+
const referenced = new Set();
|
|
6520
|
+
for (const segment of deltas) {
|
|
6521
|
+
await this.#forEachSegmentKey(segment, keyColumn, snapshot, (value) => {
|
|
6522
|
+
referenced.add(overlayKeyOf(keyColumn.type, value));
|
|
6523
|
+
});
|
|
6524
|
+
}
|
|
6525
|
+
const predicate = touchedKeyPredicate(keyColumn, referenced);
|
|
6526
|
+
const descriptions = predicate === undefined
|
|
6527
|
+
? new Map()
|
|
6528
|
+
: await this.#zoneDescriptions(partitions.flatMap((partition) => partition.columnBlockIds[keyColumn.id] ?? []), snapshot);
|
|
6529
|
+
for (const partition of partitions) {
|
|
6530
|
+
const blockIds = partition.columnBlockIds[keyColumn.id] ?? [];
|
|
6531
|
+
if (blockIds.length === 0) {
|
|
6532
|
+
throw new Error(`Partition has no key column blocks: ${partition.id}`);
|
|
6533
|
+
}
|
|
6534
|
+
for (const blockId of blockIds) {
|
|
6535
|
+
const description = descriptions.get(blockId);
|
|
6536
|
+
if (predicate !== undefined &&
|
|
6537
|
+
description !== undefined &&
|
|
6538
|
+
!zoneMapCanMatch(description, predicate)) {
|
|
6539
|
+
continue;
|
|
6540
|
+
}
|
|
6541
|
+
await this.#renewInternalLeaseIfNeeded(snapshot);
|
|
6542
|
+
const bytes = await this.store.getBlock(blockId);
|
|
6543
|
+
if (bytes === undefined)
|
|
6544
|
+
throw new Error(`Compaction source block is missing: ${blockId}`);
|
|
6545
|
+
const decoded = await decodeBlock(bytes);
|
|
6546
|
+
if (decoded.column.type !== keyColumn.type) {
|
|
6547
|
+
throw new Error(`Compaction source block differs from table schema: ${blockId}`);
|
|
6548
|
+
}
|
|
6549
|
+
if (decoded.column.values.some((value) => referenced.has(overlayKeyOf(keyColumn.type, value)))) {
|
|
6550
|
+
touched.add(partition.id);
|
|
6551
|
+
break;
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6554
|
+
}
|
|
6555
|
+
return touched;
|
|
6556
|
+
}
|
|
6557
|
+
/** Decodes a segment's key column in row order, one block resident at a time. */
|
|
6558
|
+
async #forEachSegmentKey(segment, keyColumn, snapshot, action) {
|
|
6559
|
+
let rowIndex = 0;
|
|
6560
|
+
for (const blockId of segment.columnBlockIds[keyColumn.id] ?? []) {
|
|
6561
|
+
await this.#renewInternalLeaseIfNeeded(snapshot);
|
|
6562
|
+
const bytes = await this.store.getBlock(blockId);
|
|
6563
|
+
if (bytes === undefined)
|
|
6564
|
+
throw new Error(`Compaction source block is missing: ${blockId}`);
|
|
6565
|
+
const decoded = await decodeBlock(bytes);
|
|
6566
|
+
if (decoded.column.type !== keyColumn.type) {
|
|
6567
|
+
throw new Error(`Compaction source block differs from table schema: ${blockId}`);
|
|
6568
|
+
}
|
|
6569
|
+
for (const value of decoded.column.values) {
|
|
6570
|
+
action(value, rowIndex);
|
|
6571
|
+
rowIndex += 1;
|
|
6572
|
+
}
|
|
6573
|
+
}
|
|
6574
|
+
if (rowIndex !== segment.rowCount) {
|
|
6575
|
+
throw new Error(`Mutation segment key rows differ: ${segment.id}`);
|
|
6576
|
+
}
|
|
6577
|
+
}
|
|
6578
|
+
async #createRechunkCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, partitioning) {
|
|
5734
6579
|
const first = sourceSegments[0];
|
|
5735
6580
|
const last = sourceSegments[sourceSegments.length - 1];
|
|
5736
6581
|
if (first === undefined || last === undefined) {
|
|
@@ -5784,12 +6629,20 @@ export class MinnowDatabase {
|
|
|
5784
6629
|
throw new Error("Compaction could not estimate an output block size");
|
|
5785
6630
|
}
|
|
5786
6631
|
const rowsPerOutput = Math.max(1, Math.min(0xffff_ffff, Math.floor(targetBlockBytes / maximumEncodedBytesPerRow)));
|
|
6632
|
+
const logicalOrder = await this.#firstLogicalOrder(sourceSegments);
|
|
6633
|
+
const partitions = partitioning === undefined
|
|
6634
|
+
? undefined
|
|
6635
|
+
: planLinearOutputPartitions(totalRows, partitioning.partitionRows, logicalOrder, partitioning.nextLevelZeroOrder);
|
|
5787
6636
|
const estimatedOutputs = [];
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
6637
|
+
const outputRegions = partitions ?? [{ rowStart: 0, rowCount: totalRows }];
|
|
6638
|
+
for (const region of outputRegions) {
|
|
6639
|
+
const regionEnd = region.rowStart + region.rowCount;
|
|
6640
|
+
for (let rowStart = region.rowStart; rowStart < regionEnd; rowStart += rowsPerOutput) {
|
|
6641
|
+
estimatedOutputs.push({
|
|
6642
|
+
rowStart,
|
|
6643
|
+
rowCount: Math.min(rowsPerOutput, regionEnd - rowStart),
|
|
6644
|
+
});
|
|
6645
|
+
}
|
|
5793
6646
|
}
|
|
5794
6647
|
const outputs = await this.#refinePhysicalOutputWindows(rechunkPhysicalColumns(columns), estimatedOutputs, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot);
|
|
5795
6648
|
return {
|
|
@@ -5799,12 +6652,19 @@ export class MinnowDatabase {
|
|
|
5799
6652
|
totalRows,
|
|
5800
6653
|
rowIdStart: first.rowIdStart,
|
|
5801
6654
|
rowIdEndExclusive: last.rowIdEndExclusive,
|
|
5802
|
-
logicalOrder
|
|
6655
|
+
logicalOrder,
|
|
5803
6656
|
columns,
|
|
5804
6657
|
outputs,
|
|
6658
|
+
...(partitions === undefined ? {} : { partitions }),
|
|
5805
6659
|
};
|
|
5806
6660
|
}
|
|
5807
|
-
|
|
6661
|
+
/**
|
|
6662
|
+
* Plans a merge of the sources into one canonical output. With `partitioning`, the output
|
|
6663
|
+
* is also cut into level-one partitions: each rewritten source partition keeps its rows (and
|
|
6664
|
+
* its logical order) in place, new rows form the tail, and every run is chunked to at most
|
|
6665
|
+
* `partitionRows`, using fractional orders between unchanged neighbours.
|
|
6666
|
+
*/
|
|
6667
|
+
async #createMergeCompactionPlan(table, sourceSegments, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot, partitioning) {
|
|
5808
6668
|
const keyColumn = getUniqueKeyColumn(table);
|
|
5809
6669
|
if (keyColumn === undefined) {
|
|
5810
6670
|
throw new Error(`Mutation compaction requires a unique key: ${table.name}`);
|
|
@@ -5886,6 +6746,9 @@ export class MinnowDatabase {
|
|
|
5886
6746
|
const resolved = await this.#resolveMergeOutput(table, describedSegments, keyColumn, memoryBudgetBytes, snapshot);
|
|
5887
6747
|
const { columns, rowIdSpans, totalRows } = resolved;
|
|
5888
6748
|
const rowIdEnvelope = rowIdSpanEnvelope(rowIdSpans);
|
|
6749
|
+
const partitions = partitioning === undefined
|
|
6750
|
+
? undefined
|
|
6751
|
+
: planOutputPartitions(partitioning, describedSegments, resolved.sourceOutputRowStarts, totalRows);
|
|
5889
6752
|
let outputs = [];
|
|
5890
6753
|
if (totalRows > 0) {
|
|
5891
6754
|
let maximumEncodedBytesPerRow = 0;
|
|
@@ -5902,12 +6765,16 @@ export class MinnowDatabase {
|
|
|
5902
6765
|
throw new Error("Compaction could not estimate an output block size");
|
|
5903
6766
|
}
|
|
5904
6767
|
const rowsPerOutput = Math.max(1, Math.min(0xffff_ffff, Math.floor(targetBlockBytes / maximumEncodedBytesPerRow)));
|
|
6768
|
+
// Windows never straddle a partition: each partition's blocks are its own.
|
|
5905
6769
|
const estimatedOutputs = [];
|
|
5906
|
-
for (
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
6770
|
+
for (const region of partitions ?? [{ rowStart: 0, rowCount: totalRows }]) {
|
|
6771
|
+
const regionEnd = region.rowStart + region.rowCount;
|
|
6772
|
+
for (let rowStart = region.rowStart; rowStart < regionEnd; rowStart += rowsPerOutput) {
|
|
6773
|
+
estimatedOutputs.push({
|
|
6774
|
+
rowStart,
|
|
6775
|
+
rowCount: Math.min(rowsPerOutput, regionEnd - rowStart),
|
|
6776
|
+
});
|
|
6777
|
+
}
|
|
5911
6778
|
}
|
|
5912
6779
|
outputs = await this.#refinePhysicalOutputWindows(mergePhysicalColumns(columns, describedSegments), estimatedOutputs, targetBlockBytes, outputCompression, memoryBudgetBytes, snapshot);
|
|
5913
6780
|
}
|
|
@@ -5924,97 +6791,165 @@ export class MinnowDatabase {
|
|
|
5924
6791
|
sourceSegments: describedSegments,
|
|
5925
6792
|
columns,
|
|
5926
6793
|
outputs,
|
|
6794
|
+
...(partitions === undefined ? {} : { partitions }),
|
|
5927
6795
|
};
|
|
5928
6796
|
}
|
|
6797
|
+
/**
|
|
6798
|
+
* Replays the source segments' mutations into one canonical output order, in memory that
|
|
6799
|
+
* scales with the deltas rather than with the table.
|
|
6800
|
+
*
|
|
6801
|
+
* Every row of every row-bearing source (base, insert, upsert) gets a slot, numbered in
|
|
6802
|
+
* canonical source order, and the output is the live slots in slot order. A row can only be
|
|
6803
|
+
* referenced later through its key, and only delete, update, and upsert sources reference
|
|
6804
|
+
* keys, so the first pass collects those keys — the touched set — and the replay then tracks
|
|
6805
|
+
* slots for touched keys alone. An untouched row can never be deleted, patched, or replaced:
|
|
6806
|
+
* it passes through as part of a run, one output range per source block rather than one per
|
|
6807
|
+
* row. Memory is O(delta rows + touched rows) plus two bytes per slot.
|
|
6808
|
+
*
|
|
6809
|
+
* The semantics are those of a per-row replay:
|
|
6810
|
+
* - delete: the key's live slot dies; a later insert of the key takes a new slot.
|
|
6811
|
+
* - update: the named columns of the key's live slot come from the update row; the key must
|
|
6812
|
+
* be live.
|
|
6813
|
+
* - upsert: when the key is live, every column of that slot comes from the upsert row and the
|
|
6814
|
+
* upsert row's own slot dies, so the row keeps its position and row ID; otherwise the
|
|
6815
|
+
* upsert row is a new live row.
|
|
6816
|
+
* - insert/base: a new live row; a second live occurrence of a touched key is an error.
|
|
6817
|
+
*/
|
|
5929
6818
|
async #resolveMergeOutput(table, segments, keyColumn, memoryBudgetBytes, snapshot) {
|
|
5930
6819
|
const plannerMemoryBytes = mergePlannerMemoryBound(table, segments, keyColumn.id);
|
|
5931
6820
|
if (plannerMemoryBytes > memoryBudgetBytes) {
|
|
5932
6821
|
throw new CompactionMemoryBudgetError(memoryBudgetBytes, plannerMemoryBytes);
|
|
5933
6822
|
}
|
|
5934
6823
|
const columnIndexById = new Map(table.columns.map((column, index) => [column.id, index]));
|
|
5935
|
-
|
|
5936
|
-
const
|
|
6824
|
+
// Pass 1: the keys any delta references, and each delta's keys in row order.
|
|
6825
|
+
const touched = new Set();
|
|
6826
|
+
const deltaKeys = new Map();
|
|
6827
|
+
for (const segment of segments) {
|
|
6828
|
+
if (!mergeSourceReferencesKeys(segment.kind))
|
|
6829
|
+
continue;
|
|
6830
|
+
const keys = [];
|
|
6831
|
+
await this.#forEachMergeSourceKey(segment, keyColumn, snapshot, (value) => {
|
|
6832
|
+
const key = overlayKeyOf(keyColumn.type, value);
|
|
6833
|
+
keys.push(key);
|
|
6834
|
+
touched.add(key);
|
|
6835
|
+
});
|
|
6836
|
+
deltaKeys.set(segment.segmentId, keys);
|
|
6837
|
+
}
|
|
6838
|
+
// Pass 2: replay into slot state.
|
|
6839
|
+
let slotCount = 0;
|
|
6840
|
+
for (const segment of segments) {
|
|
6841
|
+
if (mergeSourceBearsRows(segment.kind))
|
|
6842
|
+
slotCount += segment.rowCount;
|
|
6843
|
+
}
|
|
6844
|
+
const dead = new Uint8Array(slotCount);
|
|
6845
|
+
const patched = new Uint8Array(slotCount);
|
|
6846
|
+
const patches = new Map();
|
|
6847
|
+
const liveSlotByKey = new Map();
|
|
6848
|
+
let slotBase = 0;
|
|
5937
6849
|
for (const segment of segments) {
|
|
5938
6850
|
if (segment.kind === "delete") {
|
|
5939
|
-
|
|
5940
|
-
const
|
|
5941
|
-
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
6851
|
+
for (const key of deltaKeys.get(segment.segmentId) ?? []) {
|
|
6852
|
+
const slot = liveSlotByKey.get(key);
|
|
6853
|
+
if (slot === undefined)
|
|
6854
|
+
continue;
|
|
6855
|
+
dead[slot] = 1;
|
|
6856
|
+
patched[slot] = 0;
|
|
6857
|
+
patches.delete(slot);
|
|
6858
|
+
liveSlotByKey.delete(key);
|
|
6859
|
+
}
|
|
5946
6860
|
continue;
|
|
5947
6861
|
}
|
|
5948
6862
|
if (segment.kind === "update") {
|
|
5949
|
-
const
|
|
6863
|
+
const changedColumns = segment.columns
|
|
5950
6864
|
.map((column) => column.columnId)
|
|
5951
|
-
.filter((columnId) => columnId !== keyColumn.id)
|
|
5952
|
-
|
|
5953
|
-
const
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
6865
|
+
.filter((columnId) => columnId !== keyColumn.id)
|
|
6866
|
+
.map((columnId) => {
|
|
6867
|
+
const columnIndex = columnIndexById.get(columnId);
|
|
6868
|
+
if (columnIndex === undefined) {
|
|
6869
|
+
throw new Error(`Mutation compaction column is missing: ${columnId}`);
|
|
6870
|
+
}
|
|
6871
|
+
return { columnId, columnIndex };
|
|
6872
|
+
});
|
|
6873
|
+
const keys = deltaKeys.get(segment.segmentId) ?? [];
|
|
6874
|
+
for (let rowIndex = 0; rowIndex < keys.length; rowIndex += 1) {
|
|
6875
|
+
const key = keys[rowIndex];
|
|
6876
|
+
const slot = key === undefined ? undefined : liveSlotByKey.get(key);
|
|
6877
|
+
if (slot === undefined) {
|
|
5957
6878
|
throw new Error(`Update segment references a missing key: ${segment.segmentId}`);
|
|
5958
6879
|
}
|
|
5959
|
-
|
|
5960
|
-
|
|
5961
|
-
|
|
5962
|
-
|
|
5963
|
-
|
|
5964
|
-
existing.sources[columnIndex] = mergeSourceAt(segment, columnId, rowIndex);
|
|
6880
|
+
let patch = patches.get(slot);
|
|
6881
|
+
if (patch === undefined) {
|
|
6882
|
+
patch = new Array(table.columns.length).fill(undefined);
|
|
6883
|
+
patches.set(slot, patch);
|
|
6884
|
+
patched[slot] = 1;
|
|
5965
6885
|
}
|
|
5966
|
-
|
|
6886
|
+
for (const { columnId, columnIndex } of changedColumns) {
|
|
6887
|
+
patch[columnIndex] = mergeSourceAt(segment, columnId, rowIndex);
|
|
6888
|
+
}
|
|
6889
|
+
}
|
|
5967
6890
|
continue;
|
|
5968
6891
|
}
|
|
5969
|
-
|
|
5970
|
-
|
|
5971
|
-
|
|
5972
|
-
|
|
5973
|
-
|
|
5974
|
-
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
rows[existingIndex] = { rowId: existing.rowId, sources };
|
|
6892
|
+
// A row-bearing source: base, insert, or upsert.
|
|
6893
|
+
const base = slotBase;
|
|
6894
|
+
const visit = (key, rowIndex) => {
|
|
6895
|
+
if (!touched.has(key))
|
|
6896
|
+
return;
|
|
6897
|
+
const slot = base + rowIndex;
|
|
6898
|
+
const existing = liveSlotByKey.get(key);
|
|
6899
|
+
if (existing === undefined) {
|
|
6900
|
+
liveSlotByKey.set(key, slot);
|
|
5979
6901
|
return;
|
|
5980
6902
|
}
|
|
5981
|
-
if (
|
|
6903
|
+
if (segment.kind !== "upsert") {
|
|
5982
6904
|
throw new Error(`Insert segment contains a duplicate unique key: ${segment.segmentId}`);
|
|
5983
6905
|
}
|
|
5984
|
-
|
|
5985
|
-
|
|
5986
|
-
|
|
6906
|
+
patches.set(existing, table.columns.map((column) => mergeSourceAt(segment, column.id, rowIndex)));
|
|
6907
|
+
patched[existing] = 1;
|
|
6908
|
+
dead[slot] = 1;
|
|
6909
|
+
};
|
|
6910
|
+
const keys = deltaKeys.get(segment.segmentId);
|
|
6911
|
+
if (keys !== undefined) {
|
|
6912
|
+
keys.forEach(visit);
|
|
6913
|
+
}
|
|
6914
|
+
else if (touched.size > 0) {
|
|
6915
|
+
// With nothing referencing keys there is nothing to track: every row passes through.
|
|
6916
|
+
await this.#forEachMergeSourceKey(segment, keyColumn, snapshot, (value, rowIndex) => {
|
|
6917
|
+
visit(overlayKeyOf(keyColumn.type, value), rowIndex);
|
|
6918
|
+
});
|
|
6919
|
+
}
|
|
6920
|
+
slotBase += segment.rowCount;
|
|
5987
6921
|
}
|
|
5988
|
-
|
|
5989
|
-
|
|
5990
|
-
|
|
5991
|
-
|
|
5992
|
-
|
|
6922
|
+
touched.clear();
|
|
6923
|
+
liveSlotByKey.clear();
|
|
6924
|
+
deltaKeys.clear();
|
|
6925
|
+
// Pass 3: the live slots in slot order, as runs wherever nothing touched them.
|
|
6926
|
+
const output = new MergeOutputBuilder(table.columns);
|
|
6927
|
+
const sourceOutputRowStarts = new Map();
|
|
6928
|
+
slotBase = 0;
|
|
6929
|
+
for (const segment of segments) {
|
|
6930
|
+
if (!mergeSourceBearsRows(segment.kind))
|
|
5993
6931
|
continue;
|
|
5994
|
-
|
|
5995
|
-
|
|
5996
|
-
|
|
5997
|
-
const
|
|
5998
|
-
if (
|
|
5999
|
-
|
|
6000
|
-
|
|
6001
|
-
|
|
6002
|
-
|
|
6003
|
-
|
|
6004
|
-
|
|
6932
|
+
sourceOutputRowStarts.set(segment.segmentId, output.totalRows);
|
|
6933
|
+
let runStart = -1;
|
|
6934
|
+
for (let rowIndex = 0; rowIndex < segment.rowCount; rowIndex += 1) {
|
|
6935
|
+
const slot = slotBase + rowIndex;
|
|
6936
|
+
if (dead[slot] === 1 || patched[slot] === 1) {
|
|
6937
|
+
if (runStart >= 0) {
|
|
6938
|
+
output.appendRun(segment, runStart, rowIndex - runStart);
|
|
6939
|
+
runStart = -1;
|
|
6940
|
+
}
|
|
6941
|
+
if (patched[slot] === 1)
|
|
6942
|
+
output.appendPatchedRow(segment, rowIndex, patches.get(slot));
|
|
6943
|
+
continue;
|
|
6944
|
+
}
|
|
6945
|
+
if (runStart < 0)
|
|
6946
|
+
runStart = rowIndex;
|
|
6005
6947
|
}
|
|
6006
|
-
|
|
6948
|
+
if (runStart >= 0)
|
|
6949
|
+
output.appendRun(segment, runStart, segment.rowCount - runStart);
|
|
6950
|
+
slotBase += segment.rowCount;
|
|
6007
6951
|
}
|
|
6008
|
-
return {
|
|
6009
|
-
rowIdSpans,
|
|
6010
|
-
columns: table.columns.map((column, columnIndex) => {
|
|
6011
|
-
const sourceRanges = sourceRangesByColumn[columnIndex];
|
|
6012
|
-
if (sourceRanges === undefined)
|
|
6013
|
-
throw new Error("Mutation output column is missing");
|
|
6014
|
-
return { columnId: column.id, type: column.type, sourceRanges };
|
|
6015
|
-
}),
|
|
6016
|
-
totalRows,
|
|
6017
|
-
};
|
|
6952
|
+
return { ...output.finish(), sourceOutputRowStarts };
|
|
6018
6953
|
}
|
|
6019
6954
|
async #forEachMergeSourceKey(segment, column, snapshot, action) {
|
|
6020
6955
|
const planned = segment.columns.find((candidate) => candidate.columnId === column.id);
|
|
@@ -6264,56 +7199,32 @@ export class MinnowDatabase {
|
|
|
6264
7199
|
else {
|
|
6265
7200
|
if (outputSegmentId === null)
|
|
6266
7201
|
throw new Error("Compaction output segment ID is missing");
|
|
6267
|
-
const
|
|
6268
|
-
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
6278
|
-
|
|
6279
|
-
|
|
6280
|
-
|
|
6281
|
-
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
createdAt: this.#now().toISOString(),
|
|
6294
|
-
};
|
|
6295
|
-
const outputSegment = await this.store.getSegment(outputSegmentId);
|
|
6296
|
-
if (outputSegment === undefined) {
|
|
6297
|
-
await transaction.stageSegment(desiredOutputSegment);
|
|
6298
|
-
}
|
|
6299
|
-
else if (outputSegment.transactionId === transaction.id) {
|
|
6300
|
-
if (!sameCompactionSegment(outputSegment, desiredOutputSegment)) {
|
|
6301
|
-
throw new Error(`A resumed compaction segment differs: ${outputSegmentId}`);
|
|
6302
|
-
}
|
|
6303
|
-
await transaction.stageExistingSegment(outputSegmentId);
|
|
6304
|
-
}
|
|
6305
|
-
else {
|
|
6306
|
-
const owner = await this.store.getTransaction(outputSegment.transactionId);
|
|
6307
|
-
if ((owner !== undefined && owner.status !== "aborted") ||
|
|
6308
|
-
!sameCompactionSegment(outputSegment, desiredOutputSegment)) {
|
|
6309
|
-
throw new Error(`Compaction output segment cannot be adopted: ${outputSegmentId}`);
|
|
6310
|
-
}
|
|
6311
|
-
const visible = await this.#unprunedManifestContainsAll(expectedOutputIds);
|
|
6312
|
-
if (visible) {
|
|
6313
|
-
throw new Error(`Compaction output segment is already visible: ${outputSegmentId}`);
|
|
6314
|
-
}
|
|
6315
|
-
await this.store.removeSegment(outputSegmentId);
|
|
6316
|
-
await transaction.stageSegment(desiredOutputSegment);
|
|
7202
|
+
const createdAt = this.#now().toISOString();
|
|
7203
|
+
const desiredOutputSegments = rewritePlan.kind === "copy-v1"
|
|
7204
|
+
? [
|
|
7205
|
+
{
|
|
7206
|
+
id: outputSegmentId,
|
|
7207
|
+
tableId: table.id,
|
|
7208
|
+
transactionId: transaction.id,
|
|
7209
|
+
rowCount: outputRowCount,
|
|
7210
|
+
rowIdStart: first?.rowIdStart ?? 0n,
|
|
7211
|
+
rowIdEndExclusive: last?.rowIdEndExclusive ?? 0n,
|
|
7212
|
+
columnBlockIds: compactionOutputColumns(table, sourceSegments, job.id),
|
|
7213
|
+
kind: "insert",
|
|
7214
|
+
...(table.uniqueKeyColumnId === undefined
|
|
7215
|
+
? {}
|
|
7216
|
+
: { keyColumnId: table.uniqueKeyColumnId }),
|
|
7217
|
+
level: job.targetLevel,
|
|
7218
|
+
...(job.outputPartitionOrdinal === undefined
|
|
7219
|
+
? {}
|
|
7220
|
+
: { partitionOrdinal: job.outputPartitionOrdinal }),
|
|
7221
|
+
logicalOrder: await this.#firstLogicalOrder(sourceSegments),
|
|
7222
|
+
createdAt,
|
|
7223
|
+
},
|
|
7224
|
+
]
|
|
7225
|
+
: compactionOutputSegments(table, job, rewritePlan, transaction.id, createdAt);
|
|
7226
|
+
for (const desiredOutputSegment of desiredOutputSegments) {
|
|
7227
|
+
await this.#stageCompactionOutputSegment(transaction, desiredOutputSegment, expectedOutputIds);
|
|
6317
7228
|
}
|
|
6318
7229
|
}
|
|
6319
7230
|
if (job.state !== "ready") {
|
|
@@ -6327,19 +7238,30 @@ export class MinnowDatabase {
|
|
|
6327
7238
|
transaction.supersedeBlocks(job.sourceBlockIds);
|
|
6328
7239
|
transaction.markLogicallyUnchanged();
|
|
6329
7240
|
let manifest;
|
|
6330
|
-
|
|
6331
|
-
|
|
6332
|
-
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
7241
|
+
for (;;) {
|
|
7242
|
+
let publicationConflict;
|
|
7243
|
+
try {
|
|
7244
|
+
manifest = await transaction.commit();
|
|
7245
|
+
break;
|
|
7246
|
+
}
|
|
7247
|
+
catch (error) {
|
|
7248
|
+
if (!(error instanceof WriteConflictError))
|
|
7249
|
+
throw error;
|
|
7250
|
+
publicationConflict = error;
|
|
7251
|
+
}
|
|
7252
|
+
// Publication is logically neutral, so it may follow any number of concurrent data
|
|
7253
|
+
// commits while every source remains visible and in the same logical position. A single
|
|
7254
|
+
// retry is not sufficient: another tab (or this database's write queue) can win the
|
|
7255
|
+
// manifest CAS again between rebase and commit, leaving an otherwise complete job stuck
|
|
7256
|
+
// in `ready` after the last write. Keep rebasing until publication wins or a source
|
|
7257
|
+
// genuinely changes.
|
|
6336
7258
|
const current = await this.store.getCurrentManifest();
|
|
6337
7259
|
const currentIds = new Set(current?.blockIds ?? []);
|
|
6338
7260
|
if (job.sourceBlockIds.some((id) => !currentIds.has(id))) {
|
|
6339
7261
|
if (transaction.status === "active")
|
|
6340
7262
|
await transaction.abort();
|
|
6341
7263
|
job = await this.#abortCompactionJob(job, "Compaction sources changed before publication");
|
|
6342
|
-
throw new Error(job.error, { cause:
|
|
7264
|
+
throw new Error(job.error, { cause: publicationConflict });
|
|
6343
7265
|
}
|
|
6344
7266
|
const rebased = await transaction.rebase();
|
|
6345
7267
|
try {
|
|
@@ -6356,11 +7278,11 @@ export class MinnowDatabase {
|
|
|
6356
7278
|
if (transaction.status === "active")
|
|
6357
7279
|
await transaction.abort();
|
|
6358
7280
|
job = await this.#abortCompactionJob(job, `Compaction source is no longer visible: ${missingSourceId}`);
|
|
6359
|
-
throw new Error(job.error, { cause:
|
|
7281
|
+
throw new Error(job.error, { cause: publicationConflict });
|
|
6360
7282
|
}
|
|
6361
7283
|
transaction.supersedeBlocks(job.sourceBlockIds);
|
|
6362
7284
|
transaction.markLogicallyUnchanged();
|
|
6363
|
-
|
|
7285
|
+
await yieldToEventLoop();
|
|
6364
7286
|
}
|
|
6365
7287
|
job = await this.#markCompactionPublished(job, manifest.version);
|
|
6366
7288
|
return compactionProgress(table.name, job, this.#compactionResult(table, job, manifest.version));
|
|
@@ -6423,12 +7345,12 @@ export class MinnowDatabase {
|
|
|
6423
7345
|
const existing = await this.store.getBlock(outputBlockId);
|
|
6424
7346
|
let outputBytes;
|
|
6425
7347
|
if (existing === undefined) {
|
|
6426
|
-
outputBytes = await
|
|
7348
|
+
outputBytes = await this.#encodePreferredBlock(column.columnId, plan.outputCompression, built.physical.bytes.byteLength < GZIP_MINIMUM_INPUT_BYTES, (compression) => encodePhysicalBlock(built.physical, compression));
|
|
6427
7349
|
}
|
|
6428
7350
|
else {
|
|
6429
7351
|
const decoded = await decodePhysicalBlock(existing);
|
|
6430
7352
|
if (decoded.description.type !== column.type ||
|
|
6431
|
-
decoded.description.compression !==
|
|
7353
|
+
(plan.outputCompression === "raw" && decoded.description.compression !== "raw") ||
|
|
6432
7354
|
decoded.description.rowCount !== output.rowCount ||
|
|
6433
7355
|
!sameBytes(decoded.column.bytes, built.physical.bytes)) {
|
|
6434
7356
|
throw new Error(`A resumed compaction block differs: ${outputBlockId}`);
|
|
@@ -6542,7 +7464,17 @@ export class MinnowDatabase {
|
|
|
6542
7464
|
throw new Error(`Concurrent segment shares a compaction source block: ${segment.id}`);
|
|
6543
7465
|
}
|
|
6544
7466
|
}
|
|
6545
|
-
if (job.outputPartitionOrdinal
|
|
7467
|
+
if (job.outputPartitionOrdinal === undefined &&
|
|
7468
|
+
job.targetLevel === 1 &&
|
|
7469
|
+
plan.kind !== "copy-v1" &&
|
|
7470
|
+
plan.partitions !== undefined) {
|
|
7471
|
+
const table = await this.store.getTable(job.tableId);
|
|
7472
|
+
if (table === undefined)
|
|
7473
|
+
throw new Error(`Compaction table is missing: ${job.tableId}`);
|
|
7474
|
+
await this.#assertPartitionedLevelOneSnapshotOrder(job, plan, table, visibleSegments, transactions);
|
|
7475
|
+
return;
|
|
7476
|
+
}
|
|
7477
|
+
if (job.outputPartitionOrdinal !== undefined) {
|
|
6546
7478
|
if (plan.kind === "rechunk-v1") {
|
|
6547
7479
|
await this.#assertLevelTwoSnapshotOrder(job, plan, snapshot, transactions);
|
|
6548
7480
|
return;
|
|
@@ -6559,8 +7491,11 @@ export class MinnowDatabase {
|
|
|
6559
7491
|
const visibleById = new Map(visibleSegments.map((segment) => [segment.id, segment]));
|
|
6560
7492
|
for (const planned of plan.sourceSegments) {
|
|
6561
7493
|
const actual = visibleById.get(planned.segmentId);
|
|
6562
|
-
|
|
6563
|
-
|
|
7494
|
+
if (actual === undefined) {
|
|
7495
|
+
throw new Error(`Compaction source is no longer visible: ${planned.segmentId}`);
|
|
7496
|
+
}
|
|
7497
|
+
const owner = transactions.get(actual.transactionId);
|
|
7498
|
+
if (!sameMergeSourceSegment(actual, owner, planned)) {
|
|
6564
7499
|
throw new Error(`Compaction source segment differs from its plan: ${planned.segmentId}`);
|
|
6565
7500
|
}
|
|
6566
7501
|
}
|
|
@@ -6623,6 +7558,93 @@ export class MinnowDatabase {
|
|
|
6623
7558
|
}
|
|
6624
7559
|
}
|
|
6625
7560
|
}
|
|
7561
|
+
/**
|
|
7562
|
+
* The partitioned level-one rebase rule, shared by keyed merges and keyless rechunks. The
|
|
7563
|
+
* sources must be exactly as planned. Every partition the plan left alone must still be visible
|
|
7564
|
+
* and unchanged — they are read back from the planning snapshot's manifest, which the job
|
|
7565
|
+
* roots until it ends, so the check needs no record of its own. Every other visible segment
|
|
7566
|
+
* must be level-zero history committed after the latest source and ordered after every
|
|
7567
|
+
* partition the job publishes, so the output slots into the same place relative to the deltas
|
|
7568
|
+
* it did not absorb.
|
|
7569
|
+
*/
|
|
7570
|
+
async #assertPartitionedLevelOneSnapshotOrder(job, plan, table, visibleSegments, transactions) {
|
|
7571
|
+
const sourceIds = new Set(job.sourceSegmentIds);
|
|
7572
|
+
const visibleById = new Map(visibleSegments.map((segment) => [segment.id, segment]));
|
|
7573
|
+
const sourceManifest = await this.store.getManifest(job.sourceManifestVersion);
|
|
7574
|
+
if (sourceManifest === undefined || sourceManifest.prunedAt !== undefined) {
|
|
7575
|
+
throw new Error(`Compaction source manifest is unavailable: ${String(job.sourceManifestVersion)}`);
|
|
7576
|
+
}
|
|
7577
|
+
const plannedVisible = await this.#visibleSegmentRecords(table, new Snapshot(this.store, sourceManifest.version, sourceManifest.blockIds));
|
|
7578
|
+
const plannedById = new Map(plannedVisible.map((segment) => [segment.id, segment]));
|
|
7579
|
+
const plannedLayout = table.uniqueKeyColumnId === undefined
|
|
7580
|
+
? keylessLevelOneLayout(plannedVisible)
|
|
7581
|
+
: keyedLevelOneLayout(plannedVisible);
|
|
7582
|
+
if (plannedLayout === null)
|
|
7583
|
+
throw new Error("Compaction planned layout is no longer valid");
|
|
7584
|
+
let latestSource = null;
|
|
7585
|
+
if (plan.kind === "merge-v1") {
|
|
7586
|
+
for (const planned of plan.sourceSegments) {
|
|
7587
|
+
const actual = visibleById.get(planned.segmentId);
|
|
7588
|
+
if (actual === undefined) {
|
|
7589
|
+
throw new Error(`Compaction source is no longer visible: ${planned.segmentId}`);
|
|
7590
|
+
}
|
|
7591
|
+
const owner = transactions.get(actual.transactionId);
|
|
7592
|
+
if (!sameMergeSourceSegment(actual, owner, planned)) {
|
|
7593
|
+
throw new Error(`Compaction source segment differs from its plan: ${planned.segmentId}`);
|
|
7594
|
+
}
|
|
7595
|
+
}
|
|
7596
|
+
latestSource = plan.sourceSegments[plan.sourceSegments.length - 1] ?? null;
|
|
7597
|
+
}
|
|
7598
|
+
else {
|
|
7599
|
+
for (const id of job.sourceSegmentIds) {
|
|
7600
|
+
const actual = visibleById.get(id);
|
|
7601
|
+
const planned = plannedById.get(id);
|
|
7602
|
+
if (actual === undefined)
|
|
7603
|
+
throw new Error(`Compaction source is no longer visible: ${id}`);
|
|
7604
|
+
if (actual.transactionId !== planned?.transactionId ||
|
|
7605
|
+
!sameCompactionSegment(actual, planned)) {
|
|
7606
|
+
throw new Error(`Compaction source segment differs from its plan: ${id}`);
|
|
7607
|
+
}
|
|
7608
|
+
const tuple = sourceOrderTuple(actual, transactions, "Compaction source");
|
|
7609
|
+
if (latestSource === null || compareMergeSourceOrder(latestSource, tuple) < 0) {
|
|
7610
|
+
latestSource = tuple;
|
|
7611
|
+
}
|
|
7612
|
+
}
|
|
7613
|
+
}
|
|
7614
|
+
if (latestSource === null)
|
|
7615
|
+
throw new Error("Compaction source order is unavailable");
|
|
7616
|
+
const maxOutputOrder = plan.partitions === undefined
|
|
7617
|
+
? plan.logicalOrder
|
|
7618
|
+
: Math.max(plan.logicalOrder, ...plan.partitions.map((partition) => partition.logicalOrder));
|
|
7619
|
+
const retained = new Map(plannedLayout.partitions
|
|
7620
|
+
.filter((partition) => !sourceIds.has(partition.id))
|
|
7621
|
+
.map((partition) => [partition.id, partition]));
|
|
7622
|
+
for (const segment of visibleSegments) {
|
|
7623
|
+
if (sourceIds.has(segment.id))
|
|
7624
|
+
continue;
|
|
7625
|
+
if ((segment.level ?? 0) === 1) {
|
|
7626
|
+
const planned = retained.get(segment.id);
|
|
7627
|
+
if (planned?.transactionId !== segment.transactionId ||
|
|
7628
|
+
!sameCompactionSegment(segment, planned)) {
|
|
7629
|
+
throw new Error(`Concurrent segment is not a retained partition: ${segment.id}`);
|
|
7630
|
+
}
|
|
7631
|
+
continue;
|
|
7632
|
+
}
|
|
7633
|
+
if ((segment.level ?? 0) !== 0) {
|
|
7634
|
+
throw new Error(`Concurrent segment has an unsupported compaction level: ${segment.id}`);
|
|
7635
|
+
}
|
|
7636
|
+
const tuple = sourceOrderTuple(segment, transactions, "Concurrent compaction segment");
|
|
7637
|
+
if (tuple.logicalOrder <= maxOutputOrder ||
|
|
7638
|
+
compareMergeSourceOrder(latestSource, tuple) >= 0) {
|
|
7639
|
+
throw new Error(`Concurrent segment would reorder compaction output: ${segment.id}`);
|
|
7640
|
+
}
|
|
7641
|
+
}
|
|
7642
|
+
for (const id of retained.keys()) {
|
|
7643
|
+
if (!visibleById.has(id)) {
|
|
7644
|
+
throw new Error(`Retained compaction partition is no longer visible: ${id}`);
|
|
7645
|
+
}
|
|
7646
|
+
}
|
|
7647
|
+
}
|
|
6626
7648
|
async #assertLevelTwoSnapshotOrder(job, plan, snapshot, transactions) {
|
|
6627
7649
|
const table = await this.store.getTable(job.tableId);
|
|
6628
7650
|
if (table === undefined)
|
|
@@ -6777,6 +7799,35 @@ export class MinnowDatabase {
|
|
|
6777
7799
|
throw error;
|
|
6778
7800
|
}
|
|
6779
7801
|
}
|
|
7802
|
+
/**
|
|
7803
|
+
* Stages one output segment, reconciling with what a previous attempt left: the same
|
|
7804
|
+
* segment staged by this transaction is reused, one left by an aborted transaction is
|
|
7805
|
+
* adopted when it matches and was never published, anything else is an error.
|
|
7806
|
+
*/
|
|
7807
|
+
async #stageCompactionOutputSegment(transaction, desired, expectedOutputIds) {
|
|
7808
|
+
const existing = await this.store.getSegment(desired.id);
|
|
7809
|
+
if (existing === undefined) {
|
|
7810
|
+
await transaction.stageSegment(desired);
|
|
7811
|
+
return;
|
|
7812
|
+
}
|
|
7813
|
+
if (existing.transactionId === transaction.id) {
|
|
7814
|
+
if (!sameCompactionSegment(existing, desired)) {
|
|
7815
|
+
throw new Error(`A resumed compaction segment differs: ${desired.id}`);
|
|
7816
|
+
}
|
|
7817
|
+
await transaction.stageExistingSegment(desired.id);
|
|
7818
|
+
return;
|
|
7819
|
+
}
|
|
7820
|
+
const owner = await this.store.getTransaction(existing.transactionId);
|
|
7821
|
+
if ((owner !== undefined && owner.status !== "aborted") ||
|
|
7822
|
+
!sameCompactionSegment(existing, desired)) {
|
|
7823
|
+
throw new Error(`Compaction output segment cannot be adopted: ${desired.id}`);
|
|
7824
|
+
}
|
|
7825
|
+
if (await this.#unprunedManifestContainsAll(expectedOutputIds)) {
|
|
7826
|
+
throw new Error(`Compaction output segment is already visible: ${desired.id}`);
|
|
7827
|
+
}
|
|
7828
|
+
await this.store.removeSegment(desired.id);
|
|
7829
|
+
await transaction.stageSegment(desired);
|
|
7830
|
+
}
|
|
6780
7831
|
async #abortCompactionJob(job, error) {
|
|
6781
7832
|
return this.store.updateCompactionJob(job.id, job.revision, {
|
|
6782
7833
|
state: "aborted",
|
|
@@ -6803,6 +7854,7 @@ export class MinnowDatabase {
|
|
|
6803
7854
|
sourceSegmentCount: job.sourceSegmentIds.length,
|
|
6804
7855
|
sourceBlockCount: job.sourceBlockIds.length,
|
|
6805
7856
|
outputSegmentId: job.outputSegmentId,
|
|
7857
|
+
outputSegmentIds: compactionOutputSegmentIds(job),
|
|
6806
7858
|
outputBlockCount: job.outputBlockIds.length,
|
|
6807
7859
|
rowCount,
|
|
6808
7860
|
sourceStoredBytes: job.sourceStoredBytes,
|
|
@@ -8062,26 +9114,42 @@ export class MinnowDatabase {
|
|
|
8062
9114
|
* bytes and never correctness.
|
|
8063
9115
|
*/
|
|
8064
9116
|
async #encodeColumnBlock(columnId, input) {
|
|
8065
|
-
|
|
8066
|
-
|
|
9117
|
+
return this.#encodePreferredBlock(columnId, this.#compression, columnInputBytesBelow(input, GZIP_MINIMUM_INPUT_BYTES), (compression) => encodeBlock(input, compression));
|
|
9118
|
+
}
|
|
9119
|
+
/**
|
|
9120
|
+
* Applies the same adaptive gzip rule to ordinary writes and compaction output. `gzip` is a
|
|
9121
|
+
* preference, not a promise: tiny inputs and probes that save less than 20% stay raw. Only a
|
|
9122
|
+
* failed verdict is cached, so successful columns do not leave one map entry behind forever.
|
|
9123
|
+
*/
|
|
9124
|
+
async #encodePreferredBlock(columnId, preferred, belowMinimum, encode) {
|
|
9125
|
+
if (preferred !== "gzip")
|
|
9126
|
+
return encode(preferred);
|
|
9127
|
+
if (belowMinimum)
|
|
9128
|
+
return encode("raw");
|
|
8067
9129
|
const verdict = this.#gzipVerdicts.get(columnId);
|
|
8068
|
-
if (verdict !== undefined
|
|
8069
|
-
if (verdict
|
|
8070
|
-
|
|
8071
|
-
return
|
|
9130
|
+
if (verdict !== undefined) {
|
|
9131
|
+
if (verdict < GZIP_REPROBE_BLOCKS) {
|
|
9132
|
+
this.#gzipVerdicts.set(columnId, verdict + 1);
|
|
9133
|
+
return encode("raw");
|
|
8072
9134
|
}
|
|
8073
9135
|
this.#gzipVerdicts.delete(columnId);
|
|
8074
9136
|
}
|
|
8075
|
-
const bytes = await
|
|
9137
|
+
const bytes = await encode("gzip");
|
|
8076
9138
|
const description = inspectBlock(bytes);
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
this.#gzipVerdicts.
|
|
9139
|
+
const worthwhile = description.encodedLength >= bytes.byteLength * GZIP_WORTHWHILE_RATIO;
|
|
9140
|
+
if (!worthwhile) {
|
|
9141
|
+
if (!this.#gzipVerdicts.has(columnId) &&
|
|
9142
|
+
this.#gzipVerdicts.size >= GZIP_VERDICT_CACHE_LIMIT) {
|
|
9143
|
+
const oldest = this.#gzipVerdicts.keys().next().value;
|
|
9144
|
+
if (oldest !== undefined)
|
|
9145
|
+
this.#gzipVerdicts.delete(oldest);
|
|
9146
|
+
}
|
|
9147
|
+
this.#gzipVerdicts.set(columnId, 0);
|
|
8080
9148
|
// Nothing was gained, so hand back the uncompressed form rather than make every read of
|
|
8081
9149
|
// this block pay to inflate it.
|
|
8082
|
-
|
|
8083
|
-
return encodeBlock(input, "raw");
|
|
9150
|
+
return encode("raw");
|
|
8084
9151
|
}
|
|
9152
|
+
this.#gzipVerdicts.delete(columnId);
|
|
8085
9153
|
return bytes;
|
|
8086
9154
|
}
|
|
8087
9155
|
async #findTable(name) {
|
|
@@ -8542,6 +9610,22 @@ function batchKeys(table, input) {
|
|
|
8542
9610
|
}
|
|
8543
9611
|
return keys;
|
|
8544
9612
|
}
|
|
9613
|
+
/**
|
|
9614
|
+
* Whether a column block's logical payload is under `limit` bytes: strings by length (two
|
|
9615
|
+
* bytes a code unit, stopping as soon as the limit is reached), everything else eight bytes a
|
|
9616
|
+
* value. An estimate, for the write path's codec choice — not an encoded size.
|
|
9617
|
+
*/
|
|
9618
|
+
function columnInputBytesBelow(input, limit) {
|
|
9619
|
+
if (input.type !== "string")
|
|
9620
|
+
return input.values.length * 8 < limit;
|
|
9621
|
+
let bytes = 0;
|
|
9622
|
+
for (const value of input.values) {
|
|
9623
|
+
bytes += 8 + (value === null ? 0 : value.length * 2);
|
|
9624
|
+
if (bytes >= limit)
|
|
9625
|
+
return false;
|
|
9626
|
+
}
|
|
9627
|
+
return true;
|
|
9628
|
+
}
|
|
8545
9629
|
/** `keyToken` for values that may not encode: undefined instead of a thrown encoding error. */
|
|
8546
9630
|
function tryKeyToken(type, value) {
|
|
8547
9631
|
try {
|
|
@@ -8551,6 +9635,34 @@ function tryKeyToken(type, value) {
|
|
|
8551
9635
|
return undefined;
|
|
8552
9636
|
}
|
|
8553
9637
|
}
|
|
9638
|
+
/**
|
|
9639
|
+
* Whether a table's visible segments warrant a background fold: enough segments for a scan to
|
|
9640
|
+
* pay per-segment overhead, or enough deltas that every query replays a history. Counted in
|
|
9641
|
+
* segments, not rows — a handful of deltas costs little however many rows they hold, and
|
|
9642
|
+
* folding rewrites the table's anchor, so it is reserved for when the count has built up.
|
|
9643
|
+
*/
|
|
9644
|
+
/**
|
|
9645
|
+
* Whether a table's visible history warrants a background fold: enough level-zero segments to
|
|
9646
|
+
* fragment a scan, or enough deltas to cost one. Partitions compaction itself published
|
|
9647
|
+
* (level one and above) are the folded state, not fragmentation, and do not count — a large
|
|
9648
|
+
* keyed table is many partitions by design.
|
|
9649
|
+
*/
|
|
9650
|
+
function autoCompactionDue(segments) {
|
|
9651
|
+
let levelZero = 0;
|
|
9652
|
+
let deltas = 0;
|
|
9653
|
+
for (const segment of segments) {
|
|
9654
|
+
if ((segment.level ?? 0) === 0)
|
|
9655
|
+
levelZero += 1;
|
|
9656
|
+
const kind = segment.kind ?? "insert";
|
|
9657
|
+
if (kind !== "insert" && kind !== "base")
|
|
9658
|
+
deltas += 1;
|
|
9659
|
+
}
|
|
9660
|
+
return levelZero >= AUTO_COMPACT_SCAN_SEGMENTS || deltas >= AUTO_COMPACT_DELTA_SEGMENTS;
|
|
9661
|
+
}
|
|
9662
|
+
/** A macrotask boundary, so background work lets queued queries and writes run between steps. */
|
|
9663
|
+
function yieldToEventLoop() {
|
|
9664
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
9665
|
+
}
|
|
8554
9666
|
function keyToken(type, value) {
|
|
8555
9667
|
if (value === null)
|
|
8556
9668
|
throw new TypeError("Unique key cannot be null");
|
|
@@ -8578,6 +9690,12 @@ function keyToken(type, value) {
|
|
|
8578
9690
|
function formatValue(value) {
|
|
8579
9691
|
return value instanceof Date ? value.toISOString() : String(value);
|
|
8580
9692
|
}
|
|
9693
|
+
function nonNegativeWholeNumber(value, name) {
|
|
9694
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
9695
|
+
throw new RangeError(`${name} must be a non-negative whole number`);
|
|
9696
|
+
}
|
|
9697
|
+
return value;
|
|
9698
|
+
}
|
|
8581
9699
|
function positiveWholeNumber(value, name) {
|
|
8582
9700
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
8583
9701
|
throw new RangeError(`${name} must be a positive whole number`);
|
|
@@ -9087,6 +10205,229 @@ function estimatedColumnarBytes(segments, columns) {
|
|
|
9087
10205
|
rowWidth += column.type === "string" ? 32 : 8;
|
|
9088
10206
|
return rows * Math.max(rowWidth, 1);
|
|
9089
10207
|
}
|
|
10208
|
+
/** Whether a visible segment is a delete or update delta rather than appended rows. */
|
|
10209
|
+
function mutationSegmentKind(segment) {
|
|
10210
|
+
const kind = segment.kind ?? "insert";
|
|
10211
|
+
return kind !== "insert" && kind !== "base";
|
|
10212
|
+
}
|
|
10213
|
+
const BYTE_POPCOUNT = new Uint8Array(256).map((_, byte) => {
|
|
10214
|
+
let count = 0;
|
|
10215
|
+
for (let value = byte; value !== 0; value &= value - 1)
|
|
10216
|
+
count += 1;
|
|
10217
|
+
return count;
|
|
10218
|
+
});
|
|
10219
|
+
/** Set bits in `bitmap` over bit indexes `[from, to)`. */
|
|
10220
|
+
function bitmapCountRange(bitmap, from, to) {
|
|
10221
|
+
let count = 0;
|
|
10222
|
+
let index = from;
|
|
10223
|
+
while (index < to && (index & 7) !== 0) {
|
|
10224
|
+
if (bitmapHasValue(bitmap, index))
|
|
10225
|
+
count += 1;
|
|
10226
|
+
index += 1;
|
|
10227
|
+
}
|
|
10228
|
+
while (index + 8 <= to) {
|
|
10229
|
+
count += BYTE_POPCOUNT[bitmap[index >>> 3] ?? 0] ?? 0;
|
|
10230
|
+
index += 8;
|
|
10231
|
+
}
|
|
10232
|
+
while (index < to) {
|
|
10233
|
+
if (bitmapHasValue(bitmap, index))
|
|
10234
|
+
count += 1;
|
|
10235
|
+
index += 1;
|
|
10236
|
+
}
|
|
10237
|
+
return count;
|
|
10238
|
+
}
|
|
10239
|
+
/** The first index in ascending `sorted` whose value is at least `value`. */
|
|
10240
|
+
function sortedLowerBound(sorted, value) {
|
|
10241
|
+
let low = 0;
|
|
10242
|
+
let high = sorted.length;
|
|
10243
|
+
while (low < high) {
|
|
10244
|
+
const middle = (low + high) >>> 1;
|
|
10245
|
+
if ((sorted[middle] ?? 0) < value)
|
|
10246
|
+
low = middle + 1;
|
|
10247
|
+
else
|
|
10248
|
+
high = middle;
|
|
10249
|
+
}
|
|
10250
|
+
return low;
|
|
10251
|
+
}
|
|
10252
|
+
/** Members of ascending `sorted` in `[from, to)`. */
|
|
10253
|
+
function sortedCountRange(sorted, from, to) {
|
|
10254
|
+
return sortedLowerBound(sorted, to) - sortedLowerBound(sorted, from);
|
|
10255
|
+
}
|
|
10256
|
+
function overlayWindowRuns(dead, patchedSlots, from, to, patchedInWindow) {
|
|
10257
|
+
const steps = [];
|
|
10258
|
+
let nextPatched = patchedInWindow > 0 ? sortedLowerBound(patchedSlots, from) : -1;
|
|
10259
|
+
let row = from;
|
|
10260
|
+
while (row < to) {
|
|
10261
|
+
// Dead rows, eight at a time where a whole byte is dead.
|
|
10262
|
+
if (bitmapHasValue(dead, row)) {
|
|
10263
|
+
row += 1;
|
|
10264
|
+
while (row < to && (row & 7) === 0 && dead[row >>> 3] === 0xff && row + 8 <= to)
|
|
10265
|
+
row += 8;
|
|
10266
|
+
while (row < to && bitmapHasValue(dead, row))
|
|
10267
|
+
row += 1;
|
|
10268
|
+
continue;
|
|
10269
|
+
}
|
|
10270
|
+
const patchedRow = nextPatched >= 0 ? (patchedSlots[nextPatched] ?? to) : to;
|
|
10271
|
+
if (row === patchedRow) {
|
|
10272
|
+
steps.push(row, 0);
|
|
10273
|
+
row += 1;
|
|
10274
|
+
nextPatched += 1;
|
|
10275
|
+
if (nextPatched >= patchedSlots.length)
|
|
10276
|
+
nextPatched = -1;
|
|
10277
|
+
continue;
|
|
10278
|
+
}
|
|
10279
|
+
// A live run: up to the next patched row, the window end, or the next dead row — live
|
|
10280
|
+
// rows are consecutive except where a delete cut them, and whole live bytes skip in one.
|
|
10281
|
+
const limit = Math.min(to, patchedRow);
|
|
10282
|
+
const runStart = row;
|
|
10283
|
+
row += 1;
|
|
10284
|
+
while (row < limit && (row & 7) === 0 && row + 8 <= limit && dead[row >>> 3] === 0)
|
|
10285
|
+
row += 8;
|
|
10286
|
+
while (row < limit && !bitmapHasValue(dead, row))
|
|
10287
|
+
row += 1;
|
|
10288
|
+
steps.push(runStart, row - runStart);
|
|
10289
|
+
}
|
|
10290
|
+
return steps;
|
|
10291
|
+
}
|
|
10292
|
+
/**
|
|
10293
|
+
* Copies `length` bits from `source` at bit `sourceStart` to `target` at bit `targetStart`,
|
|
10294
|
+
* whole bytes at a time once the target is byte-aligned: the target bytes it overwrites lie
|
|
10295
|
+
* entirely inside the copied range, so the target's other bits are left alone. This is what
|
|
10296
|
+
* makes a validity copy proportional to bytes rather than to cells.
|
|
10297
|
+
*/
|
|
10298
|
+
function copyBitRun(source, sourceStart, target, targetStart, length) {
|
|
10299
|
+
let remaining = length;
|
|
10300
|
+
let from = sourceStart;
|
|
10301
|
+
let to = targetStart;
|
|
10302
|
+
while (remaining > 0 && (to & 7) !== 0) {
|
|
10303
|
+
if (bitmapHasValue(source, from))
|
|
10304
|
+
setBitmapValue(target, to);
|
|
10305
|
+
from += 1;
|
|
10306
|
+
to += 1;
|
|
10307
|
+
remaining -= 1;
|
|
10308
|
+
}
|
|
10309
|
+
const shift = from & 7;
|
|
10310
|
+
if (shift === 0) {
|
|
10311
|
+
const bytes = remaining >>> 3;
|
|
10312
|
+
if (bytes > 0) {
|
|
10313
|
+
target.set(source.subarray(from >>> 3, (from >>> 3) + bytes), to >>> 3);
|
|
10314
|
+
from += bytes * 8;
|
|
10315
|
+
to += bytes * 8;
|
|
10316
|
+
remaining -= bytes * 8;
|
|
10317
|
+
}
|
|
10318
|
+
}
|
|
10319
|
+
else {
|
|
10320
|
+
while (remaining >= 8) {
|
|
10321
|
+
const sourceByte = from >>> 3;
|
|
10322
|
+
target[to >>> 3] =
|
|
10323
|
+
(((source[sourceByte] ?? 0) >>> shift) | ((source[sourceByte + 1] ?? 0) << (8 - shift))) &
|
|
10324
|
+
0xff;
|
|
10325
|
+
from += 8;
|
|
10326
|
+
to += 8;
|
|
10327
|
+
remaining -= 8;
|
|
10328
|
+
}
|
|
10329
|
+
}
|
|
10330
|
+
while (remaining > 0) {
|
|
10331
|
+
if (bitmapHasValue(source, from))
|
|
10332
|
+
setBitmapValue(target, to);
|
|
10333
|
+
from += 1;
|
|
10334
|
+
to += 1;
|
|
10335
|
+
remaining -= 1;
|
|
10336
|
+
}
|
|
10337
|
+
}
|
|
10338
|
+
/**
|
|
10339
|
+
* An outer window that is the inner window's rows from `offset` on, by reference: typed-array
|
|
10340
|
+
* views over the resident block, and its dictionary as-is. Validity is a view too when the
|
|
10341
|
+
* offset falls on a byte, and otherwise the one small copy a bit offset forces.
|
|
10342
|
+
*/
|
|
10343
|
+
function overlayWindowView(inner, offset, rows, memory, column, reservations) {
|
|
10344
|
+
let validity;
|
|
10345
|
+
if ((offset & 7) === 0) {
|
|
10346
|
+
validity = inner.validity.subarray(offset >>> 3, (offset >>> 3) + Math.ceil(rows / 8));
|
|
10347
|
+
}
|
|
10348
|
+
else {
|
|
10349
|
+
validity = new Uint8Array(Math.ceil(rows / 8));
|
|
10350
|
+
reservations.push(memory.reserve(validity.byteLength, `Streamed window ${column.name}`));
|
|
10351
|
+
copyBitRun(inner.validity, offset, validity, 0, rows);
|
|
10352
|
+
}
|
|
10353
|
+
const fields = { validity, window: { start: 0, length: rows } };
|
|
10354
|
+
if (inner.kind === "string") {
|
|
10355
|
+
fields.codes = inner.codes.subarray(offset, offset + rows);
|
|
10356
|
+
fields.dictionary = inner.dictionary;
|
|
10357
|
+
}
|
|
10358
|
+
else {
|
|
10359
|
+
fields.values = inner.values.subarray(offset, offset + rows);
|
|
10360
|
+
}
|
|
10361
|
+
return fields;
|
|
10362
|
+
}
|
|
10363
|
+
/**
|
|
10364
|
+
* An outer window compacted from an inner window: live runs copied as slices, patched rows
|
|
10365
|
+
* read from their update vectors. A string window shares the inner dictionary unless a patch
|
|
10366
|
+
* has to add to it, in which case it copies the dictionary first.
|
|
10367
|
+
*/
|
|
10368
|
+
function overlayWindowCompacted(inner, innerWindowStart, steps, rows, patches, column, memory, reservations) {
|
|
10369
|
+
const validityBytes = Math.ceil(rows / 8);
|
|
10370
|
+
const typedBytes = validityBytes +
|
|
10371
|
+
(inner.kind === "boolean"
|
|
10372
|
+
? rows
|
|
10373
|
+
: inner.kind === "string"
|
|
10374
|
+
? rows * Uint32Array.BYTES_PER_ELEMENT
|
|
10375
|
+
: rows * Float64Array.BYTES_PER_ELEMENT);
|
|
10376
|
+
reservations.push(memory.reserve(typedBytes, `Streamed window ${column.name}`));
|
|
10377
|
+
const validity = new Uint8Array(validityBytes);
|
|
10378
|
+
const values = inner.kind === "boolean"
|
|
10379
|
+
? new Uint8Array(rows)
|
|
10380
|
+
: inner.kind === "string"
|
|
10381
|
+
? undefined
|
|
10382
|
+
: new Float64Array(rows);
|
|
10383
|
+
const codes = inner.kind === "string" ? new Uint32Array(rows) : undefined;
|
|
10384
|
+
codes?.fill(NULL_STRING_VECTOR_CODE);
|
|
10385
|
+
let dictionary = inner.kind === "string" ? inner.dictionary : undefined;
|
|
10386
|
+
let dictionaryIndex;
|
|
10387
|
+
let dictionaryCopied = false;
|
|
10388
|
+
const target = (codes !== undefined
|
|
10389
|
+
? { kind: "string", length: rows, validity, codes, dictionary: dictionary ?? [] }
|
|
10390
|
+
: { kind: inner.kind, length: rows, validity, values });
|
|
10391
|
+
let out = 0;
|
|
10392
|
+
for (let index = 0; index < steps.length; index += 2) {
|
|
10393
|
+
const start = steps[index] ?? 0;
|
|
10394
|
+
const length = steps[index + 1] ?? 0;
|
|
10395
|
+
const patch = length === 0 ? patches?.get(start)?.get(column.id) : undefined;
|
|
10396
|
+
if (patch === undefined) {
|
|
10397
|
+
const count = Math.max(1, length);
|
|
10398
|
+
copyVectorSpan(inner, start - innerWindowStart, count, target, out);
|
|
10399
|
+
out += count;
|
|
10400
|
+
continue;
|
|
10401
|
+
}
|
|
10402
|
+
if (target.kind === "string" && !dictionaryCopied) {
|
|
10403
|
+
// A patch value may be new to this window's dictionary, and the inner's belongs to the
|
|
10404
|
+
// buffer pool: copy before the first append, and index the copy for the lookups.
|
|
10405
|
+
dictionary = [...(dictionary ?? [])];
|
|
10406
|
+
target.dictionary = dictionary;
|
|
10407
|
+
dictionaryIndex = new Map(dictionary.map((value, code) => [value, code]));
|
|
10408
|
+
dictionaryCopied = true;
|
|
10409
|
+
}
|
|
10410
|
+
copyColumnVectorValue(patch.vector, patch.row, target, out, dictionaryIndex);
|
|
10411
|
+
out += 1;
|
|
10412
|
+
}
|
|
10413
|
+
if (out !== rows)
|
|
10414
|
+
throw new Error(`Column row count mismatch: ${column.name}`);
|
|
10415
|
+
if (dictionaryCopied && dictionary !== undefined) {
|
|
10416
|
+
let dictionaryBytes = 0;
|
|
10417
|
+
for (const value of dictionary)
|
|
10418
|
+
dictionaryBytes += 16 + value.length * 2;
|
|
10419
|
+
reservations.push(memory.reserve(dictionaryBytes, `Streamed window ${column.name}`));
|
|
10420
|
+
}
|
|
10421
|
+
const fields = { validity, window: { start: 0, length: rows } };
|
|
10422
|
+
if (codes !== undefined) {
|
|
10423
|
+
fields.codes = codes;
|
|
10424
|
+
fields.dictionary = dictionary ?? [];
|
|
10425
|
+
}
|
|
10426
|
+
else if (values !== undefined) {
|
|
10427
|
+
fields.values = values;
|
|
10428
|
+
}
|
|
10429
|
+
return fields;
|
|
10430
|
+
}
|
|
9090
10431
|
/**
|
|
9091
10432
|
* Reads a key column's values as primitives. Dictionary-coded strings resolve through the
|
|
9092
10433
|
* dictionary the vector already holds, so a string key costs one array index and no encoding.
|
|
@@ -9140,9 +10481,10 @@ function touchedKeyPredicate(keyColumn, touched) {
|
|
|
9140
10481
|
return { column: keyColumn, operator: "IN", value: members[0] ?? 0, members };
|
|
9141
10482
|
}
|
|
9142
10483
|
/**
|
|
9143
|
-
* Copies a run of rows between vectors: values as one typed-array slice, validity
|
|
10484
|
+
* Copies a run of rows between vectors: values as one typed-array slice, validity as a bit run.
|
|
9144
10485
|
* A string run needs `remap` unless both sides share a dictionary — the codes mean nothing on
|
|
9145
|
-
* their own. This is what keeps a copy proportional to bytes rather than to cells.
|
|
10486
|
+
* their own. This is what keeps a copy proportional to bytes rather than to cells. The target
|
|
10487
|
+
* validity bits of the run must be clear beforehand, as a fresh window's are.
|
|
9146
10488
|
*/
|
|
9147
10489
|
function copyVectorSpan(source, sourceStart, length, target, targetStart, remap) {
|
|
9148
10490
|
if (source.kind === "string") {
|
|
@@ -9172,26 +10514,7 @@ function copyVectorSpan(source, sourceStart, length, target, targetStart, remap)
|
|
|
9172
10514
|
}
|
|
9173
10515
|
target.values.set(source.values.subarray(sourceStart, sourceStart + length), targetStart);
|
|
9174
10516
|
}
|
|
9175
|
-
|
|
9176
|
-
if (bitmapHasValue(source.validity, sourceStart + index)) {
|
|
9177
|
-
setBitmapValue(target.validity, targetStart + index);
|
|
9178
|
-
}
|
|
9179
|
-
}
|
|
9180
|
-
}
|
|
9181
|
-
/** Source dictionary code -> target dictionary code, built once per source window. */
|
|
9182
|
-
function remapDictionary(source, targetDictionary, targetIndex) {
|
|
9183
|
-
const remap = new Uint32Array(source.length);
|
|
9184
|
-
for (let code = 0; code < source.length; code += 1) {
|
|
9185
|
-
const value = source[code] ?? "";
|
|
9186
|
-
let mapped = targetIndex.get(value);
|
|
9187
|
-
if (mapped === undefined) {
|
|
9188
|
-
mapped = targetDictionary.length;
|
|
9189
|
-
targetDictionary.push(value);
|
|
9190
|
-
targetIndex.set(value, mapped);
|
|
9191
|
-
}
|
|
9192
|
-
remap[code] = mapped;
|
|
9193
|
-
}
|
|
9194
|
-
return remap;
|
|
10517
|
+
copyBitRun(source.validity, sourceStart, target.validity, targetStart, length);
|
|
9195
10518
|
}
|
|
9196
10519
|
/**
|
|
9197
10520
|
* The live rows of a vector, in order. Runs between deletions copy as typed-array slices and
|
|
@@ -9568,13 +10891,356 @@ function sourceOrderTuple(segment, transactions, label) {
|
|
|
9568
10891
|
segmentId: segment.id,
|
|
9569
10892
|
};
|
|
9570
10893
|
}
|
|
10894
|
+
/** Modeled bytes per referenced key the merge planner and the partition probe hold resident. */
|
|
10895
|
+
const MERGE_PLANNER_KEY_BYTES = 96;
|
|
10896
|
+
function planLinearOutputPartitions(totalRows, partitionRows, firstOrder, nextOrder) {
|
|
10897
|
+
const count = Math.max(1, Math.ceil(totalRows / partitionRows));
|
|
10898
|
+
const orders = fractionalLogicalOrders(firstOrder, nextOrder, count);
|
|
10899
|
+
const partitions = [];
|
|
10900
|
+
for (let index = 0, rowStart = 0; index < count; index += 1) {
|
|
10901
|
+
const rowCount = Math.min(partitionRows, totalRows - rowStart);
|
|
10902
|
+
const logicalOrder = orders[index];
|
|
10903
|
+
if (rowCount <= 0 || logicalOrder === undefined) {
|
|
10904
|
+
throw new Error("Rechunk partition layout is incomplete");
|
|
10905
|
+
}
|
|
10906
|
+
partitions.push({ rowStart, rowCount, logicalOrder });
|
|
10907
|
+
rowStart += rowCount;
|
|
10908
|
+
}
|
|
10909
|
+
return partitions;
|
|
10910
|
+
}
|
|
10911
|
+
/**
|
|
10912
|
+
* Cuts the canonical merged output into the partitions a keyed fold publishes.
|
|
10913
|
+
*
|
|
10914
|
+
* Each rewritten source partition's surviving rows form one region that keeps the partition's
|
|
10915
|
+
* logical order, and so its place among the partitions the fold leaves alone. The rows of the
|
|
10916
|
+
* level-zero sources — the tail — form a region behind every existing partition, or extend
|
|
10917
|
+
* the last partition's region when the fold absorbs them into it. A region is then chunked to
|
|
10918
|
+
* at most `partitionRows` rows per published partition. The first chunk keeps the source
|
|
10919
|
+
* partition's order and every further chunk takes an evenly spaced fractional order before
|
|
10920
|
+
* the unchanged successor;
|
|
10921
|
+
* a fresh tail starts at its earliest source's order. Fractional orders make room independent
|
|
10922
|
+
* of adjacent commit versions, so every output is bounded by `partitionRows`. The order is
|
|
10923
|
+
* stable: a published partition sorts strictly between its neighbours and below every
|
|
10924
|
+
* level-zero segment, so a later fold rewrites it alone without moving a row.
|
|
10925
|
+
*/
|
|
10926
|
+
function planOutputPartitions(partitioning, sources, sourceOutputRowStarts, totalRows) {
|
|
10927
|
+
const { partitions, partitionRows, absorbsTail, nextLevelZeroOrder } = partitioning;
|
|
10928
|
+
const startOf = (segmentId) => {
|
|
10929
|
+
const start = sourceOutputRowStarts.get(segmentId);
|
|
10930
|
+
if (start === undefined)
|
|
10931
|
+
throw new Error(`Merge source has no output position: ${segmentId}`);
|
|
10932
|
+
return start;
|
|
10933
|
+
};
|
|
10934
|
+
const levelZeroRowSources = sources.filter((source) => source.level === 0 && mergeSourceBearsRows(source.kind));
|
|
10935
|
+
const tailStart = levelZeroRowSources.length === 0 ? totalRows : startOf(levelZeroRowSources[0]?.segmentId ?? "");
|
|
10936
|
+
const sourceIds = new Set(sources.map((source) => source.segmentId));
|
|
10937
|
+
const sourcedPartitionIndexes = partitions.flatMap((partition, index) => sourceIds.has(partition.id) ? [index] : []);
|
|
10938
|
+
const regions = [];
|
|
10939
|
+
for (const [position, index] of sourcedPartitionIndexes.entries()) {
|
|
10940
|
+
const partition = partitions[index];
|
|
10941
|
+
const order = partition?.logicalOrder;
|
|
10942
|
+
if (partition === undefined || order === undefined) {
|
|
10943
|
+
throw new Error("Partitioned merge source is not a level-one partition");
|
|
10944
|
+
}
|
|
10945
|
+
const nextSourced = sourcedPartitionIndexes[position + 1];
|
|
10946
|
+
const isLast = index === partitions.length - 1;
|
|
10947
|
+
const rowStart = startOf(partition.id);
|
|
10948
|
+
const rowEnd = isLast && absorbsTail
|
|
10949
|
+
? totalRows
|
|
10950
|
+
: nextSourced === undefined
|
|
10951
|
+
? tailStart
|
|
10952
|
+
: startOf(partitions[nextSourced]?.id ?? "");
|
|
10953
|
+
const successorOrder = partitions[index + 1]?.logicalOrder ?? nextLevelZeroOrder;
|
|
10954
|
+
regions.push({
|
|
10955
|
+
rowStart,
|
|
10956
|
+
rowCount: rowEnd - rowStart,
|
|
10957
|
+
anchorOrder: order,
|
|
10958
|
+
roomStart: order,
|
|
10959
|
+
roomEnd: successorOrder,
|
|
10960
|
+
preferredOrder: order,
|
|
10961
|
+
});
|
|
10962
|
+
}
|
|
10963
|
+
if (!(absorbsTail && partitions.length > 0)) {
|
|
10964
|
+
const lastOrder = partitions[partitions.length - 1]?.logicalOrder ?? -1;
|
|
10965
|
+
regions.push({
|
|
10966
|
+
rowStart: tailStart,
|
|
10967
|
+
rowCount: totalRows - tailStart,
|
|
10968
|
+
anchorOrder: null,
|
|
10969
|
+
roomStart: lastOrder,
|
|
10970
|
+
roomEnd: nextLevelZeroOrder,
|
|
10971
|
+
preferredOrder: Math.min(...sources.filter((source) => source.level === 0).map((source) => source.logicalOrder)),
|
|
10972
|
+
});
|
|
10973
|
+
}
|
|
10974
|
+
const output = [];
|
|
10975
|
+
for (const region of regions) {
|
|
10976
|
+
if (region.rowCount <= 0)
|
|
10977
|
+
continue;
|
|
10978
|
+
const chunks = Math.max(1, Math.ceil(region.rowCount / partitionRows));
|
|
10979
|
+
const firstOrder = region.anchorOrder ?? region.preferredOrder;
|
|
10980
|
+
if (!validLogicalOrder(firstOrder) ||
|
|
10981
|
+
firstOrder >= region.roomEnd ||
|
|
10982
|
+
(region.anchorOrder === null && firstOrder <= region.roomStart)) {
|
|
10983
|
+
throw new Error("Partitioned merge has no logical-order interval for its output");
|
|
10984
|
+
}
|
|
10985
|
+
const logicalOrders = fractionalLogicalOrders(firstOrder, region.roomEnd, chunks);
|
|
10986
|
+
const baseRows = Math.floor(region.rowCount / chunks);
|
|
10987
|
+
const extraRows = region.rowCount % chunks;
|
|
10988
|
+
let rowStart = region.rowStart;
|
|
10989
|
+
for (let chunk = 0; chunk < chunks; chunk += 1) {
|
|
10990
|
+
const rowCount = baseRows + (chunk < extraRows ? 1 : 0);
|
|
10991
|
+
const logicalOrder = logicalOrders[chunk];
|
|
10992
|
+
if (logicalOrder === undefined)
|
|
10993
|
+
throw new Error("Partition logical order is unavailable");
|
|
10994
|
+
output.push({ rowStart, rowCount, logicalOrder });
|
|
10995
|
+
rowStart += rowCount;
|
|
10996
|
+
}
|
|
10997
|
+
}
|
|
10998
|
+
let coveredRows = 0;
|
|
10999
|
+
for (const [index, partition] of output.entries()) {
|
|
11000
|
+
const previous = output[index - 1];
|
|
11001
|
+
if (partition.rowStart !== coveredRows ||
|
|
11002
|
+
partition.rowCount <= 0 ||
|
|
11003
|
+
partition.logicalOrder >= nextLevelZeroOrder ||
|
|
11004
|
+
(previous !== undefined && previous.logicalOrder >= partition.logicalOrder)) {
|
|
11005
|
+
throw new Error("Partitioned merge produced an invalid partition layout");
|
|
11006
|
+
}
|
|
11007
|
+
coveredRows += partition.rowCount;
|
|
11008
|
+
}
|
|
11009
|
+
if (coveredRows !== totalRows) {
|
|
11010
|
+
throw new Error("Partitioned merge partitions do not cover the merged output");
|
|
11011
|
+
}
|
|
11012
|
+
return output;
|
|
11013
|
+
}
|
|
11014
|
+
/** `count` increasing doubles in [first, upper), retaining `first` exactly. */
|
|
11015
|
+
function fractionalLogicalOrders(first, upper, count) {
|
|
11016
|
+
if (!validLogicalOrder(first) || !Number.isFinite(upper) || upper <= first || count < 1) {
|
|
11017
|
+
throw new Error("Partition logical-order interval is invalid");
|
|
11018
|
+
}
|
|
11019
|
+
const orders = [];
|
|
11020
|
+
for (let index = 0; index < count; index += 1) {
|
|
11021
|
+
const order = index === 0 ? first : first + ((upper - first) * index) / count;
|
|
11022
|
+
const previous = orders[index - 1];
|
|
11023
|
+
if (!validLogicalOrder(order) ||
|
|
11024
|
+
order >= upper ||
|
|
11025
|
+
(previous !== undefined && order <= previous)) {
|
|
11026
|
+
throw new Error("Partition logical-order precision is exhausted");
|
|
11027
|
+
}
|
|
11028
|
+
orders.push(order);
|
|
11029
|
+
}
|
|
11030
|
+
return orders;
|
|
11031
|
+
}
|
|
11032
|
+
/**
|
|
11033
|
+
* The planner's working memory, as `#resolveMergeOutput` allocates it: two bytes per slot, the
|
|
11034
|
+
* touched-key set and live-slot map over the delta keys, one patch array per patched row, one
|
|
11035
|
+
* decoded key block at a time, and the output ranges themselves — which number the source
|
|
11036
|
+
* blocks plus one per patched cell, not one per row. Deliberately generous per element; this
|
|
11037
|
+
* bound is what a caller's `memoryBudgetBytes` is judged against, so it must not be optimistic.
|
|
11038
|
+
*/
|
|
9571
11039
|
function mergePlannerMemoryBound(table, segments, keyColumnId) {
|
|
9572
|
-
const
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
const
|
|
9576
|
-
const
|
|
9577
|
-
|
|
11040
|
+
const SLOT_BYTES = 2;
|
|
11041
|
+
const KEY_BYTES = MERGE_PLANNER_KEY_BYTES;
|
|
11042
|
+
const PATCH_ROW_BYTES = 64;
|
|
11043
|
+
const PATCH_CELL_BYTES = 48;
|
|
11044
|
+
const RANGE_BYTES = 80;
|
|
11045
|
+
const DECODED_KEY_BLOCK_FACTOR = 4;
|
|
11046
|
+
let slotRows = 0;
|
|
11047
|
+
let deltaKeys = 0;
|
|
11048
|
+
let patchRows = 0;
|
|
11049
|
+
let sourceBlocks = 0;
|
|
11050
|
+
let largestKeyBlockBytes = 0;
|
|
11051
|
+
for (const segment of segments) {
|
|
11052
|
+
if (mergeSourceBearsRows(segment.kind))
|
|
11053
|
+
slotRows += segment.rowCount;
|
|
11054
|
+
if (mergeSourceReferencesKeys(segment.kind))
|
|
11055
|
+
deltaKeys += segment.rowCount;
|
|
11056
|
+
if (segment.kind === "update" || segment.kind === "upsert")
|
|
11057
|
+
patchRows += segment.rowCount;
|
|
11058
|
+
for (const column of segment.columns) {
|
|
11059
|
+
sourceBlocks += column.sourceBlocks.length;
|
|
11060
|
+
if (column.columnId !== keyColumnId)
|
|
11061
|
+
continue;
|
|
11062
|
+
for (const block of column.sourceBlocks) {
|
|
11063
|
+
largestKeyBlockBytes = Math.max(largestKeyBlockBytes, block.encodedBytes);
|
|
11064
|
+
}
|
|
11065
|
+
}
|
|
11066
|
+
}
|
|
11067
|
+
const columns = table.columns.length;
|
|
11068
|
+
return safeWholeNumberSum([
|
|
11069
|
+
safeWholeNumberProduct(slotRows, SLOT_BYTES, "Mutation compaction slots"),
|
|
11070
|
+
safeWholeNumberProduct(deltaKeys, KEY_BYTES, "Mutation compaction keys"),
|
|
11071
|
+
safeWholeNumberProduct(patchRows, safeWholeNumberSum([
|
|
11072
|
+
PATCH_ROW_BYTES,
|
|
11073
|
+
safeWholeNumberProduct(columns, PATCH_CELL_BYTES, "Mutation patch cells"),
|
|
11074
|
+
], "Mutation compaction patch row"), "Mutation compaction patches"),
|
|
11075
|
+
safeWholeNumberProduct(safeWholeNumberSum([sourceBlocks, safeWholeNumberProduct(patchRows, columns, "Mutation patched cells")], "Mutation compaction ranges"), RANGE_BYTES, "Mutation compaction range bytes"),
|
|
11076
|
+
safeWholeNumberProduct(largestKeyBlockBytes, DECODED_KEY_BLOCK_FACTOR, "Mutation compaction decoded key block"),
|
|
11077
|
+
], "Mutation compaction planner memory");
|
|
11078
|
+
}
|
|
11079
|
+
/** Whether a source of this kind contributes rows to the merged output. */
|
|
11080
|
+
function mergeSourceBearsRows(kind) {
|
|
11081
|
+
return kind === "insert" || kind === "upsert" || kind === "base";
|
|
11082
|
+
}
|
|
11083
|
+
/** Whether a source of this kind names existing rows by key. */
|
|
11084
|
+
function mergeSourceReferencesKeys(kind) {
|
|
11085
|
+
return kind === "delete" || kind === "update" || kind === "upsert";
|
|
11086
|
+
}
|
|
11087
|
+
/**
|
|
11088
|
+
* A decoded key value as the primitive the overlay replay keys on (`OverlayKey`): equal keys
|
|
11089
|
+
* are equal primitives, and one table's key has one type, so nothing can collide.
|
|
11090
|
+
*/
|
|
11091
|
+
function overlayKeyOf(type, value) {
|
|
11092
|
+
if (value === null)
|
|
11093
|
+
throw new TypeError("Unique key cannot be null");
|
|
11094
|
+
switch (type) {
|
|
11095
|
+
case "boolean":
|
|
11096
|
+
if (typeof value !== "boolean")
|
|
11097
|
+
throw new TypeError("Invalid boolean unique key");
|
|
11098
|
+
return value;
|
|
11099
|
+
case "number":
|
|
11100
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
11101
|
+
throw new TypeError("Invalid number unique key");
|
|
11102
|
+
}
|
|
11103
|
+
return value;
|
|
11104
|
+
case "string":
|
|
11105
|
+
if (typeof value !== "string")
|
|
11106
|
+
throw new TypeError("Invalid string unique key");
|
|
11107
|
+
return value;
|
|
11108
|
+
case "datetime":
|
|
11109
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
|
|
11110
|
+
throw new TypeError("Invalid datetime unique key");
|
|
11111
|
+
}
|
|
11112
|
+
return value.getTime();
|
|
11113
|
+
}
|
|
11114
|
+
}
|
|
11115
|
+
/**
|
|
11116
|
+
* Accumulates the merged output as coalesced row-ID spans and per-column source ranges. A run
|
|
11117
|
+
* of untouched rows appends at most one range per source block it crosses, whatever its
|
|
11118
|
+
* length; a patched row appends one range per column. Adjacent ranges over the same block
|
|
11119
|
+
* merge in place, so the finished plan is proportional to blocks plus patched cells.
|
|
11120
|
+
*/
|
|
11121
|
+
class MergeOutputBuilder {
|
|
11122
|
+
#columns;
|
|
11123
|
+
#rowIdSpans = [];
|
|
11124
|
+
#rangesByColumn;
|
|
11125
|
+
#blocksBySegment = new Map();
|
|
11126
|
+
#totalRows = 0;
|
|
11127
|
+
constructor(columns) {
|
|
11128
|
+
this.#columns = columns;
|
|
11129
|
+
this.#rangesByColumn = columns.map(() => []);
|
|
11130
|
+
}
|
|
11131
|
+
/** Output rows appended so far. */
|
|
11132
|
+
get totalRows() {
|
|
11133
|
+
return this.#totalRows;
|
|
11134
|
+
}
|
|
11135
|
+
/** Rows `[rowStart, rowStart + rowCount)` of a row-bearing source, unchanged. */
|
|
11136
|
+
appendRun(segment, rowStart, rowCount) {
|
|
11137
|
+
if (rowCount <= 0)
|
|
11138
|
+
return;
|
|
11139
|
+
this.#appendRowIds(segment, rowStart, rowCount);
|
|
11140
|
+
const blocksByColumn = this.#sourceBlocks(segment);
|
|
11141
|
+
for (let columnIndex = 0; columnIndex < this.#columns.length; columnIndex += 1) {
|
|
11142
|
+
const blocks = blocksByColumn[columnIndex];
|
|
11143
|
+
const ranges = this.#rangesByColumn[columnIndex];
|
|
11144
|
+
if (blocks === undefined || ranges === undefined) {
|
|
11145
|
+
throw new Error("Mutation output column is missing");
|
|
11146
|
+
}
|
|
11147
|
+
let outputRow = this.#totalRows;
|
|
11148
|
+
let remaining = rowCount;
|
|
11149
|
+
let rowIndex = rowStart;
|
|
11150
|
+
while (remaining > 0) {
|
|
11151
|
+
const block = rowRangeAt(blocks, rowIndex);
|
|
11152
|
+
if (block === undefined) {
|
|
11153
|
+
throw new Error(`Mutation source row is missing: ${segment.segmentId}`);
|
|
11154
|
+
}
|
|
11155
|
+
const count = Math.min(remaining, block.rowStart + block.rowCount - rowIndex);
|
|
11156
|
+
appendMergeOutputRange(ranges, outputRow, block.blockId, rowIndex - block.rowStart, count);
|
|
11157
|
+
outputRow += count;
|
|
11158
|
+
rowIndex += count;
|
|
11159
|
+
remaining -= count;
|
|
11160
|
+
}
|
|
11161
|
+
}
|
|
11162
|
+
this.#totalRows += rowCount;
|
|
11163
|
+
}
|
|
11164
|
+
/** One row of a row-bearing source whose columns may come from later mutations. */
|
|
11165
|
+
appendPatchedRow(segment, rowIndex, patch) {
|
|
11166
|
+
this.#appendRowIds(segment, rowIndex, 1);
|
|
11167
|
+
for (let columnIndex = 0; columnIndex < this.#columns.length; columnIndex += 1) {
|
|
11168
|
+
const column = this.#columns[columnIndex];
|
|
11169
|
+
const ranges = this.#rangesByColumn[columnIndex];
|
|
11170
|
+
if (column === undefined || ranges === undefined) {
|
|
11171
|
+
throw new Error("Mutation output column is missing");
|
|
11172
|
+
}
|
|
11173
|
+
const source = patch?.[columnIndex] ?? mergeSourceAt(segment, column.id, rowIndex);
|
|
11174
|
+
appendMergeOutputRange(ranges, this.#totalRows, source.blockId, source.sourceRowIndex, 1);
|
|
11175
|
+
}
|
|
11176
|
+
this.#totalRows += 1;
|
|
11177
|
+
}
|
|
11178
|
+
finish() {
|
|
11179
|
+
return {
|
|
11180
|
+
rowIdSpans: this.#rowIdSpans,
|
|
11181
|
+
columns: this.#columns.map((column, columnIndex) => {
|
|
11182
|
+
const sourceRanges = this.#rangesByColumn[columnIndex];
|
|
11183
|
+
if (sourceRanges === undefined)
|
|
11184
|
+
throw new Error("Mutation output column is missing");
|
|
11185
|
+
return { columnId: column.id, type: column.type, sourceRanges };
|
|
11186
|
+
}),
|
|
11187
|
+
totalRows: this.#totalRows,
|
|
11188
|
+
};
|
|
11189
|
+
}
|
|
11190
|
+
#sourceBlocks(segment) {
|
|
11191
|
+
let blocks = this.#blocksBySegment.get(segment.segmentId);
|
|
11192
|
+
if (blocks === undefined) {
|
|
11193
|
+
blocks = this.#columns.map((column) => {
|
|
11194
|
+
const source = segment.columns.find((candidate) => candidate.columnId === column.id);
|
|
11195
|
+
if (source === undefined) {
|
|
11196
|
+
throw new Error(`Mutation source row is missing: ${segment.segmentId}:${column.id}`);
|
|
11197
|
+
}
|
|
11198
|
+
return source.sourceBlocks;
|
|
11199
|
+
});
|
|
11200
|
+
this.#blocksBySegment.set(segment.segmentId, blocks);
|
|
11201
|
+
}
|
|
11202
|
+
return blocks;
|
|
11203
|
+
}
|
|
11204
|
+
#appendRowIds(segment, rowStart, rowCount) {
|
|
11205
|
+
let outputRow = this.#totalRows;
|
|
11206
|
+
let remaining = rowCount;
|
|
11207
|
+
let rowIndex = rowStart;
|
|
11208
|
+
while (remaining > 0) {
|
|
11209
|
+
const span = rowRangeAt(segment.rowIdSpans, rowIndex);
|
|
11210
|
+
if (span === undefined) {
|
|
11211
|
+
throw new Error(`Mutation source row ID is missing: ${String(rowIndex)}`);
|
|
11212
|
+
}
|
|
11213
|
+
const count = Math.min(remaining, span.rowStart + span.rowCount - rowIndex);
|
|
11214
|
+
appendRowIdSpan(this.#rowIdSpans, outputRow, span.rowIdStart + BigInt(rowIndex - span.rowStart), count);
|
|
11215
|
+
outputRow += count;
|
|
11216
|
+
rowIndex += count;
|
|
11217
|
+
remaining -= count;
|
|
11218
|
+
}
|
|
11219
|
+
}
|
|
11220
|
+
}
|
|
11221
|
+
/** Appends `rowCount` consecutive row IDs from `rowId`, extending the last span when contiguous. */
|
|
11222
|
+
function appendRowIdSpan(spans, rowStart, rowId, rowCount) {
|
|
11223
|
+
const previous = spans[spans.length - 1];
|
|
11224
|
+
if (previous !== undefined &&
|
|
11225
|
+
previous.rowStart + previous.rowCount === rowStart &&
|
|
11226
|
+
previous.rowIdStart + BigInt(previous.rowCount) === rowId) {
|
|
11227
|
+
previous.rowCount += rowCount;
|
|
11228
|
+
}
|
|
11229
|
+
else {
|
|
11230
|
+
spans.push({ rowStart, rowCount, rowIdStart: rowId });
|
|
11231
|
+
}
|
|
11232
|
+
}
|
|
11233
|
+
/** Appends `rowCount` output rows read from one source block, extending the last range when contiguous. */
|
|
11234
|
+
function appendMergeOutputRange(ranges, outputRowStart, sourceBlockId, sourceRowStart, rowCount) {
|
|
11235
|
+
const previous = ranges[ranges.length - 1];
|
|
11236
|
+
if (previous?.sourceBlockId === sourceBlockId &&
|
|
11237
|
+
previous.outputRowStart + previous.rowCount === outputRowStart &&
|
|
11238
|
+
previous.sourceRowStart + previous.rowCount === sourceRowStart) {
|
|
11239
|
+
previous.rowCount += rowCount;
|
|
11240
|
+
}
|
|
11241
|
+
else {
|
|
11242
|
+
ranges.push({ outputRowStart, sourceBlockId, sourceRowStart, rowCount });
|
|
11243
|
+
}
|
|
9578
11244
|
}
|
|
9579
11245
|
function mergeSourceAt(segment, columnId, rowIndex) {
|
|
9580
11246
|
const column = segment.columns.find((candidate) => candidate.columnId === columnId);
|
|
@@ -9584,12 +11250,6 @@ function mergeSourceAt(segment, columnId, rowIndex) {
|
|
|
9584
11250
|
}
|
|
9585
11251
|
return { blockId: block.blockId, sourceRowIndex: rowIndex - block.rowStart };
|
|
9586
11252
|
}
|
|
9587
|
-
function rowIdAt(spans, rowIndex) {
|
|
9588
|
-
const span = rowRangeAt(spans, rowIndex);
|
|
9589
|
-
if (span === undefined)
|
|
9590
|
-
throw new Error(`Mutation source row ID is missing: ${String(rowIndex)}`);
|
|
9591
|
-
return span.rowIdStart + BigInt(rowIndex - span.rowStart);
|
|
9592
|
-
}
|
|
9593
11253
|
function rowRangeAt(ranges, rowIndex) {
|
|
9594
11254
|
let low = 0;
|
|
9595
11255
|
let high = ranges.length - 1;
|
|
@@ -9610,17 +11270,6 @@ function rowRangeAt(ranges, rowIndex) {
|
|
|
9610
11270
|
}
|
|
9611
11271
|
return undefined;
|
|
9612
11272
|
}
|
|
9613
|
-
function appendRowIdSpan(spans, rowStart, rowId) {
|
|
9614
|
-
const previous = spans[spans.length - 1];
|
|
9615
|
-
if (previous !== undefined &&
|
|
9616
|
-
previous.rowStart + previous.rowCount === rowStart &&
|
|
9617
|
-
previous.rowIdStart + BigInt(previous.rowCount) === rowId) {
|
|
9618
|
-
spans[spans.length - 1] = { ...previous, rowCount: previous.rowCount + 1 };
|
|
9619
|
-
}
|
|
9620
|
-
else {
|
|
9621
|
-
spans.push({ rowStart, rowCount: 1, rowIdStart: rowId });
|
|
9622
|
-
}
|
|
9623
|
-
}
|
|
9624
11273
|
function rowIdSpanEnvelope(spans) {
|
|
9625
11274
|
if (spans.length === 0)
|
|
9626
11275
|
return { start: 0n, endExclusive: 0n };
|
|
@@ -9635,22 +11284,6 @@ function rowIdSpanEnvelope(spans) {
|
|
|
9635
11284
|
}
|
|
9636
11285
|
return { start, endExclusive };
|
|
9637
11286
|
}
|
|
9638
|
-
function appendMergeOutputRange(ranges, outputRowStart, source) {
|
|
9639
|
-
const previous = ranges[ranges.length - 1];
|
|
9640
|
-
if (previous?.sourceBlockId === source.blockId &&
|
|
9641
|
-
previous.outputRowStart + previous.rowCount === outputRowStart &&
|
|
9642
|
-
previous.sourceRowStart + previous.rowCount === source.sourceRowIndex) {
|
|
9643
|
-
ranges[ranges.length - 1] = { ...previous, rowCount: previous.rowCount + 1 };
|
|
9644
|
-
}
|
|
9645
|
-
else {
|
|
9646
|
-
ranges.push({
|
|
9647
|
-
outputRowStart,
|
|
9648
|
-
sourceBlockId: source.blockId,
|
|
9649
|
-
sourceRowStart: source.sourceRowIndex,
|
|
9650
|
-
rowCount: 1,
|
|
9651
|
-
});
|
|
9652
|
-
}
|
|
9653
|
-
}
|
|
9654
11287
|
function validatePhysicalTablePlan(table, plan) {
|
|
9655
11288
|
if (table.columns.length !== plan.columns.length ||
|
|
9656
11289
|
table.columns.some((column, index) => {
|
|
@@ -9779,12 +11412,108 @@ function physicalOutputBlockId(jobId, outputIndex, columnIndex) {
|
|
|
9779
11412
|
function physicalOutputBlockIds(jobId, plan) {
|
|
9780
11413
|
return plan.outputs.flatMap((_output, outputIndex) => plan.columns.map((_column, columnIndex) => physicalOutputBlockId(jobId, outputIndex, columnIndex)));
|
|
9781
11414
|
}
|
|
9782
|
-
function physicalOutputColumns(jobId, plan
|
|
11415
|
+
function physicalOutputColumns(jobId, plan, window = {
|
|
11416
|
+
rowStart: 0,
|
|
11417
|
+
rowCount: plan.totalRows,
|
|
11418
|
+
}) {
|
|
11419
|
+
const windowEnd = window.rowStart + window.rowCount;
|
|
9783
11420
|
return Object.fromEntries(plan.columns.map((column, columnIndex) => [
|
|
9784
11421
|
column.columnId,
|
|
9785
|
-
plan.outputs.
|
|
11422
|
+
plan.outputs.flatMap((output, outputIndex) => output.rowStart >= window.rowStart && output.rowStart + output.rowCount <= windowEnd
|
|
11423
|
+
? [physicalOutputBlockId(jobId, outputIndex, columnIndex)]
|
|
11424
|
+
: []),
|
|
9786
11425
|
]));
|
|
9787
11426
|
}
|
|
11427
|
+
/** The segment ID partition `index` of a partitioned merge publishes under. */
|
|
11428
|
+
function partitionOutputSegmentId(outputSegmentId, index) {
|
|
11429
|
+
return index === 0 ? outputSegmentId : `${outputSegmentId}/${String(index)}`;
|
|
11430
|
+
}
|
|
11431
|
+
/** Every segment a job publishes: one per output partition, or the single output segment. */
|
|
11432
|
+
function compactionOutputSegmentIds(job) {
|
|
11433
|
+
if (job.outputSegmentId === null)
|
|
11434
|
+
return [];
|
|
11435
|
+
const plan = job.rewritePlan;
|
|
11436
|
+
if ((plan?.kind !== "merge-v1" && plan?.kind !== "rechunk-v1") || plan.partitions === undefined) {
|
|
11437
|
+
return [job.outputSegmentId];
|
|
11438
|
+
}
|
|
11439
|
+
const outputSegmentId = job.outputSegmentId;
|
|
11440
|
+
return plan.partitions.map((_partition, index) => partitionOutputSegmentId(outputSegmentId, index));
|
|
11441
|
+
}
|
|
11442
|
+
/**
|
|
11443
|
+
* The segments a physical compaction publishes, with the blocks of the windows each covers.
|
|
11444
|
+
* A partitioned rewrite publishes one level-one segment per planned partition. A merge carries
|
|
11445
|
+
* the slice of its row-ID spans; a rechunk carries the corresponding contiguous interval.
|
|
11446
|
+
*/
|
|
11447
|
+
function compactionOutputSegments(table, job, plan, transactionId, createdAt) {
|
|
11448
|
+
const outputSegmentId = job.outputSegmentId;
|
|
11449
|
+
if (outputSegmentId === null)
|
|
11450
|
+
throw new Error("Compaction output segment ID is missing");
|
|
11451
|
+
const keyColumn = table.uniqueKeyColumnId === undefined ? {} : { keyColumnId: table.uniqueKeyColumnId };
|
|
11452
|
+
const partitionOrdinal = job.outputPartitionOrdinal === undefined
|
|
11453
|
+
? {}
|
|
11454
|
+
: { partitionOrdinal: job.outputPartitionOrdinal };
|
|
11455
|
+
if (plan.partitions !== undefined) {
|
|
11456
|
+
return plan.partitions.map((partition, index) => {
|
|
11457
|
+
const rowIdSpans = plan.kind === "merge-v1"
|
|
11458
|
+
? sliceRowIdSpans(plan.rowIdSpans, partition.rowStart, partition.rowCount)
|
|
11459
|
+
: undefined;
|
|
11460
|
+
const envelope = rowIdSpans === undefined
|
|
11461
|
+
? {
|
|
11462
|
+
start: plan.rowIdStart + BigInt(partition.rowStart),
|
|
11463
|
+
endExclusive: plan.rowIdStart + BigInt(partition.rowStart + partition.rowCount),
|
|
11464
|
+
}
|
|
11465
|
+
: rowIdSpanEnvelope(rowIdSpans);
|
|
11466
|
+
return {
|
|
11467
|
+
id: partitionOutputSegmentId(outputSegmentId, index),
|
|
11468
|
+
tableId: table.id,
|
|
11469
|
+
transactionId,
|
|
11470
|
+
rowCount: partition.rowCount,
|
|
11471
|
+
rowIdStart: envelope.start,
|
|
11472
|
+
rowIdEndExclusive: envelope.endExclusive,
|
|
11473
|
+
columnBlockIds: physicalOutputColumns(job.id, plan, partition),
|
|
11474
|
+
kind: plan.kind === "merge-v1" ? "base" : "insert",
|
|
11475
|
+
...keyColumn,
|
|
11476
|
+
level: job.targetLevel,
|
|
11477
|
+
...partitionOrdinal,
|
|
11478
|
+
logicalOrder: partition.logicalOrder,
|
|
11479
|
+
...(rowIdSpans === undefined ? {} : { rowIdSpans }),
|
|
11480
|
+
createdAt,
|
|
11481
|
+
};
|
|
11482
|
+
});
|
|
11483
|
+
}
|
|
11484
|
+
return [
|
|
11485
|
+
{
|
|
11486
|
+
id: outputSegmentId,
|
|
11487
|
+
tableId: table.id,
|
|
11488
|
+
transactionId,
|
|
11489
|
+
rowCount: plan.totalRows,
|
|
11490
|
+
rowIdStart: plan.rowIdStart,
|
|
11491
|
+
rowIdEndExclusive: plan.rowIdEndExclusive,
|
|
11492
|
+
columnBlockIds: physicalOutputColumns(job.id, plan),
|
|
11493
|
+
kind: plan.kind === "merge-v1" ? "base" : "insert",
|
|
11494
|
+
...keyColumn,
|
|
11495
|
+
level: job.targetLevel,
|
|
11496
|
+
...partitionOrdinal,
|
|
11497
|
+
logicalOrder: plan.logicalOrder,
|
|
11498
|
+
...(plan.kind === "merge-v1" ? { rowIdSpans: structuredClone(plan.rowIdSpans) } : {}),
|
|
11499
|
+
createdAt,
|
|
11500
|
+
},
|
|
11501
|
+
];
|
|
11502
|
+
}
|
|
11503
|
+
/** The spans of output rows `[rowStart, rowStart + rowCount)`, rebased to start at row zero. */
|
|
11504
|
+
function sliceRowIdSpans(spans, rowStart, rowCount) {
|
|
11505
|
+
const sliced = [];
|
|
11506
|
+
const rowEnd = rowStart + rowCount;
|
|
11507
|
+
for (const span of spans) {
|
|
11508
|
+
const spanEnd = span.rowStart + span.rowCount;
|
|
11509
|
+
if (spanEnd <= rowStart || span.rowStart >= rowEnd)
|
|
11510
|
+
continue;
|
|
11511
|
+
const start = Math.max(span.rowStart, rowStart);
|
|
11512
|
+
const end = Math.min(spanEnd, rowEnd);
|
|
11513
|
+
appendRowIdSpan(sliced, start - rowStart, span.rowIdStart + BigInt(start - span.rowStart), end - start);
|
|
11514
|
+
}
|
|
11515
|
+
return sliced;
|
|
11516
|
+
}
|
|
9788
11517
|
function isActiveCompactionState(state) {
|
|
9789
11518
|
return state === "planned" || state === "running" || state === "ready";
|
|
9790
11519
|
}
|
|
@@ -9889,6 +11618,9 @@ function garbageCollectionProgress(job) {
|
|
|
9889
11618
|
reclaimedBlockCount: job.reclaimedBlockCount,
|
|
9890
11619
|
retainedBlockCount: job.retainedBlockCount,
|
|
9891
11620
|
missingBlockCount: job.missingBlockCount,
|
|
11621
|
+
reclaimedTransactionCount: job.reclaimedTransactionCount,
|
|
11622
|
+
retainedTransactionCount: job.retainedTransactionCount,
|
|
11623
|
+
missingTransactionCount: job.missingTransactionCount,
|
|
9892
11624
|
physicallyReclaimedBytes: job.reclaimedBlockBytes,
|
|
9893
11625
|
}
|
|
9894
11626
|
: null;
|
|
@@ -9898,9 +11630,14 @@ function garbageCollectionProgress(job) {
|
|
|
9898
11630
|
examinedManifestCount: job.cursor.manifestIndex,
|
|
9899
11631
|
examinedSegmentCount: job.cursor.segmentIndex,
|
|
9900
11632
|
examinedBlockCount: job.cursor.blockIndex,
|
|
11633
|
+
examinedTransactionCount: job.cursor.transactionIndex,
|
|
9901
11634
|
result,
|
|
9902
11635
|
};
|
|
9903
11636
|
}
|
|
11637
|
+
/** Whether any of the table's triggers for these events fire AFTER the write, staging rows. */
|
|
11638
|
+
function firesAfterTriggers(table, ...events) {
|
|
11639
|
+
return (table.triggers ?? []).some((trigger) => trigger.timing === "after" && events.includes(trigger.event));
|
|
11640
|
+
}
|
|
9904
11641
|
/** Collects every real table name referenced by a block, its derived sources, or its subqueries. */
|
|
9905
11642
|
function collectRealTableNames(plan) {
|
|
9906
11643
|
const names = new Set();
|
|
@@ -10090,22 +11827,19 @@ function hasContiguousRowIds(segments) {
|
|
|
10090
11827
|
return previous === undefined || previous.rowIdEndExclusive === segment.rowIdStart;
|
|
10091
11828
|
});
|
|
10092
11829
|
}
|
|
11830
|
+
function validLogicalOrder(value) {
|
|
11831
|
+
return value !== undefined && Number.isFinite(value) && value >= 0;
|
|
11832
|
+
}
|
|
10093
11833
|
function appendLevelTwoLayout(segments) {
|
|
10094
|
-
|
|
11834
|
+
const levelOneSegments = levelOnePartitionPrefix(segments);
|
|
11835
|
+
if (levelOneSegments === null ||
|
|
11836
|
+
levelOneSegments.some((segment) => (segment.kind ?? "insert") !== "insert" || segment.rowIdSpans !== undefined)) {
|
|
11837
|
+
return null;
|
|
11838
|
+
}
|
|
11839
|
+
let index = levelOneSegments.length;
|
|
10095
11840
|
const retainedPrefix = [];
|
|
10096
11841
|
const levelTwoSegments = [];
|
|
10097
|
-
|
|
10098
|
-
if (first !== undefined && (first.level ?? 0) === 1) {
|
|
10099
|
-
if ((first.kind ?? "insert") !== "insert" ||
|
|
10100
|
-
first.rowIdSpans !== undefined ||
|
|
10101
|
-
first.partitionOrdinal !== undefined ||
|
|
10102
|
-
(first.logicalOrder !== undefined &&
|
|
10103
|
-
(!Number.isSafeInteger(first.logicalOrder) || first.logicalOrder < 0))) {
|
|
10104
|
-
return null;
|
|
10105
|
-
}
|
|
10106
|
-
retainedPrefix.push(first);
|
|
10107
|
-
index += 1;
|
|
10108
|
-
}
|
|
11842
|
+
retainedPrefix.push(...levelOneSegments);
|
|
10109
11843
|
for (;;) {
|
|
10110
11844
|
const segment = segments[index];
|
|
10111
11845
|
if (segment === undefined || (segment.level ?? 0) !== 2)
|
|
@@ -10113,8 +11847,7 @@ function appendLevelTwoLayout(segments) {
|
|
|
10113
11847
|
if ((segment.kind ?? "insert") !== "insert" ||
|
|
10114
11848
|
segment.rowIdSpans !== undefined ||
|
|
10115
11849
|
segment.partitionOrdinal !== levelTwoSegments.length ||
|
|
10116
|
-
!
|
|
10117
|
-
(segment.logicalOrder ?? -1) < 0 ||
|
|
11850
|
+
!validLogicalOrder(segment.logicalOrder) ||
|
|
10118
11851
|
segment.rowIdEndExclusive - segment.rowIdStart !== BigInt(segment.rowCount)) {
|
|
10119
11852
|
return null;
|
|
10120
11853
|
}
|
|
@@ -10127,8 +11860,7 @@ function appendLevelTwoLayout(segments) {
|
|
|
10127
11860
|
segment.partitionOrdinal !== undefined ||
|
|
10128
11861
|
(segment.kind ?? "insert") !== "insert" ||
|
|
10129
11862
|
segment.rowIdSpans !== undefined ||
|
|
10130
|
-
(segment.logicalOrder !== undefined &&
|
|
10131
|
-
(!Number.isSafeInteger(segment.logicalOrder) || segment.logicalOrder < 0)))) {
|
|
11863
|
+
(segment.logicalOrder !== undefined && !validLogicalOrder(segment.logicalOrder)))) {
|
|
10132
11864
|
return null;
|
|
10133
11865
|
}
|
|
10134
11866
|
const rowIdIntervals = segments
|
|
@@ -10148,46 +11880,67 @@ function appendLevelTwoLayout(segments) {
|
|
|
10148
11880
|
return { retainedPrefix, levelTwoSegments, level0Segments };
|
|
10149
11881
|
}
|
|
10150
11882
|
/**
|
|
10151
|
-
* Validates a keyed table's visible history for
|
|
10152
|
-
*
|
|
10153
|
-
*
|
|
10154
|
-
*
|
|
10155
|
-
*
|
|
10156
|
-
* footprint. Returns null when the shape does not hold so the planner skips explicitly.
|
|
11883
|
+
* Validates a keyed table's visible history for a partitioned level-one fold: a prefix of
|
|
11884
|
+
* level-one partitions — merged bases carrying row-ID spans, or append-shaped inserts — each
|
|
11885
|
+
* with an explicit logical order, strictly increasing along the prefix; then level-zero
|
|
11886
|
+
* segments of any mutation kind. Every row footprint must be pairwise disjoint. Returns null
|
|
11887
|
+
* when the shape does not hold so the planner skips explicitly.
|
|
10157
11888
|
*/
|
|
10158
|
-
function
|
|
10159
|
-
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
|
|
10163
|
-
|
|
11889
|
+
function keyedLevelOneLayout(segments) {
|
|
11890
|
+
const partitions = levelOnePartitionPrefix(segments);
|
|
11891
|
+
if (partitions === null)
|
|
11892
|
+
return null;
|
|
11893
|
+
const level0Segments = segments.slice(partitions.length);
|
|
11894
|
+
if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 || segment.partitionOrdinal !== undefined)) {
|
|
11895
|
+
return null;
|
|
11896
|
+
}
|
|
11897
|
+
if (!disjointRowIdFootprints(segments))
|
|
11898
|
+
return null;
|
|
11899
|
+
return { partitions, level0Segments };
|
|
11900
|
+
}
|
|
11901
|
+
/** The append-only counterpart: bounded L1 partitions followed by contiguous insert deltas. */
|
|
11902
|
+
function keylessLevelOneLayout(segments) {
|
|
11903
|
+
const partitions = levelOnePartitionPrefix(segments);
|
|
11904
|
+
if (partitions === null ||
|
|
11905
|
+
partitions.some((segment) => (segment.kind ?? "insert") !== "insert")) {
|
|
11906
|
+
return null;
|
|
11907
|
+
}
|
|
11908
|
+
const level0Segments = segments.slice(partitions.length);
|
|
11909
|
+
if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 ||
|
|
11910
|
+
segment.partitionOrdinal !== undefined ||
|
|
11911
|
+
(segment.kind ?? "insert") !== "insert" ||
|
|
11912
|
+
segment.rowIdSpans !== undefined) ||
|
|
11913
|
+
!hasContiguousRowIds(segments)) {
|
|
11914
|
+
return null;
|
|
11915
|
+
}
|
|
11916
|
+
return { partitions, level0Segments };
|
|
11917
|
+
}
|
|
11918
|
+
/**
|
|
11919
|
+
* The leading level-one segments, when they form a valid partition prefix: insert or base
|
|
11920
|
+
* kinds, no L2 ordinal, and explicit strictly increasing logical orders. Null otherwise.
|
|
11921
|
+
*/
|
|
11922
|
+
function levelOnePartitionPrefix(segments) {
|
|
11923
|
+
const partitions = [];
|
|
11924
|
+
for (const segment of segments) {
|
|
11925
|
+
if ((segment.level ?? 0) !== 1)
|
|
10164
11926
|
break;
|
|
10165
11927
|
const kind = segment.kind ?? "insert";
|
|
10166
|
-
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
(
|
|
10170
|
-
(
|
|
10171
|
-
(kind === "base" && (segment.rowIdSpans?.length ?? 0) === 0)) {
|
|
10172
|
-
return null;
|
|
10173
|
-
}
|
|
10174
|
-
levelTwoSegments.push(segment);
|
|
10175
|
-
index += 1;
|
|
10176
|
-
}
|
|
10177
|
-
let anchor;
|
|
10178
|
-
const maybeAnchor = segments[index];
|
|
10179
|
-
if (maybeAnchor !== undefined && (maybeAnchor.level ?? 0) === 1) {
|
|
10180
|
-
const kind = maybeAnchor.kind ?? "insert";
|
|
10181
|
-
if ((kind !== "insert" && kind !== "base") || maybeAnchor.partitionOrdinal !== undefined) {
|
|
11928
|
+
const previousOrder = partitions[partitions.length - 1]?.logicalOrder ?? -1;
|
|
11929
|
+
if ((kind !== "insert" && kind !== "base") ||
|
|
11930
|
+
segment.partitionOrdinal !== undefined ||
|
|
11931
|
+
!validLogicalOrder(segment.logicalOrder) ||
|
|
11932
|
+
(segment.logicalOrder ?? -1) <= previousOrder) {
|
|
10182
11933
|
return null;
|
|
10183
11934
|
}
|
|
10184
|
-
|
|
10185
|
-
index += 1;
|
|
10186
|
-
}
|
|
10187
|
-
const level0Segments = segments.slice(index);
|
|
10188
|
-
if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 || segment.partitionOrdinal !== undefined)) {
|
|
10189
|
-
return null;
|
|
11935
|
+
partitions.push(segment);
|
|
10190
11936
|
}
|
|
11937
|
+
return partitions;
|
|
11938
|
+
}
|
|
11939
|
+
/**
|
|
11940
|
+
* Whether the segments' row footprints — spans where present, otherwise the contiguous
|
|
11941
|
+
* interval — are positive and pairwise disjoint. Update and delete deltas carry no footprint.
|
|
11942
|
+
*/
|
|
11943
|
+
function disjointRowIdFootprints(segments) {
|
|
10191
11944
|
const intervals = [];
|
|
10192
11945
|
for (const segment of segments) {
|
|
10193
11946
|
if (segment.rowIdSpans !== undefined) {
|
|
@@ -10199,18 +11952,56 @@ function keyedLevelTwoLayout(segments) {
|
|
|
10199
11952
|
if (segment.rowIdEndExclusive <= segment.rowIdStart)
|
|
10200
11953
|
continue;
|
|
10201
11954
|
if (segment.rowIdEndExclusive - segment.rowIdStart !== BigInt(segment.rowCount))
|
|
10202
|
-
return
|
|
11955
|
+
return false;
|
|
10203
11956
|
intervals.push({ start: segment.rowIdStart, end: segment.rowIdEndExclusive });
|
|
10204
11957
|
}
|
|
10205
11958
|
intervals.sort((left, right) => left.start < right.start ? -1 : left.start > right.start ? 1 : 0);
|
|
10206
11959
|
for (const [intervalIndex, interval] of intervals.entries()) {
|
|
10207
11960
|
if (interval.start <= 0n)
|
|
10208
|
-
return
|
|
11961
|
+
return false;
|
|
10209
11962
|
const previous = intervals[intervalIndex - 1];
|
|
10210
11963
|
if (previous !== undefined && interval.start < previous.end)
|
|
11964
|
+
return false;
|
|
11965
|
+
}
|
|
11966
|
+
return true;
|
|
11967
|
+
}
|
|
11968
|
+
/**
|
|
11969
|
+
* Validates a keyed table's visible history for multi-range L2 promotion: existing partitions
|
|
11970
|
+
* (append-shaped inserts or merged bases carrying row-ID spans) with ordinals exactly 0..N-1,
|
|
11971
|
+
* then the level-one partitions, then level-zero segments of any mutation kind. Every row
|
|
11972
|
+
* footprint — a partition's spans or interval, the anchors', and each level-zero insert/upsert
|
|
11973
|
+
* interval — must be pairwise disjoint; update and delete deltas carry no footprint. Returns
|
|
11974
|
+
* null when the shape does not hold so the planner skips explicitly.
|
|
11975
|
+
*/
|
|
11976
|
+
function keyedLevelTwoLayout(segments) {
|
|
11977
|
+
let index = 0;
|
|
11978
|
+
const levelTwoSegments = [];
|
|
11979
|
+
for (;;) {
|
|
11980
|
+
const segment = segments[index];
|
|
11981
|
+
if (segment === undefined || (segment.level ?? 0) !== 2)
|
|
11982
|
+
break;
|
|
11983
|
+
const kind = segment.kind ?? "insert";
|
|
11984
|
+
if (segment.partitionOrdinal !== levelTwoSegments.length ||
|
|
11985
|
+
!validLogicalOrder(segment.logicalOrder) ||
|
|
11986
|
+
(kind !== "insert" && kind !== "base") ||
|
|
11987
|
+
(kind === "insert" && segment.rowIdSpans !== undefined) ||
|
|
11988
|
+
(kind === "base" && (segment.rowIdSpans?.length ?? 0) === 0)) {
|
|
10211
11989
|
return null;
|
|
11990
|
+
}
|
|
11991
|
+
levelTwoSegments.push(segment);
|
|
11992
|
+
index += 1;
|
|
11993
|
+
}
|
|
11994
|
+
const anchors = levelOnePartitionPrefix(segments.slice(index));
|
|
11995
|
+
if (anchors === null)
|
|
11996
|
+
return null;
|
|
11997
|
+
index += anchors.length;
|
|
11998
|
+
const level0Segments = segments.slice(index);
|
|
11999
|
+
if (level0Segments.some((segment) => (segment.level ?? 0) !== 0 || segment.partitionOrdinal !== undefined)) {
|
|
12000
|
+
return null;
|
|
10212
12001
|
}
|
|
10213
|
-
|
|
12002
|
+
if (!disjointRowIdFootprints(segments))
|
|
12003
|
+
return null;
|
|
12004
|
+
return { levelTwoSegments, anchors, level0Segments };
|
|
10214
12005
|
}
|
|
10215
12006
|
function compactionWriteAmplificationSkipped(input) {
|
|
10216
12007
|
return {
|