@sixb/ducklake 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -5
- package/dist/ducklake-storage.d.ts +3 -1
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +1963 -480
- package/dist/index.js.map +25 -15
- package/dist/internal/dataset-row-commit.d.ts +10 -0
- package/dist/internal/dataset-row-commit.d.ts.map +1 -1
- package/dist/internal/dataset-row-merge.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
- package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
- package/dist/internal/ducklake-maintenance.d.ts +21 -1
- package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
- package/dist/internal/ducklake-snapshot-reader.d.ts +81 -9
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-sql-executor.d.ts +9 -1
- package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
- package/dist/internal/ducklake-version-transaction.d.ts +54 -0
- package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
- package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
- package/dist/internal/lineage-encoding.d.ts +8 -0
- package/dist/internal/lineage-encoding.d.ts.map +1 -0
- package/dist/internal/sql-lineage/analysis.d.ts +32 -0
- package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
- package/dist/internal/sql-lineage/expressions.d.ts +9 -0
- package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
- package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
- package/dist/internal/sql-lineage/rejection.d.ts +11 -0
- package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
- package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
- package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
- package/dist/internal/sql-transform-relations.d.ts +20 -5
- package/dist/internal/sql-transform-relations.d.ts.map +1 -1
- package/dist/internal/sql.d.ts +11 -2
- package/dist/internal/sql.d.ts.map +1 -1
- package/dist/internal/versions.d.ts +10 -1
- package/dist/internal/versions.d.ts.map +1 -1
- package/dist/types.d.ts +10 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/ducklake-storage.ts +17 -2
- package/src/internal/dataset-row-commit.ts +28 -2
- package/src/internal/dataset-row-merge.ts +4 -0
- package/src/internal/ducklake-dataset-catalog.ts +162 -49
- package/src/internal/ducklake-dataset-table-ref.ts +75 -17
- package/src/internal/ducklake-maintenance.ts +110 -48
- package/src/internal/ducklake-snapshot-reader.ts +549 -254
- package/src/internal/ducklake-sql-executor.ts +214 -14
- package/src/internal/ducklake-version-transaction.ts +224 -0
- package/src/internal/ducklake-write-coordinator.ts +226 -148
- package/src/internal/lineage-encoding.ts +136 -0
- package/src/internal/sql-lineage/analysis.ts +257 -0
- package/src/internal/sql-lineage/expressions.ts +208 -0
- package/src/internal/sql-lineage/plan-json.ts +130 -0
- package/src/internal/sql-lineage/plan-relation.ts +59 -0
- package/src/internal/sql-lineage/plan-walker.ts +727 -0
- package/src/internal/sql-lineage/rejection.ts +16 -0
- package/src/internal/sql-lineage/statement-gate.ts +164 -0
- package/src/internal/sql-lineage/table-functions.ts +11 -0
- package/src/internal/sql-transform-relations.ts +90 -40
- package/src/internal/sql.ts +35 -6
- package/src/internal/versions.ts +37 -14
- package/src/types.ts +11 -1
|
@@ -1,29 +1,45 @@
|
|
|
1
|
-
import type { DatasetDefinition } from "@sixb/core"
|
|
1
|
+
import type { DatasetDefinition, DatasetSchema } from "@sixb/core"
|
|
2
2
|
import type {
|
|
3
3
|
DatasetCatalogState,
|
|
4
4
|
DatasetLatestVersionSummary,
|
|
5
|
+
DatasetProducer,
|
|
5
6
|
DatasetVersion,
|
|
7
|
+
DatasetVersionLineage,
|
|
6
8
|
DatasetVersionMode,
|
|
7
9
|
DatasetVersionRef,
|
|
10
|
+
LatestVersionsSince,
|
|
11
|
+
ListLatestVersionsSinceInput,
|
|
8
12
|
} from "@sixb/core/lake-storage"
|
|
9
13
|
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
10
14
|
import type { DuckLakeStorageOptions } from "../types"
|
|
11
|
-
import { getBigIntLike,
|
|
15
|
+
import { getBigIntLike, getDate, getOptionalString, getString } from "./duckdb-row"
|
|
12
16
|
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
13
17
|
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
14
18
|
import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
|
|
15
|
-
import { type DatasetTableRef, resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
|
|
16
|
-
import { encodeDatasetTableName } from "./names"
|
|
17
|
-
import { duckLakeMetadataTableName, quoteSqlString } from "./sql"
|
|
18
19
|
import {
|
|
20
|
+
type DatasetTableRef,
|
|
21
|
+
datasetTableIds,
|
|
22
|
+
resolveDatasetTableRef,
|
|
23
|
+
resolveDatasetTableRefAtCurrentSnapshot,
|
|
24
|
+
} from "./ducklake-dataset-table-ref"
|
|
25
|
+
import type { LatestVersionAtSnapshot } from "./ducklake-version-transaction"
|
|
26
|
+
import { decodeLineage } from "./lineage-encoding"
|
|
27
|
+
import {
|
|
28
|
+
decodeDatasetTableName,
|
|
29
|
+
encodeDatasetSequenceTableName,
|
|
30
|
+
encodeDatasetTableName,
|
|
31
|
+
} from "./names"
|
|
32
|
+
import { buildDuckLakeMetadataQuery, duckLakeMetadataTableName, quoteSqlString } from "./sql"
|
|
33
|
+
import {
|
|
34
|
+
changesTouchTables,
|
|
19
35
|
parseCommitMetadata,
|
|
20
|
-
|
|
36
|
+
parseTableDataChange,
|
|
21
37
|
parseVersionId,
|
|
22
38
|
type SixbCommitMetadata,
|
|
23
39
|
toVersionId,
|
|
24
40
|
} from "./versions"
|
|
25
41
|
|
|
26
|
-
interface DatasetSnapshotRow {
|
|
42
|
+
export interface DatasetSnapshotRow {
|
|
27
43
|
readonly snapshotId: string
|
|
28
44
|
readonly tableId: bigint
|
|
29
45
|
readonly createdAt: Date
|
|
@@ -36,30 +52,44 @@ interface DatasetSnapshotCandidateRow {
|
|
|
36
52
|
readonly snapshotId: string
|
|
37
53
|
readonly createdAt: Date
|
|
38
54
|
readonly changesMade: string
|
|
39
|
-
readonly hasFileChange: boolean
|
|
40
|
-
readonly hasFileDeleteChange: boolean
|
|
41
55
|
readonly metadata?: SixbCommitMetadata
|
|
42
56
|
}
|
|
43
57
|
|
|
44
|
-
interface
|
|
58
|
+
interface FeedTable {
|
|
59
|
+
readonly datasetId: string
|
|
45
60
|
readonly tableId: bigint
|
|
61
|
+
/** A dataset cannot have a version before its table exists. */
|
|
62
|
+
readonly startSnapshotId: bigint
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SnapshotCandidateQueryInput {
|
|
66
|
+
readonly table: DatasetTableRef
|
|
46
67
|
readonly exactSnapshotId?: string
|
|
47
68
|
readonly beforeSnapshotId?: string
|
|
48
|
-
readonly limit
|
|
69
|
+
readonly limit: number
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
interface VisibleSnapshotRowsInput {
|
|
52
|
-
readonly
|
|
53
|
-
readonly tableId: bigint
|
|
73
|
+
readonly table: DatasetTableRef
|
|
54
74
|
readonly exactSnapshotId?: string
|
|
55
75
|
readonly beforeSnapshotId?: string
|
|
56
76
|
readonly visibleRowLimit?: number
|
|
57
77
|
}
|
|
58
78
|
|
|
79
|
+
/** A version reference, with the DuckLake table and snapshot it was resolved through. */
|
|
80
|
+
export interface DuckLakeTableVersionRef extends DatasetVersionRef {
|
|
81
|
+
readonly table: DatasetTableRef
|
|
82
|
+
readonly snapshot: DatasetSnapshotRow
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a commit needs of the version it builds on. */
|
|
59
86
|
export interface DuckLakeVersionSummary {
|
|
60
87
|
readonly datasetId: string
|
|
61
88
|
readonly versionId: string
|
|
89
|
+
readonly schema: DatasetSchema
|
|
90
|
+
readonly producer?: DatasetProducer
|
|
62
91
|
readonly inputs?: readonly DatasetVersionRef[]
|
|
92
|
+
readonly lineage?: DatasetVersionLineage
|
|
63
93
|
readonly rowCount?: number
|
|
64
94
|
readonly validatedPrimaryKeyColumns?: readonly string[]
|
|
65
95
|
}
|
|
@@ -69,16 +99,16 @@ interface CatalogScanSnapshot {
|
|
|
69
99
|
readonly snapshotId: string
|
|
70
100
|
readonly createdAt: Date
|
|
71
101
|
readonly changesMade: string
|
|
72
|
-
readonly metadata?: SixbCommitMetadata
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Per (snapshot, table) file-change flags merged into the catalog scan. */
|
|
76
|
-
interface FileChangeFlags {
|
|
77
|
-
readonly hasFileChange: boolean
|
|
78
|
-
readonly hasFileDeleteChange: boolean
|
|
79
102
|
}
|
|
80
103
|
|
|
81
104
|
const SNAPSHOT_ROW_BATCH_SIZE = 128
|
|
105
|
+
// One feed read covers at most this many commits; the next read continues from its cursor.
|
|
106
|
+
const FEED_PAGE_SIZE = 512
|
|
107
|
+
|
|
108
|
+
// A read of the latest version, or of the one before a version, usually finds it among the
|
|
109
|
+
// dataset's most recent snapshots. It first reads only a few of them: each carries the commit
|
|
110
|
+
// metadata of its version, whose lineage may be large.
|
|
111
|
+
const SHORT_READ_BATCH_SIZE = 8
|
|
82
112
|
|
|
83
113
|
// The bulk catalog scan shares one descending walk of recent snapshots across
|
|
84
114
|
// all requested datasets, so its cost is bounded by this window rather than by
|
|
@@ -96,8 +126,13 @@ const CATALOG_SNAPSHOT_SCAN_LIMIT = 512
|
|
|
96
126
|
*
|
|
97
127
|
* This class intentionally reads DuckLake metadata directly. The provider does
|
|
98
128
|
* not keep a Sixb side table for versions; a Sixb version is a DuckLake
|
|
99
|
-
* snapshot
|
|
100
|
-
* Sixb
|
|
129
|
+
* snapshot whose `changes_made` names the dataset table: one that changed its
|
|
130
|
+
* rows, or that altered the table with Sixb commit metadata naming the dataset,
|
|
131
|
+
* as a version that changes no rows does (see the write coordinator's
|
|
132
|
+
* `claimTable`).
|
|
133
|
+
*
|
|
134
|
+
* DuckLake names a table in `changes_made` for every change to its rows, inlined
|
|
135
|
+
* or written to files, so version reads never scan the catalog's file tables.
|
|
101
136
|
*/
|
|
102
137
|
export class DuckLakeSnapshotReader {
|
|
103
138
|
constructor(
|
|
@@ -172,11 +207,11 @@ export class DuckLakeSnapshotReader {
|
|
|
172
207
|
datasetIds: readonly string[]
|
|
173
208
|
): Promise<readonly DatasetCatalogState[]> {
|
|
174
209
|
const uniqueIds = [...new Set(datasetIds)]
|
|
175
|
-
const
|
|
176
|
-
const latestRowByDatasetId = await this.resolveLatestSnapshotRows(runtime,
|
|
210
|
+
const tablesByDatasetId = await this.resolveDatasetTables(runtime, uniqueIds)
|
|
211
|
+
const latestRowByDatasetId = await this.resolveLatestSnapshotRows(runtime, tablesByDatasetId)
|
|
177
212
|
|
|
178
213
|
return uniqueIds.map((datasetId) => {
|
|
179
|
-
if (!
|
|
214
|
+
if (!tablesByDatasetId.has(datasetId)) {
|
|
180
215
|
return { datasetId, materialized: false, latestVersion: null }
|
|
181
216
|
}
|
|
182
217
|
|
|
@@ -189,6 +224,205 @@ export class DuckLakeSnapshotReader {
|
|
|
189
224
|
})
|
|
190
225
|
}
|
|
191
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Change feed over DuckLake's snapshot table, which is an append-only commit log: snapshot ids
|
|
229
|
+
* only increase, and a commit becomes visible only after every lower id has (concurrent commits
|
|
230
|
+
* that pick the same id conflict, and the loser retries with the next one). The cursor is the
|
|
231
|
+
* last snapshot id read. Every query is bounded by a snapshot-id range, so an idle read is one
|
|
232
|
+
* indexed query that returns the cursor's own snapshot, whatever the history length.
|
|
233
|
+
*/
|
|
234
|
+
async listLatestVersionsSince(
|
|
235
|
+
input: ListLatestVersionsSinceInput
|
|
236
|
+
): Promise<LatestVersionsSince | null> {
|
|
237
|
+
this.connections.assertOpen()
|
|
238
|
+
|
|
239
|
+
return this.connections.withAttachedRuntime(async (runtime) => {
|
|
240
|
+
if (input.cursor === null) {
|
|
241
|
+
return { cursor: await this.queryHeadSnapshotId(runtime), versions: [] }
|
|
242
|
+
}
|
|
243
|
+
if (!/^\d+$/.test(input.cursor)) {
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const snapshots = await this.querySnapshotsFrom(runtime, input.cursor)
|
|
248
|
+
// The cursor's own snapshot proves the cursor is still a position in this lake's history.
|
|
249
|
+
if (snapshots[0]?.snapshotId !== input.cursor) {
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
const newSnapshots = snapshots.slice(1)
|
|
253
|
+
if (newSnapshots.length === 0) {
|
|
254
|
+
return { cursor: input.cursor, versions: [] }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const tables = await this.resolveFeedTables(runtime, input.datasetIds)
|
|
258
|
+
const latest = await this.collectFeedPage(runtime, tables, newSnapshots)
|
|
259
|
+
return {
|
|
260
|
+
cursor: newSnapshots[newSnapshots.length - 1]?.snapshotId ?? input.cursor,
|
|
261
|
+
versions: [...latest].map(([datasetId, row]) => this.snapshotRowToSummary(datasetId, row)),
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async queryHeadSnapshotId(runtime: DuckDbQueryRuntime): Promise<string> {
|
|
267
|
+
const [row] = await runtime.query(
|
|
268
|
+
buildDuckLakeMetadataQuery(
|
|
269
|
+
this.options,
|
|
270
|
+
(table) => `SELECT max(snapshot_id) AS snapshot_id FROM ${table("ducklake_snapshot")}`
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
if (row?.snapshot_id === null || row?.snapshot_id === undefined) {
|
|
274
|
+
throw new LakeStorageError("[SixbDuckLake] DuckLake catalog has no snapshots.")
|
|
275
|
+
}
|
|
276
|
+
return String(getBigIntLike(row, "snapshot_id"))
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The cursor's snapshot and up to one feed page of snapshots after it, oldest first. */
|
|
280
|
+
private async querySnapshotsFrom(
|
|
281
|
+
runtime: DuckDbQueryRuntime,
|
|
282
|
+
cursor: string
|
|
283
|
+
): Promise<readonly { readonly snapshotId: string; readonly createdAt: Date }[]> {
|
|
284
|
+
const rows = await runtime.query(
|
|
285
|
+
buildDuckLakeMetadataQuery(
|
|
286
|
+
this.options,
|
|
287
|
+
(table) => `
|
|
288
|
+
SELECT snapshot_id, snapshot_time
|
|
289
|
+
FROM ${table("ducklake_snapshot")}
|
|
290
|
+
WHERE snapshot_id >= ${cursor}
|
|
291
|
+
ORDER BY snapshot_id
|
|
292
|
+
LIMIT ${FEED_PAGE_SIZE + 1}
|
|
293
|
+
`
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
return rows.map((row) => ({
|
|
297
|
+
snapshotId: String(getBigIntLike(row, "snapshot_id")),
|
|
298
|
+
createdAt: getDate(row, "snapshot_time"),
|
|
299
|
+
}))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Current dataset tables, with the first snapshot each table id existed in. */
|
|
303
|
+
private async resolveFeedTables(
|
|
304
|
+
runtime: DuckDbQueryRuntime,
|
|
305
|
+
datasetIds: readonly string[] | undefined
|
|
306
|
+
): Promise<readonly FeedTable[]> {
|
|
307
|
+
const requested = datasetIds === undefined ? null : new Set(datasetIds)
|
|
308
|
+
const rows = await runtime.query(`
|
|
309
|
+
SELECT table_id, table_name, begin_snapshot, end_snapshot
|
|
310
|
+
FROM ${duckLakeMetadataTableName(this.options, "ducklake_table")}
|
|
311
|
+
`)
|
|
312
|
+
|
|
313
|
+
const startByTableId = new Map<bigint, bigint>()
|
|
314
|
+
const current: { datasetId: string; tableId: bigint }[] = []
|
|
315
|
+
for (const row of rows) {
|
|
316
|
+
const tableId = getBigIntLike(row, "table_id")
|
|
317
|
+
const begin = getBigIntLike(row, "begin_snapshot")
|
|
318
|
+
const start = startByTableId.get(tableId)
|
|
319
|
+
if (start === undefined || begin < start) {
|
|
320
|
+
startByTableId.set(tableId, begin)
|
|
321
|
+
}
|
|
322
|
+
if (row.end_snapshot !== null && row.end_snapshot !== undefined) {
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
const datasetId = decodeDatasetTableName(getString(row, "table_name"))
|
|
326
|
+
if (datasetId !== null && (requested === null || requested.has(datasetId))) {
|
|
327
|
+
current.push({ datasetId, tableId })
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return current.map((table) => ({
|
|
332
|
+
...table,
|
|
333
|
+
startSnapshotId: startByTableId.get(table.tableId) ?? 0n,
|
|
334
|
+
}))
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Folds a page of snapshots, oldest first, into each dataset's newest data version. */
|
|
338
|
+
private async collectFeedPage(
|
|
339
|
+
runtime: DuckDbQueryRuntime,
|
|
340
|
+
tables: readonly FeedTable[],
|
|
341
|
+
snapshots: readonly { readonly snapshotId: string; readonly createdAt: Date }[]
|
|
342
|
+
): Promise<Map<string, DatasetSnapshotRow>> {
|
|
343
|
+
const latest = new Map<string, DatasetSnapshotRow>()
|
|
344
|
+
const first = snapshots[0]?.snapshotId
|
|
345
|
+
const last = snapshots[snapshots.length - 1]?.snapshotId
|
|
346
|
+
if (first === undefined || last === undefined || tables.length === 0) {
|
|
347
|
+
return latest
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const changeRows = await runtime.query(
|
|
351
|
+
buildDuckLakeMetadataQuery(
|
|
352
|
+
this.options,
|
|
353
|
+
(table) => `
|
|
354
|
+
SELECT snapshot_id, changes_made, commit_extra_info
|
|
355
|
+
FROM ${table("ducklake_snapshot_changes")}
|
|
356
|
+
WHERE snapshot_id >= ${first} AND snapshot_id <= ${last}
|
|
357
|
+
`
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
const changesBySnapshotId = new Map(
|
|
361
|
+
changeRows.map((row) => [String(getBigIntLike(row, "snapshot_id")), row])
|
|
362
|
+
)
|
|
363
|
+
const tablesByTableId = new Map(tables.map((table) => [table.tableId.toString(), table]))
|
|
364
|
+
|
|
365
|
+
for (const snapshot of snapshots) {
|
|
366
|
+
const changes = changesBySnapshotId.get(snapshot.snapshotId)
|
|
367
|
+
if (changes === undefined) {
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
const changesMade = getString(changes, "changes_made")
|
|
371
|
+
const metadata = parseCommitMetadata(changes.commit_extra_info)
|
|
372
|
+
|
|
373
|
+
// Only the tables this snapshot names can have a version in it.
|
|
374
|
+
const touched = new Set<FeedTable>()
|
|
375
|
+
for (const change of changesMade.split(",")) {
|
|
376
|
+
const table = tablesByTableId.get(change.split(":")[1] ?? "")
|
|
377
|
+
if (table) touched.add(table)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const table of touched) {
|
|
381
|
+
if (BigInt(snapshot.snapshotId) < table.startSnapshotId) {
|
|
382
|
+
continue
|
|
383
|
+
}
|
|
384
|
+
const row = this.candidateToSnapshotRow(table.datasetId, table.tableId, {
|
|
385
|
+
snapshotId: snapshot.snapshotId,
|
|
386
|
+
createdAt: snapshot.createdAt,
|
|
387
|
+
changesMade,
|
|
388
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
389
|
+
})
|
|
390
|
+
if (row && row.mode !== "schema") {
|
|
391
|
+
latest.set(table.datasetId, row)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return latest
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The snapshot of every dataset's latest version, however old, from one shared walk down the
|
|
401
|
+
* whole history: maintenance must never expire one.
|
|
402
|
+
*/
|
|
403
|
+
async latestVersionSnapshotIds(runtime: DuckDbQueryRuntime): Promise<readonly string[]> {
|
|
404
|
+
const tables = await runtime.query(`
|
|
405
|
+
SELECT table_name
|
|
406
|
+
FROM ${duckLakeMetadataTableName(this.options, "ducklake_table")}
|
|
407
|
+
WHERE end_snapshot IS NULL
|
|
408
|
+
`)
|
|
409
|
+
const datasetIds = tables.flatMap((table) => {
|
|
410
|
+
const datasetId = decodeDatasetTableName(getString(table, "table_name"))
|
|
411
|
+
return datasetId === null ? [] : [datasetId]
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
if (datasetIds.length === 0) {
|
|
415
|
+
return []
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const latest = await this.resolveLatestSnapshotRows(
|
|
419
|
+
runtime,
|
|
420
|
+
await this.resolveDatasetTables(runtime, datasetIds),
|
|
421
|
+
Number.POSITIVE_INFINITY
|
|
422
|
+
)
|
|
423
|
+
return [...latest.values()].map((row) => row.snapshotId)
|
|
424
|
+
}
|
|
425
|
+
|
|
192
426
|
private snapshotRowToSummary(
|
|
193
427
|
datasetId: string,
|
|
194
428
|
row: DatasetSnapshotRow
|
|
@@ -202,19 +436,21 @@ export class DuckLakeSnapshotReader {
|
|
|
202
436
|
}
|
|
203
437
|
}
|
|
204
438
|
|
|
205
|
-
private async
|
|
439
|
+
private async resolveDatasetTables(
|
|
206
440
|
runtime: DuckDbQueryRuntime,
|
|
207
441
|
datasetIds: readonly string[]
|
|
208
|
-
): Promise<Map<string,
|
|
442
|
+
): Promise<Map<string, DatasetTableRef>> {
|
|
209
443
|
// Encoded table names are a collision-free bijection with dataset ids, so a
|
|
210
|
-
// single `ducklake_table` read maps every requested id to its current
|
|
444
|
+
// single `ducklake_table` read maps every requested id to its current tables.
|
|
211
445
|
const datasetIdByTableName = new Map<string, string>()
|
|
446
|
+
const datasetIdBySequenceTableName = new Map<string, string>()
|
|
212
447
|
for (const datasetId of datasetIds) {
|
|
213
448
|
datasetIdByTableName.set(encodeDatasetTableName(datasetId), datasetId)
|
|
449
|
+
datasetIdBySequenceTableName.set(encodeDatasetSequenceTableName(datasetId), datasetId)
|
|
214
450
|
}
|
|
215
451
|
|
|
216
452
|
const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
|
|
217
|
-
const tableNameList = [...datasetIdByTableName.keys()]
|
|
453
|
+
const tableNameList = [...datasetIdByTableName.keys(), ...datasetIdBySequenceTableName.keys()]
|
|
218
454
|
.map((name) => quoteSqlString(name))
|
|
219
455
|
.join(", ")
|
|
220
456
|
const rows = await runtime.query(`
|
|
@@ -224,15 +460,31 @@ export class DuckLakeSnapshotReader {
|
|
|
224
460
|
AND end_snapshot IS NULL
|
|
225
461
|
`)
|
|
226
462
|
|
|
227
|
-
const
|
|
463
|
+
const tablesByDatasetId = new Map<string, DatasetTableRef>()
|
|
228
464
|
for (const row of rows) {
|
|
229
|
-
const
|
|
465
|
+
const tableName = getString(row, "table_name")
|
|
466
|
+
const datasetId = datasetIdByTableName.get(tableName)
|
|
230
467
|
if (datasetId !== undefined) {
|
|
231
|
-
|
|
468
|
+
tablesByDatasetId.set(datasetId, {
|
|
469
|
+
datasetId,
|
|
470
|
+
tableName,
|
|
471
|
+
tableId: getBigIntLike(row, "table_id"),
|
|
472
|
+
})
|
|
232
473
|
}
|
|
233
474
|
}
|
|
234
475
|
|
|
235
|
-
|
|
476
|
+
for (const row of rows) {
|
|
477
|
+
const datasetId = datasetIdBySequenceTableName.get(getString(row, "table_name"))
|
|
478
|
+
const table = datasetId === undefined ? undefined : tablesByDatasetId.get(datasetId)
|
|
479
|
+
if (datasetId !== undefined && table !== undefined) {
|
|
480
|
+
tablesByDatasetId.set(datasetId, {
|
|
481
|
+
...table,
|
|
482
|
+
sequenceTableId: getBigIntLike(row, "table_id"),
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return tablesByDatasetId
|
|
236
488
|
}
|
|
237
489
|
|
|
238
490
|
/**
|
|
@@ -244,19 +496,19 @@ export class DuckLakeSnapshotReader {
|
|
|
244
496
|
*/
|
|
245
497
|
private async resolveLatestSnapshotRows(
|
|
246
498
|
runtime: DuckDbQueryRuntime,
|
|
247
|
-
|
|
499
|
+
tablesByDatasetId: ReadonlyMap<string, DatasetTableRef>,
|
|
500
|
+
scanLimit = CATALOG_SNAPSHOT_SCAN_LIMIT
|
|
248
501
|
): Promise<Map<string, DatasetSnapshotRow>> {
|
|
249
502
|
const result = new Map<string, DatasetSnapshotRow>()
|
|
250
|
-
if (
|
|
503
|
+
if (tablesByDatasetId.size === 0) {
|
|
251
504
|
return result
|
|
252
505
|
}
|
|
253
506
|
|
|
254
|
-
const unresolved = new Map<string,
|
|
255
|
-
const tableIds = [...new Set(tableIdsByDatasetId.values())]
|
|
507
|
+
const unresolved = new Map<string, DatasetTableRef>(tablesByDatasetId)
|
|
256
508
|
|
|
257
509
|
let beforeSnapshotId: string | undefined
|
|
258
510
|
let scanned = 0
|
|
259
|
-
while (unresolved.size > 0 && scanned <
|
|
511
|
+
while (unresolved.size > 0 && scanned < scanLimit) {
|
|
260
512
|
const candidates = await this.queryCatalogSnapshotBatch(
|
|
261
513
|
runtime,
|
|
262
514
|
beforeSnapshotId,
|
|
@@ -266,23 +518,27 @@ export class DuckLakeSnapshotReader {
|
|
|
266
518
|
break
|
|
267
519
|
}
|
|
268
520
|
|
|
269
|
-
const
|
|
521
|
+
const tables = [...unresolved.values()]
|
|
522
|
+
const metadata = await this.readCommitMetadata(
|
|
270
523
|
runtime,
|
|
271
|
-
candidates
|
|
272
|
-
|
|
524
|
+
candidates
|
|
525
|
+
.filter((candidate) =>
|
|
526
|
+
tables.some((table) =>
|
|
527
|
+
changesTouchTables(candidate.changesMade, datasetTableIds(table))
|
|
528
|
+
)
|
|
529
|
+
)
|
|
530
|
+
.map((candidate) => candidate.snapshotId)
|
|
273
531
|
)
|
|
274
532
|
|
|
275
533
|
for (const candidate of candidates) {
|
|
276
534
|
scanned += 1
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const row = this.candidateToSnapshotRow(datasetId, tableId, {
|
|
535
|
+
const candidateMetadata = metadata.get(candidate.snapshotId)
|
|
536
|
+
for (const [datasetId, table] of [...unresolved]) {
|
|
537
|
+
const row = this.candidateToSnapshotRow(datasetId, table.tableId, {
|
|
280
538
|
snapshotId: candidate.snapshotId,
|
|
281
539
|
createdAt: candidate.createdAt,
|
|
282
540
|
changesMade: candidate.changesMade,
|
|
283
|
-
|
|
284
|
-
hasFileDeleteChange: flags?.hasFileDeleteChange ?? false,
|
|
285
|
-
...(candidate.metadata !== undefined ? { metadata: candidate.metadata } : {}),
|
|
541
|
+
...(candidateMetadata !== undefined ? { metadata: candidateMetadata } : {}),
|
|
286
542
|
})
|
|
287
543
|
if (row) {
|
|
288
544
|
result.set(datasetId, row)
|
|
@@ -325,8 +581,7 @@ export class DuckLakeSnapshotReader {
|
|
|
325
581
|
SELECT
|
|
326
582
|
snapshot.snapshot_id,
|
|
327
583
|
snapshot.snapshot_time,
|
|
328
|
-
changes.changes_made
|
|
329
|
-
changes.commit_extra_info
|
|
584
|
+
changes.changes_made
|
|
330
585
|
FROM ${ducklakeSnapshot} snapshot
|
|
331
586
|
JOIN ${ducklakeSnapshotChanges} changes ON changes.snapshot_id = snapshot.snapshot_id
|
|
332
587
|
${whereSql}
|
|
@@ -334,70 +589,11 @@ export class DuckLakeSnapshotReader {
|
|
|
334
589
|
LIMIT ${Math.max(0, Math.trunc(limit))}
|
|
335
590
|
`)
|
|
336
591
|
|
|
337
|
-
return rows.map((row) => {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
changesMade: getString(row, "changes_made"),
|
|
343
|
-
...(metadata !== undefined ? { metadata } : {}),
|
|
344
|
-
}
|
|
345
|
-
})
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
private async queryFileChangeFlags(
|
|
349
|
-
runtime: DuckDbQueryRuntime,
|
|
350
|
-
snapshotIds: readonly string[],
|
|
351
|
-
tableIds: readonly bigint[]
|
|
352
|
-
): Promise<Map<string, FileChangeFlags>> {
|
|
353
|
-
const flags = new Map<string, FileChangeFlags>()
|
|
354
|
-
if (snapshotIds.length === 0 || tableIds.length === 0) {
|
|
355
|
-
return flags
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
for (const snapshotId of snapshotIds) {
|
|
359
|
-
assertDuckLakeSnapshotId(snapshotId)
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const snapshotIdList = snapshotIds.join(", ")
|
|
363
|
-
const tableIdList = tableIds.map((tableId) => tableId.toString()).join(", ")
|
|
364
|
-
const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
|
|
365
|
-
const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
|
|
366
|
-
|
|
367
|
-
// Large tables keep their changes in data/delete files instead of inline
|
|
368
|
-
// `changes_made`, so merge file-level changes for the scanned snapshots.
|
|
369
|
-
const rows = await runtime.query(`
|
|
370
|
-
WITH file_changes AS (
|
|
371
|
-
SELECT begin_snapshot AS snapshot_id, table_id, false AS is_delete
|
|
372
|
-
FROM ${ducklakeDataFile}
|
|
373
|
-
WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
|
|
374
|
-
UNION ALL
|
|
375
|
-
SELECT end_snapshot, table_id, true
|
|
376
|
-
FROM ${ducklakeDataFile}
|
|
377
|
-
WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
|
|
378
|
-
UNION ALL
|
|
379
|
-
SELECT begin_snapshot, table_id, true
|
|
380
|
-
FROM ${ducklakeDeleteFile}
|
|
381
|
-
WHERE table_id IN (${tableIdList}) AND begin_snapshot IN (${snapshotIdList})
|
|
382
|
-
UNION ALL
|
|
383
|
-
SELECT end_snapshot, table_id, true
|
|
384
|
-
FROM ${ducklakeDeleteFile}
|
|
385
|
-
WHERE table_id IN (${tableIdList}) AND end_snapshot IN (${snapshotIdList})
|
|
386
|
-
)
|
|
387
|
-
SELECT snapshot_id, table_id, bool_or(is_delete) AS has_delete
|
|
388
|
-
FROM file_changes
|
|
389
|
-
GROUP BY snapshot_id, table_id
|
|
390
|
-
`)
|
|
391
|
-
|
|
392
|
-
for (const row of rows) {
|
|
393
|
-
const key = fileChangeKey(
|
|
394
|
-
String(getBigIntLike(row, "snapshot_id")),
|
|
395
|
-
getBigIntLike(row, "table_id")
|
|
396
|
-
)
|
|
397
|
-
flags.set(key, { hasFileChange: true, hasFileDeleteChange: getBoolean(row, "has_delete") })
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
return flags
|
|
592
|
+
return rows.map((row) => ({
|
|
593
|
+
snapshotId: String(getBigIntLike(row, "snapshot_id")),
|
|
594
|
+
createdAt: getDate(row, "snapshot_time"),
|
|
595
|
+
changesMade: getString(row, "changes_made"),
|
|
596
|
+
}))
|
|
401
597
|
}
|
|
402
598
|
|
|
403
599
|
private assertNoMetadataConflict(
|
|
@@ -423,9 +619,14 @@ export class DuckLakeSnapshotReader {
|
|
|
423
619
|
async getLatestVersionRefForDefinition(
|
|
424
620
|
runtime: DuckDbQueryRuntime,
|
|
425
621
|
dataset: DatasetDefinition
|
|
426
|
-
): Promise<
|
|
427
|
-
const
|
|
428
|
-
|
|
622
|
+
): Promise<DuckLakeTableVersionRef | null> {
|
|
623
|
+
const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
|
|
624
|
+
if (!tableRef) {
|
|
625
|
+
return null
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const row = await this.getLatestSnapshotRowForTableRef(runtime, tableRef)
|
|
629
|
+
return row ? tableVersionRef(tableRef, row) : null
|
|
429
630
|
}
|
|
430
631
|
|
|
431
632
|
async getLatestVersionSummaryForDefinition(
|
|
@@ -433,19 +634,49 @@ export class DuckLakeSnapshotReader {
|
|
|
433
634
|
dataset: DatasetDefinition
|
|
434
635
|
): Promise<DuckLakeVersionSummary | null> {
|
|
435
636
|
const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
|
|
436
|
-
|
|
437
|
-
|
|
637
|
+
return tableRef ? this.getLatestVersionSummaryForTableRef(runtime, tableRef) : null
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The latest version a commit builds on, with the lake's snapshot it was read at. Its first
|
|
642
|
+
* read fixes the snapshot of a transaction it runs in (see `beginTransactionOnLatestVersion`).
|
|
643
|
+
*/
|
|
644
|
+
async getLatestVersionSummaryAtCurrentSnapshot(
|
|
645
|
+
runtime: DuckDbQueryRuntime,
|
|
646
|
+
dataset: DatasetDefinition
|
|
647
|
+
): Promise<LatestVersionAtSnapshot<DuckLakeVersionSummary>> {
|
|
648
|
+
const { snapshotId, table } = await resolveDatasetTableRefAtCurrentSnapshot(
|
|
649
|
+
this.options,
|
|
650
|
+
runtime,
|
|
651
|
+
dataset.id
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
return {
|
|
655
|
+
snapshotId,
|
|
656
|
+
latestVersion: table ? await this.getLatestVersionSummaryForTableRef(runtime, table) : null,
|
|
438
657
|
}
|
|
658
|
+
}
|
|
439
659
|
|
|
660
|
+
private async getLatestVersionSummaryForTableRef(
|
|
661
|
+
runtime: DuckDbQueryRuntime,
|
|
662
|
+
tableRef: DatasetTableRef
|
|
663
|
+
): Promise<DuckLakeVersionSummary | null> {
|
|
440
664
|
const row = await this.getLatestSnapshotRowForTableRef(runtime, tableRef)
|
|
441
665
|
if (!row) {
|
|
442
666
|
return null
|
|
443
667
|
}
|
|
444
668
|
|
|
669
|
+
const [schema] = await this.datasets.getDatasetSchemasAtSnapshots(runtime, [
|
|
670
|
+
{ table: tableRef, snapshotId: row.snapshotId },
|
|
671
|
+
])
|
|
672
|
+
|
|
445
673
|
return {
|
|
446
674
|
datasetId: tableRef.datasetId,
|
|
447
675
|
versionId: toVersionId(row.snapshotId),
|
|
676
|
+
schema,
|
|
677
|
+
producer: row.metadata?.producer,
|
|
448
678
|
inputs: row.metadata?.inputs,
|
|
679
|
+
...storedLineage(tableRef.datasetId, row),
|
|
449
680
|
...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
|
|
450
681
|
...(row.metadata?.validatedPrimaryKeyColumns !== undefined
|
|
451
682
|
? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns }
|
|
@@ -472,14 +703,14 @@ export class DuckLakeSnapshotReader {
|
|
|
472
703
|
const match = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
|
|
473
704
|
includeParent: true,
|
|
474
705
|
})
|
|
475
|
-
return match ? this.
|
|
706
|
+
return match ? (await this.snapshotsToVersions(runtime, tableRef, [match]))[0] : null
|
|
476
707
|
}
|
|
477
708
|
|
|
478
709
|
async getVersionRefForSnapshot(
|
|
479
710
|
runtime: DuckDbQueryRuntime,
|
|
480
711
|
dataset: DatasetDefinition,
|
|
481
712
|
snapshotId: string
|
|
482
|
-
): Promise<
|
|
713
|
+
): Promise<DuckLakeTableVersionRef | null> {
|
|
483
714
|
assertDuckLakeSnapshotId(snapshotId)
|
|
484
715
|
|
|
485
716
|
const tableRef = await resolveDatasetTableRef(this.options, runtime, dataset.id)
|
|
@@ -490,7 +721,23 @@ export class DuckLakeSnapshotReader {
|
|
|
490
721
|
const row = await this.getSnapshotRowForTableRef(runtime, tableRef, snapshotId, {
|
|
491
722
|
includeParent: false,
|
|
492
723
|
})
|
|
493
|
-
return row ?
|
|
724
|
+
return row ? tableVersionRef(tableRef, row) : null
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The versions resolved refs point to, with their schemas from one catalog read. Refs resolve
|
|
729
|
+
* without their parent, so these versions carry no `parentVersionId`.
|
|
730
|
+
*/
|
|
731
|
+
async getVersionsForRefs(
|
|
732
|
+
runtime: DuckDbQueryRuntime,
|
|
733
|
+
refs: readonly DuckLakeTableVersionRef[]
|
|
734
|
+
): Promise<DatasetVersion[]> {
|
|
735
|
+
const schemas = await this.datasets.getDatasetSchemasAtSnapshots(
|
|
736
|
+
runtime,
|
|
737
|
+
refs.map((ref) => ({ table: ref.table, snapshotId: ref.snapshot.snapshotId }))
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
return refs.map((ref, index) => snapshotToVersion(ref.table, ref.snapshot, schemas[index]))
|
|
494
741
|
}
|
|
495
742
|
|
|
496
743
|
async listVersionsForDefinition(
|
|
@@ -508,13 +755,7 @@ export class DuckLakeSnapshotReader {
|
|
|
508
755
|
limit?: number
|
|
509
756
|
): Promise<readonly DatasetVersion[]> {
|
|
510
757
|
const limitedRows = await this.getSnapshotRowsForTableRef(runtime, tableRef, limit)
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
for (const row of limitedRows) {
|
|
514
|
-
versions.push(await this.snapshotToVersion(runtime, tableRef, row))
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
return versions
|
|
758
|
+
return this.snapshotsToVersions(runtime, tableRef, limitedRows)
|
|
518
759
|
}
|
|
519
760
|
|
|
520
761
|
async getLatestVersionForTableRef(
|
|
@@ -530,8 +771,7 @@ export class DuckLakeSnapshotReader {
|
|
|
530
771
|
tableRef: DatasetTableRef
|
|
531
772
|
): Promise<DatasetSnapshotRow | null> {
|
|
532
773
|
const [row] = await this.collectVisibleSnapshotRows(runtime, {
|
|
533
|
-
|
|
534
|
-
tableId: tableRef.tableId,
|
|
774
|
+
table: tableRef,
|
|
535
775
|
visibleRowLimit: 1,
|
|
536
776
|
})
|
|
537
777
|
return row ?? null
|
|
@@ -546,8 +786,7 @@ export class DuckLakeSnapshotReader {
|
|
|
546
786
|
assertDuckLakeSnapshotId(snapshotId)
|
|
547
787
|
|
|
548
788
|
const [row] = await this.collectVisibleSnapshotRows(runtime, {
|
|
549
|
-
|
|
550
|
-
tableId: tableRef.tableId,
|
|
789
|
+
table: tableRef,
|
|
551
790
|
exactSnapshotId: snapshotId,
|
|
552
791
|
visibleRowLimit: 1,
|
|
553
792
|
})
|
|
@@ -563,8 +802,7 @@ export class DuckLakeSnapshotReader {
|
|
|
563
802
|
}
|
|
564
803
|
|
|
565
804
|
const [parent] = await this.collectVisibleSnapshotRows(runtime, {
|
|
566
|
-
|
|
567
|
-
tableId: tableRef.tableId,
|
|
805
|
+
table: tableRef,
|
|
568
806
|
beforeSnapshotId: snapshotId,
|
|
569
807
|
visibleRowLimit: 1,
|
|
570
808
|
})
|
|
@@ -583,8 +821,7 @@ export class DuckLakeSnapshotReader {
|
|
|
583
821
|
}
|
|
584
822
|
|
|
585
823
|
const rows = await this.collectVisibleSnapshotRows(runtime, {
|
|
586
|
-
|
|
587
|
-
tableId: tableRef.tableId,
|
|
824
|
+
table: tableRef,
|
|
588
825
|
visibleRowLimit: visibleLimit === undefined ? undefined : visibleLimit + 1,
|
|
589
826
|
})
|
|
590
827
|
const rowsWithParents = this.withParentSnapshotIds(rows)
|
|
@@ -601,20 +838,29 @@ export class DuckLakeSnapshotReader {
|
|
|
601
838
|
|
|
602
839
|
const snapshots: DatasetSnapshotRow[] = []
|
|
603
840
|
let beforeSnapshotId = input.beforeSnapshotId
|
|
841
|
+
let shortRead =
|
|
842
|
+
input.exactSnapshotId !== undefined ||
|
|
843
|
+
(input.visibleRowLimit !== undefined && input.visibleRowLimit <= 2)
|
|
604
844
|
|
|
605
845
|
while (input.visibleRowLimit === undefined || snapshots.length < input.visibleRowLimit) {
|
|
846
|
+
const batchSize = shortRead ? SHORT_READ_BATCH_SIZE : SNAPSHOT_ROW_BATCH_SIZE
|
|
606
847
|
const candidates = await this.querySnapshotCandidates(runtime, {
|
|
607
|
-
|
|
848
|
+
table: input.table,
|
|
608
849
|
exactSnapshotId: input.exactSnapshotId,
|
|
609
850
|
beforeSnapshotId,
|
|
610
|
-
limit: input.exactSnapshotId === undefined ?
|
|
851
|
+
limit: input.exactSnapshotId === undefined ? batchSize : 1,
|
|
611
852
|
})
|
|
853
|
+
shortRead = false
|
|
612
854
|
if (candidates.length === 0) {
|
|
613
855
|
break
|
|
614
856
|
}
|
|
615
857
|
|
|
616
858
|
for (const candidate of candidates) {
|
|
617
|
-
const snapshot = this.candidateToSnapshotRow(
|
|
859
|
+
const snapshot = this.candidateToSnapshotRow(
|
|
860
|
+
input.table.datasetId,
|
|
861
|
+
input.table.tableId,
|
|
862
|
+
candidate
|
|
863
|
+
)
|
|
618
864
|
if (snapshot) {
|
|
619
865
|
snapshots.push(snapshot)
|
|
620
866
|
}
|
|
@@ -624,7 +870,7 @@ export class DuckLakeSnapshotReader {
|
|
|
624
870
|
}
|
|
625
871
|
}
|
|
626
872
|
|
|
627
|
-
if (input.exactSnapshotId !== undefined || candidates.length <
|
|
873
|
+
if (input.exactSnapshotId !== undefined || candidates.length < batchSize) {
|
|
628
874
|
break
|
|
629
875
|
}
|
|
630
876
|
|
|
@@ -634,121 +880,133 @@ export class DuckLakeSnapshotReader {
|
|
|
634
880
|
return snapshots
|
|
635
881
|
}
|
|
636
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Hydrates, newest first, the snapshots whose changes name one of the dataset's tables: every
|
|
885
|
+
* snapshot that may hold one of its versions. The text match is a superset;
|
|
886
|
+
* candidateToSnapshotRow decides visibility.
|
|
887
|
+
*
|
|
888
|
+
* The query walks the catalog's snapshot ids downwards, from `beforeSnapshotId` or the head,
|
|
889
|
+
* and stops at the limit or at the table's creation. A lookup therefore reads the snapshots
|
|
890
|
+
* between where it starts and the versions it returns: a few for an active dataset, at most the
|
|
891
|
+
* table's lifetime for one written long ago. It never reads the file tables, whose replaced
|
|
892
|
+
* files stay until cleanup.
|
|
893
|
+
*/
|
|
637
894
|
private async querySnapshotCandidates(
|
|
638
895
|
runtime: DuckDbQueryRuntime,
|
|
639
896
|
input: SnapshotCandidateQueryInput
|
|
640
897
|
): Promise<readonly DatasetSnapshotCandidateRow[]> {
|
|
641
|
-
|
|
898
|
+
const limit = Math.max(0, Math.trunc(input.limit))
|
|
899
|
+
if (limit === 0) {
|
|
642
900
|
return []
|
|
643
901
|
}
|
|
644
|
-
|
|
645
|
-
// A dataset cannot have a version before its table exists. Use the earliest table record:
|
|
646
|
-
// a rename may create a newer record with the same table id and must not hide older versions.
|
|
647
|
-
const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
|
|
648
|
-
const where = [
|
|
649
|
-
`snapshot.snapshot_id >= (SELECT min(begin_snapshot) FROM ${ducklakeTable} WHERE table_id = ${input.tableId})`,
|
|
650
|
-
]
|
|
651
902
|
if (input.exactSnapshotId !== undefined) {
|
|
652
903
|
assertDuckLakeSnapshotId(input.exactSnapshotId)
|
|
653
|
-
where.push(`snapshot.snapshot_id = ${input.exactSnapshotId}`)
|
|
654
904
|
}
|
|
655
905
|
if (input.beforeSnapshotId !== undefined) {
|
|
656
906
|
assertDuckLakeSnapshotId(input.beforeSnapshotId)
|
|
657
|
-
where.push(`snapshot.snapshot_id < ${input.beforeSnapshotId}`)
|
|
658
907
|
}
|
|
659
908
|
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
909
|
+
const rows = await runtime.query(
|
|
910
|
+
buildDuckLakeMetadataQuery(this.options, (table) => {
|
|
911
|
+
// A dataset cannot have a version before its table exists. Use the earliest table record:
|
|
912
|
+
// a rename may create a newer record with the same table id and must not hide older
|
|
913
|
+
// versions.
|
|
914
|
+
const bounds = [
|
|
915
|
+
`changes.snapshot_id >= (
|
|
916
|
+
SELECT min(begin_snapshot)
|
|
917
|
+
FROM ${table("ducklake_table")}
|
|
918
|
+
WHERE table_id = ${input.table.tableId}
|
|
919
|
+
)`,
|
|
920
|
+
]
|
|
921
|
+
if (input.exactSnapshotId !== undefined) {
|
|
922
|
+
bounds.push(`changes.snapshot_id = ${input.exactSnapshotId}`)
|
|
923
|
+
}
|
|
924
|
+
if (input.beforeSnapshotId !== undefined) {
|
|
925
|
+
bounds.push(`changes.snapshot_id < ${input.beforeSnapshotId}`)
|
|
926
|
+
}
|
|
670
927
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
|
|
697
|
-
UNION ALL
|
|
698
|
-
SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
|
|
699
|
-
FROM ${ducklakeDeleteFile}
|
|
700
|
-
WHERE table_id = ${input.tableId}
|
|
701
|
-
AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
|
|
702
|
-
UNION ALL
|
|
703
|
-
SELECT end_snapshot AS snapshot_id, true AS is_delete_change
|
|
704
|
-
FROM ${ducklakeDeleteFile}
|
|
705
|
-
WHERE table_id = ${input.tableId}
|
|
706
|
-
AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
|
|
707
|
-
),
|
|
708
|
-
file_changes_by_snapshot AS (
|
|
709
|
-
-- Collapse file-level changes into one row per snapshot.
|
|
710
|
-
SELECT
|
|
711
|
-
snapshot_id,
|
|
712
|
-
count(*) > 0 AS has_file_change,
|
|
713
|
-
count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
|
|
714
|
-
FROM file_changes
|
|
715
|
-
GROUP BY snapshot_id
|
|
716
|
-
)
|
|
717
|
-
SELECT
|
|
718
|
-
-- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
|
|
719
|
-
candidate.snapshot_id,
|
|
720
|
-
candidate.snapshot_time,
|
|
721
|
-
candidate.changes_made,
|
|
722
|
-
candidate.commit_extra_info,
|
|
723
|
-
coalesce(file_changes.has_file_change, false) AS has_file_change,
|
|
724
|
-
coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
|
|
725
|
-
FROM candidate_snapshots candidate
|
|
726
|
-
LEFT JOIN file_changes_by_snapshot file_changes
|
|
727
|
-
ON file_changes.snapshot_id = candidate.snapshot_id
|
|
728
|
-
ORDER BY candidate.snapshot_id DESC
|
|
729
|
-
`)
|
|
928
|
+
const namesDatasetTable = datasetTableIds(input.table)
|
|
929
|
+
.map(
|
|
930
|
+
(tableId) =>
|
|
931
|
+
`strpos(',' || changes.changes_made || ',', ${quoteSqlString(`:${tableId},`)}) > 0`
|
|
932
|
+
)
|
|
933
|
+
.join(" OR ")
|
|
934
|
+
|
|
935
|
+
// Bounds and order follow the snapshot-id primary key, so PostgreSQL reads it backwards
|
|
936
|
+
// and stops at the limit; the text match only filters the rows it passes.
|
|
937
|
+
return `
|
|
938
|
+
SELECT
|
|
939
|
+
changes.snapshot_id,
|
|
940
|
+
snapshot.snapshot_time,
|
|
941
|
+
changes.changes_made,
|
|
942
|
+
changes.commit_extra_info
|
|
943
|
+
FROM ${table("ducklake_snapshot_changes")} changes
|
|
944
|
+
JOIN ${table("ducklake_snapshot")} snapshot
|
|
945
|
+
ON snapshot.snapshot_id = changes.snapshot_id
|
|
946
|
+
WHERE ${bounds.join(" AND ")}
|
|
947
|
+
AND (${namesDatasetTable})
|
|
948
|
+
ORDER BY changes.snapshot_id DESC
|
|
949
|
+
LIMIT ${limit}
|
|
950
|
+
`
|
|
951
|
+
})
|
|
952
|
+
)
|
|
730
953
|
|
|
731
954
|
return rows.map((row) => {
|
|
732
955
|
const metadata = parseCommitMetadata(row.commit_extra_info)
|
|
956
|
+
|
|
733
957
|
return {
|
|
734
958
|
snapshotId: String(getBigIntLike(row, "snapshot_id")),
|
|
735
959
|
createdAt: getDate(row, "snapshot_time"),
|
|
736
960
|
changesMade: getString(row, "changes_made"),
|
|
737
|
-
hasFileChange: getBoolean(row, "has_file_change"),
|
|
738
|
-
hasFileDeleteChange: getBoolean(row, "has_file_delete_change"),
|
|
739
961
|
...(metadata !== undefined ? { metadata } : {}),
|
|
740
962
|
}
|
|
741
963
|
})
|
|
742
964
|
}
|
|
743
965
|
|
|
966
|
+
/**
|
|
967
|
+
* Sixb commit metadata of the snapshots that changed a scanned dataset. Each one carries the
|
|
968
|
+
* lineage of its version, which can be large: scans read it only for the snapshots they keep,
|
|
969
|
+
* never for the other datasets' snapshots they page past.
|
|
970
|
+
*/
|
|
971
|
+
private async readCommitMetadata(
|
|
972
|
+
runtime: DuckDbQueryRuntime,
|
|
973
|
+
snapshotIds: readonly string[]
|
|
974
|
+
): Promise<Map<string, SixbCommitMetadata>> {
|
|
975
|
+
const metadata = new Map<string, SixbCommitMetadata>()
|
|
976
|
+
if (snapshotIds.length === 0) {
|
|
977
|
+
return metadata
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
for (const snapshotId of snapshotIds) {
|
|
981
|
+
assertDuckLakeSnapshotId(snapshotId)
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const ducklakeSnapshotChanges = duckLakeMetadataTableName(
|
|
985
|
+
this.options,
|
|
986
|
+
"ducklake_snapshot_changes"
|
|
987
|
+
)
|
|
988
|
+
const rows = await runtime.query(`
|
|
989
|
+
SELECT snapshot_id, commit_extra_info
|
|
990
|
+
FROM ${ducklakeSnapshotChanges}
|
|
991
|
+
WHERE snapshot_id IN (${snapshotIds.join(", ")})
|
|
992
|
+
`)
|
|
993
|
+
|
|
994
|
+
for (const row of rows) {
|
|
995
|
+
const parsed = parseCommitMetadata(getOptionalString(row, "commit_extra_info"))
|
|
996
|
+
if (parsed !== undefined) {
|
|
997
|
+
metadata.set(String(getBigIntLike(row, "snapshot_id")), parsed)
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
return metadata
|
|
1002
|
+
}
|
|
1003
|
+
|
|
744
1004
|
private candidateToSnapshotRow(
|
|
745
1005
|
datasetId: string,
|
|
746
1006
|
tableId: bigint,
|
|
747
1007
|
candidate: DatasetSnapshotCandidateRow
|
|
748
1008
|
): DatasetSnapshotRow | null {
|
|
749
|
-
const
|
|
750
|
-
const hasDataChange = inlineChange.hasDataChange || candidate.hasFileChange
|
|
751
|
-
const hasDeleteChange = inlineChange.hasDeleteChange || candidate.hasFileDeleteChange
|
|
1009
|
+
const { hasDataChange, hasDeleteChange } = parseTableDataChange(candidate.changesMade, tableId)
|
|
752
1010
|
|
|
753
1011
|
// Metadata-only snapshots are common for table comments, schema changes,
|
|
754
1012
|
// and other catalog operations. Treat them as dataset versions only when
|
|
@@ -782,36 +1040,17 @@ export class DuckLakeSnapshotReader {
|
|
|
782
1040
|
}
|
|
783
1041
|
}
|
|
784
1042
|
|
|
785
|
-
|
|
1043
|
+
/** One catalog read for the schemas of every listed version, not one per version. */
|
|
1044
|
+
private async snapshotsToVersions(
|
|
786
1045
|
runtime: DuckDbQueryRuntime,
|
|
787
1046
|
tableRef: DatasetTableRef,
|
|
788
|
-
|
|
789
|
-
): Promise<DatasetVersion> {
|
|
790
|
-
const
|
|
1047
|
+
rows: readonly DatasetSnapshotRow[]
|
|
1048
|
+
): Promise<DatasetVersion[]> {
|
|
1049
|
+
const schemas = await this.datasets.getDatasetSchemasAtSnapshots(
|
|
791
1050
|
runtime,
|
|
792
|
-
tableRef.
|
|
793
|
-
tableRef.tableName,
|
|
794
|
-
tableRef.tableId,
|
|
795
|
-
row.snapshotId
|
|
1051
|
+
rows.map((row) => ({ table: tableRef, snapshotId: row.snapshotId }))
|
|
796
1052
|
)
|
|
797
|
-
|
|
798
|
-
// DuckLake gives us version id, timestamp, and time travel. Sixb metadata
|
|
799
|
-
// fills in caller intent such as append vs snapshot and producer lineage.
|
|
800
|
-
// Version listing is a metadata path, so it only carries row counts already
|
|
801
|
-
// stored in Sixb commit metadata and never counts historical table contents.
|
|
802
|
-
|
|
803
|
-
return {
|
|
804
|
-
datasetId: tableRef.datasetId,
|
|
805
|
-
versionId: toVersionId(row.snapshotId),
|
|
806
|
-
parentVersionId:
|
|
807
|
-
row.parentSnapshotId === undefined ? undefined : toVersionId(row.parentSnapshotId),
|
|
808
|
-
mode: row.mode,
|
|
809
|
-
createdAt: row.createdAt,
|
|
810
|
-
schema: schemaAtSnapshot,
|
|
811
|
-
producer: row.metadata?.producer,
|
|
812
|
-
inputs: row.metadata?.inputs,
|
|
813
|
-
...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
|
|
814
|
-
}
|
|
1053
|
+
return rows.map((row, index) => snapshotToVersion(tableRef, row, schemas[index]))
|
|
815
1054
|
}
|
|
816
1055
|
|
|
817
1056
|
private withParentSnapshotIds(
|
|
@@ -829,12 +1068,68 @@ export class DuckLakeSnapshotReader {
|
|
|
829
1068
|
}
|
|
830
1069
|
}
|
|
831
1070
|
|
|
1071
|
+
function snapshotToVersion(
|
|
1072
|
+
tableRef: DatasetTableRef,
|
|
1073
|
+
row: DatasetSnapshotRow,
|
|
1074
|
+
schema: DatasetVersion["schema"]
|
|
1075
|
+
): DatasetVersion {
|
|
1076
|
+
// DuckLake gives us version id, timestamp, and time travel. Sixb metadata
|
|
1077
|
+
// fills in caller intent such as append vs snapshot and producer lineage.
|
|
1078
|
+
// Version listing is a metadata path, so it only carries row counts already
|
|
1079
|
+
// stored in Sixb commit metadata and never counts historical table contents.
|
|
1080
|
+
|
|
1081
|
+
return {
|
|
1082
|
+
datasetId: tableRef.datasetId,
|
|
1083
|
+
versionId: toVersionId(row.snapshotId),
|
|
1084
|
+
parentVersionId:
|
|
1085
|
+
row.parentSnapshotId === undefined ? undefined : toVersionId(row.parentSnapshotId),
|
|
1086
|
+
mode: row.mode,
|
|
1087
|
+
createdAt: row.createdAt,
|
|
1088
|
+
schema,
|
|
1089
|
+
producer: row.metadata?.producer,
|
|
1090
|
+
inputs: row.metadata?.inputs,
|
|
1091
|
+
...storedLineage(tableRef.datasetId, row),
|
|
1092
|
+
...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* A data snapshot without Sixb metadata was written outside Sixb, by any DuckLake client. It may
|
|
1098
|
+
* keep rows of its parent whatever change it made, and nothing says where its own rows come from:
|
|
1099
|
+
* its lineage is unknown.
|
|
1100
|
+
*/
|
|
1101
|
+
function storedLineage(
|
|
1102
|
+
datasetId: string,
|
|
1103
|
+
row: DatasetSnapshotRow
|
|
1104
|
+
): Pick<DatasetVersion, "lineage"> {
|
|
1105
|
+
const metadata = row.metadata
|
|
1106
|
+
if (metadata === undefined) {
|
|
1107
|
+
return {
|
|
1108
|
+
lineage: {
|
|
1109
|
+
unknown: `version '${toVersionId(row.snapshotId)}' of '${datasetId}' was written outside Sixb`,
|
|
1110
|
+
},
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return metadata.lineage === undefined
|
|
1115
|
+
? {}
|
|
1116
|
+
: { lineage: decodeLineage(metadata.datasetId, metadata.lineage) }
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function tableVersionRef(
|
|
1120
|
+
table: DatasetTableRef,
|
|
1121
|
+
snapshot: DatasetSnapshotRow
|
|
1122
|
+
): DuckLakeTableVersionRef {
|
|
1123
|
+
return {
|
|
1124
|
+
datasetId: table.datasetId,
|
|
1125
|
+
versionId: toVersionId(snapshot.snapshotId),
|
|
1126
|
+
table,
|
|
1127
|
+
snapshot,
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
832
1131
|
function assertDuckLakeSnapshotId(snapshotId: string): void {
|
|
833
1132
|
if (!/^\d+$/.test(snapshotId)) {
|
|
834
1133
|
throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`)
|
|
835
1134
|
}
|
|
836
1135
|
}
|
|
837
|
-
|
|
838
|
-
function fileChangeKey(snapshotId: string, tableId: bigint): string {
|
|
839
|
-
return `${snapshotId}|${tableId}`
|
|
840
|
-
}
|