@sixb/ducklake 0.1.6 → 0.1.8
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 +25 -27
- package/dist/ducklake-storage.d.ts +2 -1
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +736 -244
- package/dist/index.js.map +14 -12
- package/dist/internal/duckdb-reader.d.ts +28 -0
- package/dist/internal/duckdb-reader.d.ts.map +1 -0
- package/dist/internal/duckdb-runtime.d.ts +5 -3
- package/dist/internal/duckdb-runtime.d.ts.map +1 -1
- package/dist/internal/ducklake-connection-manager.d.ts +19 -3
- package/dist/internal/ducklake-connection-manager.d.ts.map +1 -1
- package/dist/internal/ducklake-read-gate.d.ts +11 -0
- package/dist/internal/ducklake-read-gate.d.ts.map +1 -0
- package/dist/internal/ducklake-row-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-snapshot-reader.d.ts +26 -1
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-write-coordinator.d.ts +2 -1
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
- package/dist/internal/sql.d.ts +11 -2
- package/dist/internal/sql.d.ts.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/ducklake-storage.ts +8 -0
- package/src/internal/duckdb-reader.ts +129 -0
- package/src/internal/duckdb-runtime.ts +75 -51
- package/src/internal/ducklake-connection-manager.ts +117 -33
- package/src/internal/ducklake-maintenance.ts +1 -1
- package/src/internal/ducklake-read-gate.ts +58 -0
- package/src/internal/ducklake-row-reader.ts +84 -63
- package/src/internal/ducklake-snapshot-reader.ts +331 -79
- package/src/internal/ducklake-write-coordinator.ts +56 -15
- package/src/internal/ducklake-write-session.ts +1 -0
- package/src/internal/sql.ts +35 -6
- package/src/types.ts +2 -0
|
@@ -2,6 +2,7 @@ import type { DatasetColumnDefinition, DatasetRow } from "@sixb/core"
|
|
|
2
2
|
import type { DatasetVersion, ReadDatasetRowsInput } from "@sixb/core/lake-storage"
|
|
3
3
|
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
4
4
|
import type { DuckLakeStorageOptions } from "../types"
|
|
5
|
+
import type { DuckDbReader } from "./duckdb-reader"
|
|
5
6
|
import { getBigIntLike } from "./duckdb-row"
|
|
6
7
|
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
7
8
|
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
@@ -11,10 +12,7 @@ import { normalizeReadValue } from "./schema"
|
|
|
11
12
|
import { qualifiedTableName, quoteIdentifier } from "./sql"
|
|
12
13
|
import { parseVersionId } from "./versions"
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
// bounding converted-row memory, this lets a JavaScript pipeline feed one DuckLake dataset into
|
|
16
|
-
// another: the destination appender can use the runtime after each page instead of waiting forever
|
|
17
|
-
// behind its own still-open source stream.
|
|
15
|
+
// Bounded fallback for catalogs whose metadata transactions cannot stay open during a read.
|
|
18
16
|
const READ_PAGE_ROWS = 5_000
|
|
19
17
|
const PHYSICAL_ROW_ID_ALIAS = "__sixb_physical_row_id"
|
|
20
18
|
|
|
@@ -33,78 +31,88 @@ export class DuckLakeRowReader {
|
|
|
33
31
|
) {}
|
|
34
32
|
|
|
35
33
|
async *readRows(input: ReadDatasetRowsInput): AsyncIterable<DatasetRow> {
|
|
36
|
-
this.connections.
|
|
37
|
-
|
|
38
|
-
// Keep the attachment lease across pages so local catalogs do not pay DETACH/ATTACH for every
|
|
39
|
-
// page. Individual query() calls still release the DuckDB queue before rows are yielded.
|
|
40
|
-
const lease = await this.connections.acquireAttachedRuntime()
|
|
34
|
+
const read = await this.connections.acquireRead(input.signal)
|
|
35
|
+
let reader: DuckDbReader | null = null
|
|
41
36
|
try {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
const prepared = await this.connections.withAttachedRuntime(async (runtime) => {
|
|
38
|
+
read.signal.throwIfAborted()
|
|
39
|
+
const tableRef = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
|
|
40
|
+
if (!tableRef) {
|
|
41
|
+
throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.datasetId}'.`)
|
|
42
|
+
}
|
|
43
|
+
// Resolve latest once, before yielding any rows. Every query names the same snapshot.
|
|
44
|
+
const version = await this.resolveVersion(runtime, tableRef, input.versionId)
|
|
45
|
+
const columns = this.resolveReadColumns(tableRef.datasetId, version.schema, input.columns)
|
|
46
|
+
const names = new Set(version.schema.columns.map((column) => column.name.toLowerCase()))
|
|
47
|
+
const physicalOrder = !names.has("rowid") && !names.has(PHYSICAL_ROW_ID_ALIAS)
|
|
48
|
+
const relation = `${qualifiedTableName(this.options, tableRef.tableName)} AT (VERSION => ${parseVersionId(version.versionId)})`
|
|
49
|
+
const columnsSql = columns.map((column) => quoteIdentifier(column.name)).join(", ")
|
|
50
|
+
const offset = Math.max(0, Math.trunc(input.offset ?? 0))
|
|
51
|
+
const limit = input.limit === undefined ? undefined : Math.max(0, Math.trunc(input.limit))
|
|
52
|
+
const sql = `SELECT ${columnsSql} FROM ${relation}${physicalOrder ? " ORDER BY rowid" : ""}${limit === undefined ? "" : ` LIMIT ${limit}`}${offset > 0 ? ` OFFSET ${offset}` : ""}`
|
|
53
|
+
const cursor =
|
|
54
|
+
limit === undefined || limit > READ_PAGE_ROWS
|
|
55
|
+
? await this.connections.openReader(runtime, sql, read.signal, read.release)
|
|
56
|
+
: null
|
|
57
|
+
const query = { columnsSql, relation, physicalOrder }
|
|
58
|
+
const firstPage =
|
|
59
|
+
cursor || limit === 0
|
|
60
|
+
? []
|
|
61
|
+
: await runtime.query(
|
|
62
|
+
pageSql(query, Math.min(limit ?? READ_PAGE_ROWS, READ_PAGE_ROWS), offset, null)
|
|
63
|
+
)
|
|
64
|
+
return { columns, ...query, offset, limit, cursor, firstPage }
|
|
65
|
+
})
|
|
66
|
+
reader = prepared.cursor
|
|
67
|
+
// A paused paged reader owns no native query. Cancellation can release its read lease without
|
|
68
|
+
// waiting for the consumer to call next()/return(); native readers release after cleanup.
|
|
69
|
+
if (!reader) read.signal.addEventListener("abort", read.release, { once: true })
|
|
70
|
+
read.signal.throwIfAborted()
|
|
71
|
+
if (reader) {
|
|
72
|
+
yield* this.normalizeRows(reader.rows(), prepared.columns, read.signal)
|
|
73
|
+
return
|
|
48
74
|
}
|
|
49
|
-
const version = await this.resolveVersion(runtime, tableRef, input.versionId)
|
|
50
|
-
const selectedColumns = this.resolveReadColumns(
|
|
51
|
-
tableRef.datasetId,
|
|
52
|
-
version.schema,
|
|
53
|
-
input.columns
|
|
54
|
-
)
|
|
55
|
-
const snapshotId = parseVersionId(version.versionId)
|
|
56
|
-
const columnsSql = selectedColumns.map((column) => quoteIdentifier(column.name)).join(", ")
|
|
57
|
-
const tableSql = qualifiedTableName(this.options, tableRef.tableName)
|
|
58
|
-
// A user column named `rowid` shadows DuckDB's virtual row id. In that edge case the
|
|
59
|
-
// connection-level invariant still preserves insertion order; otherwise make it explicit.
|
|
60
|
-
const schemaNames = new Set(version.schema.columns.map((column) => column.name.toLowerCase()))
|
|
61
|
-
const canUsePhysicalRowId =
|
|
62
|
-
!schemaNames.has("rowid") && !schemaNames.has(PHYSICAL_ROW_ID_ALIAS.toLowerCase())
|
|
63
|
-
const orderSql = canUsePhysicalRowId ? " ORDER BY rowid" : ""
|
|
64
|
-
const baseSql = `SELECT ${columnsSql} FROM ${tableSql} AT (VERSION => ${snapshotId})`
|
|
65
|
-
const requestedOffset = Math.max(0, Math.trunc(input.offset ?? 0))
|
|
66
|
-
let remaining = input.limit === undefined ? undefined : Math.max(0, Math.trunc(input.limit))
|
|
67
|
-
if (remaining === 0) return
|
|
68
75
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
let
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
76
|
+
// Each bounded query releases both the runtime queue and attachment lease before yielding.
|
|
77
|
+
// SQLite metadata locks and PostgreSQL pool slots are released before consumer operations.
|
|
78
|
+
let offset = prepared.offset
|
|
79
|
+
let remaining = prepared.limit
|
|
80
|
+
let cursor: bigint | null = null
|
|
81
|
+
let page: readonly Record<string, unknown>[] | undefined = prepared.firstPage
|
|
82
|
+
while (remaining !== 0) {
|
|
83
|
+
read.signal.throwIfAborted()
|
|
84
|
+
const pageSize = Math.min(remaining ?? READ_PAGE_ROWS, READ_PAGE_ROWS)
|
|
85
|
+
const rows: readonly Record<string, unknown>[] =
|
|
86
|
+
page ??
|
|
87
|
+
(await this.connections.withAttachedRuntime((runtime) =>
|
|
88
|
+
runtime.query(pageSql(prepared, pageSize, offset, cursor))
|
|
89
|
+
))
|
|
90
|
+
page = undefined
|
|
91
|
+
read.signal.throwIfAborted()
|
|
92
|
+
yield* this.normalizeRows(rows, prepared.columns, read.signal)
|
|
93
|
+
if (remaining !== undefined) remaining -= rows.length
|
|
94
|
+
if (rows.length < pageSize) return
|
|
95
|
+
if (prepared.physicalOrder)
|
|
96
|
+
cursor = getBigIntLike(rows[rows.length - 1]!, PHYSICAL_ROW_ID_ALIAS)
|
|
88
97
|
offset += rows.length
|
|
89
|
-
if (remaining !== undefined) {
|
|
90
|
-
remaining -= rows.length
|
|
91
|
-
if (remaining === 0) return
|
|
92
|
-
}
|
|
93
|
-
if (canUsePhysicalRowId) {
|
|
94
|
-
physicalCursor = getBigIntLike(rows[rows.length - 1]!, PHYSICAL_ROW_ID_ALIAS)
|
|
95
|
-
}
|
|
96
|
-
if (rows.length < pageRows) return
|
|
97
98
|
}
|
|
98
99
|
} finally {
|
|
99
|
-
|
|
100
|
+
read.signal.removeEventListener("abort", read.release)
|
|
101
|
+
try {
|
|
102
|
+
await reader?.close()
|
|
103
|
+
} finally {
|
|
104
|
+
read.release()
|
|
105
|
+
}
|
|
100
106
|
}
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
private async *normalizeRows(
|
|
104
110
|
rows: Iterable<Record<string, unknown>> | AsyncIterable<Record<string, unknown>>,
|
|
105
|
-
selectedColumns: readonly DatasetColumnDefinition[]
|
|
111
|
+
selectedColumns: readonly DatasetColumnDefinition[],
|
|
112
|
+
signal: AbortSignal
|
|
106
113
|
): AsyncIterable<DatasetRow> {
|
|
107
114
|
for await (const row of rows) {
|
|
115
|
+
signal.throwIfAborted()
|
|
108
116
|
const output: Record<string, unknown> = {}
|
|
109
117
|
for (const column of selectedColumns) {
|
|
110
118
|
output[column.name] = normalizeReadValue(row[column.name], column)
|
|
@@ -165,3 +173,16 @@ export class DuckLakeRowReader {
|
|
|
165
173
|
)
|
|
166
174
|
}
|
|
167
175
|
}
|
|
176
|
+
|
|
177
|
+
function pageSql(
|
|
178
|
+
read: { readonly columnsSql: string; readonly relation: string; readonly physicalOrder: boolean },
|
|
179
|
+
limit: number,
|
|
180
|
+
offset: number,
|
|
181
|
+
cursor: bigint | null
|
|
182
|
+
): string {
|
|
183
|
+
return read.physicalOrder
|
|
184
|
+
? `SELECT rowid AS ${quoteIdentifier(PHYSICAL_ROW_ID_ALIAS)}, ${read.columnsSql}
|
|
185
|
+
FROM ${read.relation} ${cursor === null ? "" : `WHERE rowid > ${cursor}`}
|
|
186
|
+
ORDER BY rowid LIMIT ${limit} ${cursor === null && offset > 0 ? `OFFSET ${offset}` : ""}`
|
|
187
|
+
: `SELECT ${read.columnsSql} FROM ${read.relation} LIMIT ${limit} OFFSET ${offset}`
|
|
188
|
+
}
|
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
DatasetVersion,
|
|
6
6
|
DatasetVersionMode,
|
|
7
7
|
DatasetVersionRef,
|
|
8
|
+
LatestVersionsSince,
|
|
9
|
+
ListLatestVersionsSinceInput,
|
|
8
10
|
} from "@sixb/core/lake-storage"
|
|
9
11
|
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
10
12
|
import type { DuckLakeStorageOptions } from "../types"
|
|
@@ -13,8 +15,8 @@ import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
|
13
15
|
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
14
16
|
import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
|
|
15
17
|
import { type DatasetTableRef, resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
|
|
16
|
-
import { encodeDatasetTableName } from "./names"
|
|
17
|
-
import { duckLakeMetadataTableName, quoteSqlString } from "./sql"
|
|
18
|
+
import { decodeDatasetTableName, encodeDatasetTableName } from "./names"
|
|
19
|
+
import { buildDuckLakeMetadataQuery, duckLakeMetadataTableName, quoteSqlString } from "./sql"
|
|
18
20
|
import {
|
|
19
21
|
parseCommitMetadata,
|
|
20
22
|
parseInlineDataChange,
|
|
@@ -41,11 +43,24 @@ interface DatasetSnapshotCandidateRow {
|
|
|
41
43
|
readonly metadata?: SixbCommitMetadata
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
interface FeedTable {
|
|
47
|
+
readonly datasetId: string
|
|
48
|
+
readonly tableId: bigint
|
|
49
|
+
/** A dataset cannot have a version before its table exists. */
|
|
50
|
+
readonly startSnapshotId: bigint
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface MissingSnapshotCandidate {
|
|
54
|
+
readonly snapshotId: string
|
|
55
|
+
readonly missing: true
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
interface SnapshotCandidateQueryInput {
|
|
59
|
+
readonly datasetId: string
|
|
45
60
|
readonly tableId: bigint
|
|
46
61
|
readonly exactSnapshotId?: string
|
|
47
62
|
readonly beforeSnapshotId?: string
|
|
48
|
-
readonly limit
|
|
63
|
+
readonly limit: number
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
interface VisibleSnapshotRowsInput {
|
|
@@ -79,6 +94,8 @@ interface FileChangeFlags {
|
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
const SNAPSHOT_ROW_BATCH_SIZE = 128
|
|
97
|
+
// One feed read covers at most this many commits; the next read continues from its cursor.
|
|
98
|
+
const FEED_PAGE_SIZE = 512
|
|
82
99
|
|
|
83
100
|
// The bulk catalog scan shares one descending walk of recent snapshots across
|
|
84
101
|
// all requested datasets, so its cost is bounded by this window rather than by
|
|
@@ -189,6 +206,192 @@ export class DuckLakeSnapshotReader {
|
|
|
189
206
|
})
|
|
190
207
|
}
|
|
191
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Change feed over DuckLake's snapshot table, which is an append-only commit log: snapshot ids
|
|
211
|
+
* only increase, and a commit becomes visible only after every lower id has (concurrent commits
|
|
212
|
+
* that pick the same id conflict, and the loser retries with the next one). The cursor is the
|
|
213
|
+
* last snapshot id read. Every query is bounded by a snapshot-id range, so an idle read is one
|
|
214
|
+
* indexed query that returns the cursor's own snapshot, whatever the history length.
|
|
215
|
+
*/
|
|
216
|
+
async listLatestVersionsSince(
|
|
217
|
+
input: ListLatestVersionsSinceInput
|
|
218
|
+
): Promise<LatestVersionsSince | null> {
|
|
219
|
+
this.connections.assertOpen()
|
|
220
|
+
|
|
221
|
+
return this.connections.withAttachedRuntime(async (runtime) => {
|
|
222
|
+
if (input.cursor === null) {
|
|
223
|
+
return { cursor: await this.queryHeadSnapshotId(runtime), versions: [] }
|
|
224
|
+
}
|
|
225
|
+
if (!/^\d+$/.test(input.cursor)) {
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const snapshots = await this.querySnapshotsFrom(runtime, input.cursor)
|
|
230
|
+
// The cursor's own snapshot proves the cursor is still a position in this lake's history.
|
|
231
|
+
if (snapshots[0]?.snapshotId !== input.cursor) {
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
const newSnapshots = snapshots.slice(1)
|
|
235
|
+
if (newSnapshots.length === 0) {
|
|
236
|
+
return { cursor: input.cursor, versions: [] }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const tables = await this.resolveFeedTables(runtime, input.datasetIds)
|
|
240
|
+
const latest = await this.collectFeedPage(runtime, tables, newSnapshots)
|
|
241
|
+
return {
|
|
242
|
+
cursor: newSnapshots[newSnapshots.length - 1]?.snapshotId ?? input.cursor,
|
|
243
|
+
versions: [...latest].map(([datasetId, row]) => this.snapshotRowToSummary(datasetId, row)),
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private async queryHeadSnapshotId(runtime: DuckDbQueryRuntime): Promise<string> {
|
|
249
|
+
const [row] = await runtime.query(
|
|
250
|
+
buildDuckLakeMetadataQuery(
|
|
251
|
+
this.options,
|
|
252
|
+
(table) => `SELECT max(snapshot_id) AS snapshot_id FROM ${table("ducklake_snapshot")}`
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
if (row?.snapshot_id === null || row?.snapshot_id === undefined) {
|
|
256
|
+
throw new LakeStorageError("[SixbDuckLake] DuckLake catalog has no snapshots.")
|
|
257
|
+
}
|
|
258
|
+
return String(getBigIntLike(row, "snapshot_id"))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The cursor's snapshot and up to one feed page of snapshots after it, oldest first. */
|
|
262
|
+
private async querySnapshotsFrom(
|
|
263
|
+
runtime: DuckDbQueryRuntime,
|
|
264
|
+
cursor: string
|
|
265
|
+
): Promise<readonly { readonly snapshotId: string; readonly createdAt: Date }[]> {
|
|
266
|
+
const rows = await runtime.query(
|
|
267
|
+
buildDuckLakeMetadataQuery(
|
|
268
|
+
this.options,
|
|
269
|
+
(table) => `
|
|
270
|
+
SELECT snapshot_id, snapshot_time
|
|
271
|
+
FROM ${table("ducklake_snapshot")}
|
|
272
|
+
WHERE snapshot_id >= ${cursor}
|
|
273
|
+
ORDER BY snapshot_id
|
|
274
|
+
LIMIT ${FEED_PAGE_SIZE + 1}
|
|
275
|
+
`
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return rows.map((row) => ({
|
|
279
|
+
snapshotId: String(getBigIntLike(row, "snapshot_id")),
|
|
280
|
+
createdAt: getDate(row, "snapshot_time"),
|
|
281
|
+
}))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Current dataset tables, with the first snapshot each table id existed in. */
|
|
285
|
+
private async resolveFeedTables(
|
|
286
|
+
runtime: DuckDbQueryRuntime,
|
|
287
|
+
datasetIds: readonly string[] | undefined
|
|
288
|
+
): Promise<readonly FeedTable[]> {
|
|
289
|
+
const requested = datasetIds === undefined ? null : new Set(datasetIds)
|
|
290
|
+
const rows = await runtime.query(`
|
|
291
|
+
SELECT table_id, table_name, begin_snapshot, end_snapshot
|
|
292
|
+
FROM ${duckLakeMetadataTableName(this.options, "ducklake_table")}
|
|
293
|
+
`)
|
|
294
|
+
|
|
295
|
+
const startByTableId = new Map<bigint, bigint>()
|
|
296
|
+
const current: { datasetId: string; tableId: bigint }[] = []
|
|
297
|
+
for (const row of rows) {
|
|
298
|
+
const tableId = getBigIntLike(row, "table_id")
|
|
299
|
+
const begin = getBigIntLike(row, "begin_snapshot")
|
|
300
|
+
const start = startByTableId.get(tableId)
|
|
301
|
+
if (start === undefined || begin < start) {
|
|
302
|
+
startByTableId.set(tableId, begin)
|
|
303
|
+
}
|
|
304
|
+
if (row.end_snapshot !== null && row.end_snapshot !== undefined) {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
const datasetId = decodeDatasetTableName(getString(row, "table_name"))
|
|
308
|
+
if (datasetId !== null && (requested === null || requested.has(datasetId))) {
|
|
309
|
+
current.push({ datasetId, tableId })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return current.map((table) => ({
|
|
314
|
+
...table,
|
|
315
|
+
startSnapshotId: startByTableId.get(table.tableId) ?? 0n,
|
|
316
|
+
}))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Folds a page of snapshots, oldest first, into each dataset's newest data version. */
|
|
320
|
+
private async collectFeedPage(
|
|
321
|
+
runtime: DuckDbQueryRuntime,
|
|
322
|
+
tables: readonly FeedTable[],
|
|
323
|
+
snapshots: readonly { readonly snapshotId: string; readonly createdAt: Date }[]
|
|
324
|
+
): Promise<Map<string, DatasetSnapshotRow>> {
|
|
325
|
+
const latest = new Map<string, DatasetSnapshotRow>()
|
|
326
|
+
const first = snapshots[0]?.snapshotId
|
|
327
|
+
const last = snapshots[snapshots.length - 1]?.snapshotId
|
|
328
|
+
if (first === undefined || last === undefined || tables.length === 0) {
|
|
329
|
+
return latest
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const changeRows = await runtime.query(
|
|
333
|
+
buildDuckLakeMetadataQuery(
|
|
334
|
+
this.options,
|
|
335
|
+
(table) => `
|
|
336
|
+
SELECT snapshot_id, changes_made, commit_extra_info
|
|
337
|
+
FROM ${table("ducklake_snapshot_changes")}
|
|
338
|
+
WHERE snapshot_id >= ${first} AND snapshot_id <= ${last}
|
|
339
|
+
`
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
const fileFlags = await this.queryFileChangeFlags(
|
|
343
|
+
runtime,
|
|
344
|
+
snapshots.map((snapshot) => snapshot.snapshotId),
|
|
345
|
+
tables.map((table) => table.tableId)
|
|
346
|
+
)
|
|
347
|
+
const changesBySnapshotId = new Map(
|
|
348
|
+
changeRows.map((row) => [String(getBigIntLike(row, "snapshot_id")), row])
|
|
349
|
+
)
|
|
350
|
+
const tablesByTableId = new Map(tables.map((table) => [table.tableId.toString(), table]))
|
|
351
|
+
const tablesByDatasetId = new Map(tables.map((table) => [table.datasetId, table]))
|
|
352
|
+
|
|
353
|
+
for (const snapshot of snapshots) {
|
|
354
|
+
const changes = changesBySnapshotId.get(snapshot.snapshotId)
|
|
355
|
+
if (changes === undefined) {
|
|
356
|
+
continue
|
|
357
|
+
}
|
|
358
|
+
const changesMade = getString(changes, "changes_made")
|
|
359
|
+
const metadata = parseCommitMetadata(changes.commit_extra_info)
|
|
360
|
+
|
|
361
|
+
// Only tables this snapshot names, touches through files, or tags can change visibility.
|
|
362
|
+
const touched = new Set<FeedTable>()
|
|
363
|
+
for (const change of changesMade.split(",")) {
|
|
364
|
+
const table = tablesByTableId.get(change.split(":")[1] ?? "")
|
|
365
|
+
if (table) touched.add(table)
|
|
366
|
+
}
|
|
367
|
+
for (const table of tables) {
|
|
368
|
+
if (fileFlags.has(fileChangeKey(snapshot.snapshotId, table.tableId))) touched.add(table)
|
|
369
|
+
}
|
|
370
|
+
const tagged = metadata === undefined ? undefined : tablesByDatasetId.get(metadata.datasetId)
|
|
371
|
+
if (tagged) touched.add(tagged)
|
|
372
|
+
|
|
373
|
+
for (const table of touched) {
|
|
374
|
+
if (BigInt(snapshot.snapshotId) < table.startSnapshotId) {
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
const flags = fileFlags.get(fileChangeKey(snapshot.snapshotId, table.tableId))
|
|
378
|
+
const row = this.candidateToSnapshotRow(table.datasetId, table.tableId, {
|
|
379
|
+
snapshotId: snapshot.snapshotId,
|
|
380
|
+
createdAt: snapshot.createdAt,
|
|
381
|
+
changesMade,
|
|
382
|
+
hasFileChange: flags?.hasFileChange ?? false,
|
|
383
|
+
hasFileDeleteChange: flags?.hasFileDeleteChange ?? false,
|
|
384
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
385
|
+
})
|
|
386
|
+
if (row && row.mode !== "schema") {
|
|
387
|
+
latest.set(table.datasetId, row)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return latest
|
|
393
|
+
}
|
|
394
|
+
|
|
192
395
|
private snapshotRowToSummary(
|
|
193
396
|
datasetId: string,
|
|
194
397
|
row: DatasetSnapshotRow
|
|
@@ -604,6 +807,7 @@ export class DuckLakeSnapshotReader {
|
|
|
604
807
|
|
|
605
808
|
while (input.visibleRowLimit === undefined || snapshots.length < input.visibleRowLimit) {
|
|
606
809
|
const candidates = await this.querySnapshotCandidates(runtime, {
|
|
810
|
+
datasetId: input.datasetId,
|
|
607
811
|
tableId: input.tableId,
|
|
608
812
|
exactSnapshotId: input.exactSnapshotId,
|
|
609
813
|
beforeSnapshotId,
|
|
@@ -614,6 +818,9 @@ export class DuckLakeSnapshotReader {
|
|
|
614
818
|
}
|
|
615
819
|
|
|
616
820
|
for (const candidate of candidates) {
|
|
821
|
+
if ("missing" in candidate) {
|
|
822
|
+
continue
|
|
823
|
+
}
|
|
617
824
|
const snapshot = this.candidateToSnapshotRow(input.datasetId, input.tableId, candidate)
|
|
618
825
|
if (snapshot) {
|
|
619
826
|
snapshots.push(snapshot)
|
|
@@ -634,99 +841,144 @@ export class DuckLakeSnapshotReader {
|
|
|
634
841
|
return snapshots
|
|
635
842
|
}
|
|
636
843
|
|
|
844
|
+
/**
|
|
845
|
+
* Hydrates, newest first, the snapshots that may hold a version of one dataset table: those
|
|
846
|
+
* that added or removed its data or delete files, that DuckLake recorded as changing it inline,
|
|
847
|
+
* or whose Sixb commit metadata names the dataset. The text matches are a superset;
|
|
848
|
+
* candidateToSnapshotRow decides visibility. Only this table's snapshots are read, so the cost
|
|
849
|
+
* follows the dataset's own history rather than every commit in the catalog.
|
|
850
|
+
*
|
|
851
|
+
* Returns one row per candidate id, including ids whose snapshot no longer exists, so the caller
|
|
852
|
+
* can tell a short batch from the end of the history.
|
|
853
|
+
*/
|
|
637
854
|
private async querySnapshotCandidates(
|
|
638
855
|
runtime: DuckDbQueryRuntime,
|
|
639
856
|
input: SnapshotCandidateQueryInput
|
|
640
|
-
): Promise<readonly DatasetSnapshotCandidateRow[]> {
|
|
641
|
-
|
|
857
|
+
): Promise<readonly (DatasetSnapshotCandidateRow | MissingSnapshotCandidate)[]> {
|
|
858
|
+
const tableId = input.tableId.toString()
|
|
859
|
+
const limit = Math.max(0, Math.trunc(input.limit))
|
|
860
|
+
if (limit === 0) {
|
|
642
861
|
return []
|
|
643
862
|
}
|
|
644
|
-
|
|
645
|
-
const where: string[] = []
|
|
646
863
|
if (input.exactSnapshotId !== undefined) {
|
|
647
864
|
assertDuckLakeSnapshotId(input.exactSnapshotId)
|
|
648
|
-
where.push(`snapshot.snapshot_id = ${input.exactSnapshotId}`)
|
|
649
865
|
}
|
|
650
866
|
if (input.beforeSnapshotId !== undefined) {
|
|
651
867
|
assertDuckLakeSnapshotId(input.beforeSnapshotId)
|
|
652
|
-
where.push(`snapshot.snapshot_id < ${input.beforeSnapshotId}`)
|
|
653
868
|
}
|
|
654
869
|
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
870
|
+
const rows = await runtime.query(
|
|
871
|
+
buildDuckLakeMetadataQuery(this.options, (table) => {
|
|
872
|
+
// A dataset cannot have a version before its table exists. Use the earliest table record:
|
|
873
|
+
// a rename may create a newer record with the same table id and must not hide older
|
|
874
|
+
// versions.
|
|
875
|
+
const tableStart = `(SELECT min(begin_snapshot) FROM ${table("ducklake_table")} WHERE table_id = ${tableId})`
|
|
876
|
+
const candidateIds =
|
|
877
|
+
input.exactSnapshotId !== undefined
|
|
878
|
+
? `SELECT CAST(${input.exactSnapshotId} AS BIGINT) AS snapshot_id
|
|
879
|
+
WHERE ${input.exactSnapshotId} >= ${tableStart}`
|
|
880
|
+
: `SELECT DISTINCT snapshot_id
|
|
881
|
+
FROM (
|
|
882
|
+
SELECT begin_snapshot AS snapshot_id
|
|
883
|
+
FROM ${table("ducklake_data_file")} WHERE table_id = ${tableId}
|
|
884
|
+
UNION ALL
|
|
885
|
+
SELECT end_snapshot
|
|
886
|
+
FROM ${table("ducklake_data_file")}
|
|
887
|
+
WHERE table_id = ${tableId} AND end_snapshot IS NOT NULL
|
|
888
|
+
UNION ALL
|
|
889
|
+
SELECT begin_snapshot
|
|
890
|
+
FROM ${table("ducklake_delete_file")} WHERE table_id = ${tableId}
|
|
891
|
+
UNION ALL
|
|
892
|
+
SELECT end_snapshot
|
|
893
|
+
FROM ${table("ducklake_delete_file")}
|
|
894
|
+
WHERE table_id = ${tableId} AND end_snapshot IS NOT NULL
|
|
895
|
+
UNION ALL
|
|
896
|
+
SELECT snapshot_id
|
|
897
|
+
FROM ${table("ducklake_snapshot_changes")}
|
|
898
|
+
WHERE strpos(',' || changes_made || ',', ${quoteSqlString(`:${tableId},`)}) > 0
|
|
899
|
+
OR strpos(
|
|
900
|
+
commit_extra_info,
|
|
901
|
+
${quoteSqlString(`"datasetId":${JSON.stringify(input.datasetId)}`)}
|
|
902
|
+
) > 0
|
|
903
|
+
) touched
|
|
904
|
+
WHERE snapshot_id >= ${tableStart}
|
|
905
|
+
${input.beforeSnapshotId === undefined ? "" : `AND snapshot_id < ${input.beforeSnapshotId}`}
|
|
906
|
+
ORDER BY snapshot_id DESC
|
|
907
|
+
LIMIT ${limit}`
|
|
908
|
+
|
|
909
|
+
return `
|
|
910
|
+
WITH candidate_ids AS (
|
|
911
|
+
${candidateIds}
|
|
912
|
+
),
|
|
913
|
+
candidate_snapshots AS (
|
|
914
|
+
SELECT
|
|
915
|
+
snapshot.snapshot_id,
|
|
916
|
+
snapshot.snapshot_time,
|
|
917
|
+
changes.changes_made,
|
|
918
|
+
changes.commit_extra_info
|
|
919
|
+
FROM ${table("ducklake_snapshot")} snapshot
|
|
920
|
+
JOIN ${table("ducklake_snapshot_changes")} changes
|
|
921
|
+
ON changes.snapshot_id = snapshot.snapshot_id
|
|
922
|
+
WHERE snapshot.snapshot_id IN (SELECT snapshot_id FROM candidate_ids)
|
|
923
|
+
),
|
|
924
|
+
file_changes AS (
|
|
925
|
+
-- File metadata tells whether this table changed in each candidate snapshot.
|
|
926
|
+
SELECT begin_snapshot AS snapshot_id, false AS is_delete_change
|
|
927
|
+
FROM ${table("ducklake_data_file")}
|
|
928
|
+
WHERE table_id = ${tableId}
|
|
929
|
+
AND begin_snapshot IN (SELECT snapshot_id FROM candidate_ids)
|
|
930
|
+
UNION ALL
|
|
931
|
+
SELECT end_snapshot AS snapshot_id, true AS is_delete_change
|
|
932
|
+
FROM ${table("ducklake_data_file")}
|
|
933
|
+
WHERE table_id = ${tableId}
|
|
934
|
+
AND end_snapshot IN (SELECT snapshot_id FROM candidate_ids)
|
|
935
|
+
UNION ALL
|
|
936
|
+
SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
|
|
937
|
+
FROM ${table("ducklake_delete_file")}
|
|
938
|
+
WHERE table_id = ${tableId}
|
|
939
|
+
AND begin_snapshot IN (SELECT snapshot_id FROM candidate_ids)
|
|
940
|
+
UNION ALL
|
|
941
|
+
SELECT end_snapshot AS snapshot_id, true AS is_delete_change
|
|
942
|
+
FROM ${table("ducklake_delete_file")}
|
|
943
|
+
WHERE table_id = ${tableId}
|
|
944
|
+
AND end_snapshot IN (SELECT snapshot_id FROM candidate_ids)
|
|
945
|
+
),
|
|
946
|
+
file_changes_by_snapshot AS (
|
|
947
|
+
-- Collapse file-level changes into one row per snapshot.
|
|
948
|
+
SELECT
|
|
949
|
+
snapshot_id,
|
|
950
|
+
count(*) > 0 AS has_file_change,
|
|
951
|
+
count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
|
|
952
|
+
FROM file_changes
|
|
953
|
+
GROUP BY snapshot_id
|
|
954
|
+
)
|
|
955
|
+
SELECT
|
|
956
|
+
-- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
|
|
957
|
+
candidate_ids.snapshot_id,
|
|
958
|
+
candidate.snapshot_time,
|
|
959
|
+
candidate.changes_made,
|
|
960
|
+
candidate.commit_extra_info,
|
|
961
|
+
coalesce(file_changes.has_file_change, false) AS has_file_change,
|
|
962
|
+
coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
|
|
963
|
+
FROM candidate_ids
|
|
964
|
+
LEFT JOIN candidate_snapshots candidate
|
|
965
|
+
ON candidate.snapshot_id = candidate_ids.snapshot_id
|
|
966
|
+
LEFT JOIN file_changes_by_snapshot file_changes
|
|
967
|
+
ON file_changes.snapshot_id = candidate_ids.snapshot_id
|
|
968
|
+
ORDER BY candidate_ids.snapshot_id DESC
|
|
969
|
+
`
|
|
970
|
+
})
|
|
662
971
|
)
|
|
663
|
-
const ducklakeDataFile = duckLakeMetadataTableName(this.options, "ducklake_data_file")
|
|
664
|
-
const ducklakeDeleteFile = duckLakeMetadataTableName(this.options, "ducklake_delete_file")
|
|
665
|
-
|
|
666
|
-
const rows = await runtime.query(`
|
|
667
|
-
WITH candidate_snapshots AS (
|
|
668
|
-
-- Recent DuckLake snapshots to inspect; Sixb visibility is filtered later.
|
|
669
|
-
SELECT
|
|
670
|
-
snapshot.snapshot_id,
|
|
671
|
-
snapshot.snapshot_time,
|
|
672
|
-
changes.changes_made,
|
|
673
|
-
changes.commit_extra_info
|
|
674
|
-
FROM ${ducklakeSnapshot} snapshot
|
|
675
|
-
JOIN ${ducklakeSnapshotChanges} changes
|
|
676
|
-
ON changes.snapshot_id = snapshot.snapshot_id
|
|
677
|
-
${whereSql}
|
|
678
|
-
ORDER BY snapshot.snapshot_id DESC
|
|
679
|
-
${limitSql}
|
|
680
|
-
),
|
|
681
|
-
file_changes AS (
|
|
682
|
-
-- File metadata tells whether this table changed in each candidate snapshot.
|
|
683
|
-
SELECT begin_snapshot AS snapshot_id, false AS is_delete_change
|
|
684
|
-
FROM ${ducklakeDataFile}
|
|
685
|
-
WHERE table_id = ${input.tableId}
|
|
686
|
-
AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
|
|
687
|
-
UNION ALL
|
|
688
|
-
SELECT end_snapshot AS snapshot_id, true AS is_delete_change
|
|
689
|
-
FROM ${ducklakeDataFile}
|
|
690
|
-
WHERE table_id = ${input.tableId}
|
|
691
|
-
AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
|
|
692
|
-
UNION ALL
|
|
693
|
-
SELECT begin_snapshot AS snapshot_id, true AS is_delete_change
|
|
694
|
-
FROM ${ducklakeDeleteFile}
|
|
695
|
-
WHERE table_id = ${input.tableId}
|
|
696
|
-
AND begin_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
|
|
697
|
-
UNION ALL
|
|
698
|
-
SELECT end_snapshot AS snapshot_id, true AS is_delete_change
|
|
699
|
-
FROM ${ducklakeDeleteFile}
|
|
700
|
-
WHERE table_id = ${input.tableId}
|
|
701
|
-
AND end_snapshot IN (SELECT snapshot_id FROM candidate_snapshots)
|
|
702
|
-
),
|
|
703
|
-
file_changes_by_snapshot AS (
|
|
704
|
-
-- Collapse file-level changes into one row per snapshot.
|
|
705
|
-
SELECT
|
|
706
|
-
snapshot_id,
|
|
707
|
-
count(*) > 0 AS has_file_change,
|
|
708
|
-
count(*) FILTER (WHERE is_delete_change) > 0 AS has_file_delete_change
|
|
709
|
-
FROM file_changes
|
|
710
|
-
GROUP BY snapshot_id
|
|
711
|
-
)
|
|
712
|
-
SELECT
|
|
713
|
-
-- Keep metadata-only candidates; Sixb filters them with commit_extra_info.
|
|
714
|
-
candidate.snapshot_id,
|
|
715
|
-
candidate.snapshot_time,
|
|
716
|
-
candidate.changes_made,
|
|
717
|
-
candidate.commit_extra_info,
|
|
718
|
-
coalesce(file_changes.has_file_change, false) AS has_file_change,
|
|
719
|
-
coalesce(file_changes.has_file_delete_change, false) AS has_file_delete_change
|
|
720
|
-
FROM candidate_snapshots candidate
|
|
721
|
-
LEFT JOIN file_changes_by_snapshot file_changes
|
|
722
|
-
ON file_changes.snapshot_id = candidate.snapshot_id
|
|
723
|
-
ORDER BY candidate.snapshot_id DESC
|
|
724
|
-
`)
|
|
725
972
|
|
|
726
973
|
return rows.map((row) => {
|
|
974
|
+
const snapshotId = String(getBigIntLike(row, "snapshot_id"))
|
|
975
|
+
// Expired snapshots keep their file records; they are no longer versions.
|
|
976
|
+
if (row.snapshot_time === null || row.snapshot_time === undefined) {
|
|
977
|
+
return { snapshotId, missing: true } as const
|
|
978
|
+
}
|
|
727
979
|
const metadata = parseCommitMetadata(row.commit_extra_info)
|
|
728
980
|
return {
|
|
729
|
-
snapshotId
|
|
981
|
+
snapshotId,
|
|
730
982
|
createdAt: getDate(row, "snapshot_time"),
|
|
731
983
|
changesMade: getString(row, "changes_made"),
|
|
732
984
|
hasFileChange: getBoolean(row, "has_file_change"),
|