@sixb/ducklake 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -4
- package/dist/ducklake-storage.d.ts +1 -0
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +1817 -511
- package/dist/index.js.map +24 -14
- 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 +63 -16
- 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/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 +6 -6
- package/src/ducklake-storage.ts +9 -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 +352 -304
- 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/versions.ts +37 -14
- package/src/types.ts +11 -1
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
2
|
import type { DatasetColumnDefinition, DatasetDefinition, DatasetRow } from "@sixb/core"
|
|
3
3
|
import {
|
|
4
|
+
type AnalyzeSqlTransformInput,
|
|
5
|
+
type AnalyzeSqlTransformSource,
|
|
6
|
+
type DatasetColumnLineage,
|
|
7
|
+
type DatasetColumnRef,
|
|
8
|
+
type DatasetProducer,
|
|
9
|
+
type DatasetVersion,
|
|
10
|
+
type DatasetVersionLineage,
|
|
4
11
|
type DatasetVersionRef,
|
|
5
12
|
type DatasetWriteCommitResult,
|
|
6
13
|
type DatasetWriteMode,
|
|
@@ -8,6 +15,10 @@ import {
|
|
|
8
15
|
type LakeSqlExecutor,
|
|
9
16
|
LakeStorageError,
|
|
10
17
|
type PreviewSqlTransformInput,
|
|
18
|
+
resolveStepLineage,
|
|
19
|
+
type SqlTransformAnalysis,
|
|
20
|
+
type SqlTransformColumnRef,
|
|
21
|
+
type SqlTransformLineage,
|
|
11
22
|
} from "@sixb/core/lake-storage"
|
|
12
23
|
import type { DuckLakeStorageOptions } from "../types"
|
|
13
24
|
import {
|
|
@@ -20,13 +31,17 @@ import { getString } from "./duckdb-row"
|
|
|
20
31
|
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
21
32
|
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
22
33
|
import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
|
|
23
|
-
import type { DuckLakeSnapshotReader } from "./ducklake-snapshot-reader"
|
|
34
|
+
import type { DuckLakeSnapshotReader, DuckLakeTableVersionRef } from "./ducklake-snapshot-reader"
|
|
24
35
|
import type { DuckLakeWriteCoordinator } from "./ducklake-write-coordinator"
|
|
25
36
|
import { datasetColumnTypeToDuckDbSql } from "./schema"
|
|
26
37
|
import { quoteIdentifier } from "./sql"
|
|
38
|
+
import { analyzeSqlTransformLineage, type SqlLineageAnalysis } from "./sql-lineage/analysis"
|
|
27
39
|
import {
|
|
40
|
+
createDuckLakeSqlTransformTemplate,
|
|
28
41
|
type DuckLakeSqlTransformSourceRelation,
|
|
29
|
-
|
|
42
|
+
type DuckLakeSqlTransformTemplate,
|
|
43
|
+
pickSourceColumns,
|
|
44
|
+
renderDuckLakeSourceRelations,
|
|
30
45
|
} from "./sql-transform-relations"
|
|
31
46
|
import { parseVersionId } from "./versions"
|
|
32
47
|
|
|
@@ -35,9 +50,16 @@ const MAX_PREVIEW_LIMIT = 1_000
|
|
|
35
50
|
|
|
36
51
|
interface ResolvedDuckLakeSqlTransformSources {
|
|
37
52
|
readonly relations: Readonly<Record<string, DuckLakeSqlTransformSourceRelation>>
|
|
53
|
+
readonly tables: Readonly<Record<string, ResolvedDuckLakeSourceTable>>
|
|
38
54
|
readonly inputs: readonly DatasetVersionRef[]
|
|
39
55
|
}
|
|
40
56
|
|
|
57
|
+
/** A source's stored definition, and the version it resolved to. */
|
|
58
|
+
interface ResolvedDuckLakeSourceTable {
|
|
59
|
+
readonly definition: DatasetDefinition
|
|
60
|
+
readonly version: DuckLakeTableVersionRef
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
interface ApplySqlTransformInput {
|
|
42
64
|
readonly runtime: DuckDbQueryRuntime
|
|
43
65
|
readonly target: DatasetDefinition
|
|
@@ -79,13 +101,11 @@ export class DuckLakeSqlExecutor implements LakeSqlExecutor<"duckdb"> {
|
|
|
79
101
|
async *preview(input: PreviewSqlTransformInput<"duckdb">): AsyncIterable<DatasetRow> {
|
|
80
102
|
this.connections.assertOpen()
|
|
81
103
|
|
|
104
|
+
const template = createDuckLakeSqlTransformTemplate(Object.keys(input.sources), input.sql)
|
|
105
|
+
|
|
82
106
|
const rows = await this.connections.withAttachedRuntime(async (runtime) => {
|
|
83
107
|
const sources = await this.resolveSources(runtime, input.sources)
|
|
84
|
-
const sql =
|
|
85
|
-
options: this.options,
|
|
86
|
-
sources: sources.relations,
|
|
87
|
-
sql: input.sql,
|
|
88
|
-
})
|
|
108
|
+
const sql = template.render(renderDuckLakeSourceRelations(this.options, sources.relations))
|
|
89
109
|
return runtime.query(buildPreviewSql(sql, previewLimit(input.limit)))
|
|
90
110
|
})
|
|
91
111
|
|
|
@@ -98,16 +118,29 @@ export class DuckLakeSqlExecutor implements LakeSqlExecutor<"duckdb"> {
|
|
|
98
118
|
this.connections.assertOpen()
|
|
99
119
|
assertDatasetWriteMode(input.mode, "SQL transform")
|
|
100
120
|
|
|
121
|
+
const template = createDuckLakeSqlTransformTemplate(Object.keys(input.sources), input.sql)
|
|
122
|
+
|
|
101
123
|
return this.writes.withCommitRuntime(async (runtime) => {
|
|
102
124
|
const target = await this.resolveTargetDataset(runtime, input.target)
|
|
103
125
|
const sources = await this.resolveSources(runtime, input.sources)
|
|
104
|
-
const sql =
|
|
105
|
-
options: this.options,
|
|
106
|
-
sources: sources.relations,
|
|
107
|
-
sql: input.sql,
|
|
108
|
-
})
|
|
126
|
+
const sql = template.render(renderDuckLakeSourceRelations(this.options, sources.relations))
|
|
109
127
|
|
|
110
|
-
const
|
|
128
|
+
const sourceVersions = await this.readSourceVersions(runtime, sources).catch(
|
|
129
|
+
(error: unknown) => skipLineage(target, error)
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// The analysis binds in its own DuckDB instance while the lake checks the result schema.
|
|
133
|
+
const [checkedCasts, stepLineage] = await Promise.all([
|
|
134
|
+
this.assertResultSchemaMatchesTarget(runtime, target, sql),
|
|
135
|
+
sourceVersions === undefined
|
|
136
|
+
? undefined
|
|
137
|
+
: analyzeExecutedLineage(
|
|
138
|
+
template,
|
|
139
|
+
pinnedSources(sources, sourceVersions),
|
|
140
|
+
sources.relations,
|
|
141
|
+
target
|
|
142
|
+
),
|
|
143
|
+
])
|
|
111
144
|
|
|
112
145
|
return this.writes.commitDatasetVersionOnExclusiveRuntime(runtime, {
|
|
113
146
|
dataset: target,
|
|
@@ -116,6 +149,10 @@ export class DuckLakeSqlExecutor implements LakeSqlExecutor<"duckdb"> {
|
|
|
116
149
|
commitMessage: input.commitMessage ?? `execute SQL transform for dataset ${target.id}`,
|
|
117
150
|
producer: input.producer,
|
|
118
151
|
inputs: sources.inputs,
|
|
152
|
+
lineage:
|
|
153
|
+
stepLineage === undefined || sourceVersions === undefined
|
|
154
|
+
? unanalyzedLineage(target, input.producer)
|
|
155
|
+
: resolveStepLineage(stepLineage, [...sourceVersions.values()]),
|
|
119
156
|
applyChanges: (runtime, context) =>
|
|
120
157
|
this.applySqlTransform({
|
|
121
158
|
runtime,
|
|
@@ -129,21 +166,47 @@ export class DuckLakeSqlExecutor implements LakeSqlExecutor<"duckdb"> {
|
|
|
129
166
|
})
|
|
130
167
|
}
|
|
131
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Column lineage bound against the definitions passed in, without reading the lake: it works
|
|
171
|
+
* before any source version exists. Their columns are bound in the order given, which can differ
|
|
172
|
+
* from the lake's stored order; `execute` binds each source's stored version schema instead.
|
|
173
|
+
*/
|
|
174
|
+
async analyze(input: AnalyzeSqlTransformInput<"duckdb">): Promise<SqlTransformAnalysis> {
|
|
175
|
+
for (const source of Object.values(input.sources)) {
|
|
176
|
+
this.datasets.assertSchema(source.dataset)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const analysis = await analyzeSqlTransformLineage({
|
|
180
|
+
sources: input.sources,
|
|
181
|
+
template: createDuckLakeSqlTransformTemplate(Object.keys(input.sources), input.sql),
|
|
182
|
+
})
|
|
183
|
+
return analysis.supported
|
|
184
|
+
? { supported: true, lineage: analysis.lineage }
|
|
185
|
+
: { supported: false, reason: analysis.reason }
|
|
186
|
+
}
|
|
187
|
+
|
|
132
188
|
private async resolveSources(
|
|
133
189
|
runtime: DuckDbQueryRuntime,
|
|
134
190
|
sources: PreviewSqlTransformInput<"duckdb">["sources"]
|
|
135
191
|
): Promise<ResolvedDuckLakeSqlTransformSources> {
|
|
136
192
|
const relations: Record<string, DuckLakeSqlTransformSourceRelation> = Object.create(null)
|
|
193
|
+
const tables: Record<string, ResolvedDuckLakeSourceTable> = Object.create(null)
|
|
137
194
|
const inputs: DatasetVersionRef[] = []
|
|
138
195
|
|
|
139
196
|
for (const [sourceName, source] of Object.entries(sources)) {
|
|
140
197
|
const definition = await this.resolveSourceDataset(runtime, source.dataset)
|
|
198
|
+
|
|
199
|
+
// Rejects a pick of columns the stored definition does not have before any SQL renders it.
|
|
200
|
+
pickSourceColumns(sourceName, definition, source.columns)
|
|
201
|
+
|
|
141
202
|
const versionRef = await this.resolveSourceVersion(runtime, definition, source.versionId)
|
|
142
203
|
|
|
143
204
|
relations[sourceName] = {
|
|
144
205
|
datasetId: definition.id,
|
|
145
206
|
versionId: versionRef.versionId,
|
|
207
|
+
...(source.columns === undefined ? {} : { columns: source.columns }),
|
|
146
208
|
}
|
|
209
|
+
tables[sourceName] = { definition, version: versionRef }
|
|
147
210
|
inputs.push({
|
|
148
211
|
datasetId: definition.id,
|
|
149
212
|
versionId: versionRef.versionId,
|
|
@@ -152,6 +215,7 @@ export class DuckLakeSqlExecutor implements LakeSqlExecutor<"duckdb"> {
|
|
|
152
215
|
|
|
153
216
|
return {
|
|
154
217
|
relations: Object.freeze(relations),
|
|
218
|
+
tables: Object.freeze(tables),
|
|
155
219
|
inputs: Object.freeze(inputs),
|
|
156
220
|
}
|
|
157
221
|
}
|
|
@@ -185,11 +249,25 @@ export class DuckLakeSqlExecutor implements LakeSqlExecutor<"duckdb"> {
|
|
|
185
249
|
return definition
|
|
186
250
|
}
|
|
187
251
|
|
|
252
|
+
/** The source versions read, by source name, with their schemas from one catalog read. */
|
|
253
|
+
private async readSourceVersions(
|
|
254
|
+
runtime: DuckDbQueryRuntime,
|
|
255
|
+
sources: ResolvedDuckLakeSqlTransformSources
|
|
256
|
+
): Promise<ReadonlyMap<string, DatasetVersion>> {
|
|
257
|
+
const names = Object.keys(sources.tables)
|
|
258
|
+
const versions = await this.snapshots.getVersionsForRefs(
|
|
259
|
+
runtime,
|
|
260
|
+
names.map((sourceName) => sources.tables[sourceName].version)
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return new Map(names.map((sourceName, index) => [sourceName, versions[index]]))
|
|
264
|
+
}
|
|
265
|
+
|
|
188
266
|
private async resolveSourceVersion(
|
|
189
267
|
runtime: DuckDbQueryRuntime,
|
|
190
268
|
dataset: DatasetDefinition,
|
|
191
269
|
versionId?: string
|
|
192
|
-
): Promise<
|
|
270
|
+
): Promise<DuckLakeTableVersionRef> {
|
|
193
271
|
if (versionId !== undefined) {
|
|
194
272
|
const snapshotId = parseVersionId(versionId)
|
|
195
273
|
const versionRef = await this.snapshots.getVersionRefForSnapshot(runtime, dataset, snapshotId)
|
|
@@ -301,6 +379,128 @@ export class DuckLakeSqlExecutor implements LakeSqlExecutor<"duckdb"> {
|
|
|
301
379
|
}
|
|
302
380
|
}
|
|
303
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Each source as the transform binds it on the lake: the schema of the version it reads, in the
|
|
384
|
+
* stored column order, then its pick. Today's definition may have gained columns since, and a new
|
|
385
|
+
* column can change what a name in the SQL resolves to, such as a lateral alias.
|
|
386
|
+
*/
|
|
387
|
+
function pinnedSources(
|
|
388
|
+
sources: ResolvedDuckLakeSqlTransformSources,
|
|
389
|
+
versions: ReadonlyMap<string, DatasetVersion>
|
|
390
|
+
): Readonly<Record<string, AnalyzeSqlTransformSource>> {
|
|
391
|
+
const pinned: Record<string, AnalyzeSqlTransformSource> = Object.create(null)
|
|
392
|
+
for (const [sourceName, version] of versions) {
|
|
393
|
+
pinned[sourceName] = {
|
|
394
|
+
dataset: { ...sources.tables[sourceName].definition, schema: version.schema },
|
|
395
|
+
columns: sources.relations[sourceName].columns,
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return pinned
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Lineage of the SQL about to run, by the sources' dataset ids, against the schemas of the
|
|
404
|
+
* versions it reads.
|
|
405
|
+
*
|
|
406
|
+
* SQL that cannot be analyzed exactly but reads only its sources, such as a PIVOT, depends on at
|
|
407
|
+
* most every column it reads. Otherwise there is none: the SQL may read what no lineage names. A
|
|
408
|
+
* result shaped unlike the target means the analysis tables diverged from what runs on the lake, so
|
|
409
|
+
* that lineage is not recorded either.
|
|
410
|
+
*/
|
|
411
|
+
async function analyzeExecutedLineage(
|
|
412
|
+
template: DuckLakeSqlTransformTemplate,
|
|
413
|
+
pinnedSources: Readonly<Record<string, AnalyzeSqlTransformSource>>,
|
|
414
|
+
relations: Readonly<Record<string, DuckLakeSqlTransformSourceRelation>>,
|
|
415
|
+
target: DatasetDefinition
|
|
416
|
+
): Promise<DatasetColumnLineage | undefined> {
|
|
417
|
+
let analysis: SqlLineageAnalysis
|
|
418
|
+
try {
|
|
419
|
+
analysis = await analyzeSqlTransformLineage({ sources: pinnedSources, template })
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return skipLineage(target, error)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!analysis.supported) {
|
|
425
|
+
return analysis.readsOnlySources
|
|
426
|
+
? toDatasetColumnLineage(everySourceColumn(pinnedSources, target), relations)
|
|
427
|
+
: undefined
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (!describesTarget(analysis.outputColumns, target)) return undefined
|
|
431
|
+
return toDatasetColumnLineage(analysis.lineage, relations)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Every column the sources expose decides every row of the target, and so each of its values. */
|
|
435
|
+
function everySourceColumn(
|
|
436
|
+
sources: Readonly<Record<string, AnalyzeSqlTransformSource>>,
|
|
437
|
+
target: DatasetDefinition
|
|
438
|
+
): SqlTransformLineage {
|
|
439
|
+
const rows = Object.entries(sources).flatMap(([source, { dataset, columns }]) =>
|
|
440
|
+
(columns ?? dataset.schema.columns.map((column) => column.name)).map((column) => ({
|
|
441
|
+
source,
|
|
442
|
+
column,
|
|
443
|
+
}))
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return { rows, columns: Object.fromEntries(target.schema.columns.map(({ name }) => [name, []])) }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* A pipeline write without lineage is classified with every marking. Any other write without it
|
|
451
|
+
* would depend on its own columns only, so one whose SQL cannot be analyzed records that.
|
|
452
|
+
*/
|
|
453
|
+
function unanalyzedLineage(
|
|
454
|
+
target: DatasetDefinition,
|
|
455
|
+
producer: DatasetProducer | undefined
|
|
456
|
+
): DatasetVersionLineage | undefined {
|
|
457
|
+
return producer?.kind === "pipeline"
|
|
458
|
+
? undefined
|
|
459
|
+
: { unknown: `a SQL transform into '${target.id}' could not be analyzed` }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Lineage never fails a transform: invalid SQL or picks fail where the transform binds on the lake.
|
|
464
|
+
* When the analysis cannot even start, the version records no lineage of its own SQL.
|
|
465
|
+
*/
|
|
466
|
+
function skipLineage(target: DatasetDefinition, error: unknown): undefined {
|
|
467
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
468
|
+
console.warn(
|
|
469
|
+
`[SixbDuckLake] SQL transform into dataset '${target.id}' records no column lineage: ${message}`
|
|
470
|
+
)
|
|
471
|
+
return undefined
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function describesTarget(outputColumns: readonly string[], target: DatasetDefinition): boolean {
|
|
475
|
+
return (
|
|
476
|
+
outputColumns.length === target.schema.columns.length &&
|
|
477
|
+
target.schema.columns.every((column, index) => outputColumns[index] === column.name)
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Source names become dataset ids; two sources reading one dataset share its column refs. */
|
|
482
|
+
function toDatasetColumnLineage(
|
|
483
|
+
lineage: SqlTransformLineage,
|
|
484
|
+
relations: Readonly<Record<string, DuckLakeSqlTransformSourceRelation>>
|
|
485
|
+
): DatasetColumnLineage {
|
|
486
|
+
const toRefs = (refs: readonly SqlTransformColumnRef[]): DatasetColumnRef[] => {
|
|
487
|
+
const unique = new Map<string, DatasetColumnRef>()
|
|
488
|
+
for (const { source, column } of refs) {
|
|
489
|
+
const datasetId = relations[source].datasetId
|
|
490
|
+
unique.set(JSON.stringify([datasetId, column]), { datasetId, column })
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return [...unique.values()]
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
rows: toRefs(lineage.rows),
|
|
498
|
+
columns: Object.fromEntries(
|
|
499
|
+
Object.entries(lineage.columns).map(([column, refs]) => [column, toRefs(refs)])
|
|
500
|
+
),
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
304
504
|
function previewLimit(limit: number | undefined): number {
|
|
305
505
|
if (limit === undefined || !Number.isFinite(limit)) {
|
|
306
506
|
return DEFAULT_PREVIEW_LIMIT
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { LakeConcurrencyError, LakeStorageError } from "@sixb/core/lake-storage"
|
|
2
|
+
import type { DuckLakeCatalogOptions, DuckLakeStorageOptions } from "../types"
|
|
3
|
+
import { getBigIntLike } from "./duckdb-row"
|
|
4
|
+
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
5
|
+
import { duckLakeAlias, quoteIdentifier } from "./sql"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How a version transaction reruns after other writers moved the catalog under it. Of concurrent
|
|
9
|
+
* commits to one claimed table, only the first commits: the others rerun, behind a winner that may
|
|
10
|
+
* already be committing its next version, so rerunning at once can lose every time. Each rerun
|
|
11
|
+
* after the first waits a random part (full jitter) of a window that doubles up to a cap, which
|
|
12
|
+
* spreads the losers apart.
|
|
13
|
+
*
|
|
14
|
+
* Ten writers that kept committing to one keyed dataset on PostgreSQL needed up to 10 attempts per
|
|
15
|
+
* write; rerunning at once, one of them ran out of 12. 16 attempts leave room above that, and wait
|
|
16
|
+
* at most 9.3 s in all (4.6 s on average) before a writer that cannot commit fails.
|
|
17
|
+
*/
|
|
18
|
+
const VERSION_RERUNS = { maxAttempts: 16, baseDelayMillis: 10, maxDelayMillis: 1_000 } as const
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The catalog moved while a version transaction was being prepared or committed, before anything
|
|
22
|
+
* was stored. Rerunning the whole transaction from a fresh read of the latest version is safe.
|
|
23
|
+
*/
|
|
24
|
+
export class CatalogMovedError extends LakeConcurrencyError {}
|
|
25
|
+
|
|
26
|
+
/** The latest version of a dataset, and the lake's snapshot it was read at. */
|
|
27
|
+
export interface LatestVersionAtSnapshot<T> {
|
|
28
|
+
readonly snapshotId: string
|
|
29
|
+
readonly latestVersion: T | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BeginVersionTransactionInput<T> {
|
|
33
|
+
readonly options: DuckLakeStorageOptions
|
|
34
|
+
readonly runtime: DuckDbQueryRuntime
|
|
35
|
+
readonly datasetId: string
|
|
36
|
+
readonly signal?: AbortSignal
|
|
37
|
+
/**
|
|
38
|
+
* Reads the latest version of the dataset, which the transaction then builds on. Its first
|
|
39
|
+
* statement reads the lake's current snapshot, which fixes the transaction's snapshot.
|
|
40
|
+
*/
|
|
41
|
+
readonly readLatestVersion: () => Promise<LatestVersionAtSnapshot<T>>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Begins the transaction of a new dataset version together with a read of the latest version, so
|
|
46
|
+
* what the new version derives from its parent, such as its row count or its lineage, describes
|
|
47
|
+
* the parent it commits on top of, provided every version it must not build on unseen claims its
|
|
48
|
+
* table (see the write coordinator).
|
|
49
|
+
*/
|
|
50
|
+
export async function beginTransactionOnLatestVersion<T>(
|
|
51
|
+
input: BeginVersionTransactionInput<T>
|
|
52
|
+
): Promise<T | null> {
|
|
53
|
+
if (!isSqliteCatalog(input.options.catalog)) {
|
|
54
|
+
await input.runtime.run("BEGIN TRANSACTION")
|
|
55
|
+
|
|
56
|
+
// DuckLake fixes a transaction's snapshot on its first read of the lake, not at BEGIN: the
|
|
57
|
+
// read fixes it before it reads the latest version, so a version that claimed the table and
|
|
58
|
+
// committed after that read makes this transaction conflict.
|
|
59
|
+
return (await input.readLatestVersion()).latestVersion
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Direct metadata reads enlist a second SQLite transaction on the outer DuckDB connection.
|
|
63
|
+
// Its read lock blocks DuckLake's own metadata connection at COMMIT. Read in autocommit,
|
|
64
|
+
// then fix the transaction's DuckLake snapshot and verify nothing changed in between: the
|
|
65
|
+
// read is then valid for this transaction, including null (no previous version).
|
|
66
|
+
input.signal?.throwIfAborted()
|
|
67
|
+
const { snapshotId, latestVersion } = await input.readLatestVersion()
|
|
68
|
+
|
|
69
|
+
await input.runtime.run("BEGIN TRANSACTION")
|
|
70
|
+
if ((await currentSnapshotId(input.options, input.runtime)) !== snapshotId) {
|
|
71
|
+
await input.runtime.run("ROLLBACK")
|
|
72
|
+
throw new CatalogMovedError(
|
|
73
|
+
`[SixbDuckLake] Catalog changed while a write to dataset '${input.datasetId}' was prepared.`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return latestVersion
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CommitOnReadSnapshot {
|
|
81
|
+
/** Recycles the connection when its DuckLake retry setting cannot be restored. */
|
|
82
|
+
readonly poisonRuntime: () => void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Commits a version transaction. A conflict with a concurrent commit fails before DuckLake stores
|
|
87
|
+
* anything, and is raised as a `CatalogMovedError` for the transaction to rerun.
|
|
88
|
+
*
|
|
89
|
+
* DuckLake commits on top of any snapshot committed since the transaction began that does not
|
|
90
|
+
* conflict with it. With `onReadSnapshot`, the commit fails on any such snapshot instead, of this
|
|
91
|
+
* dataset or not: a version that must sit on the version it read, but that inserts and deletes no
|
|
92
|
+
* rows DuckLake could check, needs it.
|
|
93
|
+
*/
|
|
94
|
+
export async function commitVersionTransaction(
|
|
95
|
+
runtime: DuckDbQueryRuntime,
|
|
96
|
+
datasetId: string,
|
|
97
|
+
onReadSnapshot?: CommitOnReadSnapshot
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
if (onReadSnapshot) {
|
|
100
|
+
await runtime.run("SET ducklake_max_retry_count = 0")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await runtime.run("COMMIT")
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw catalogMovedError(error, datasetId) ?? error
|
|
107
|
+
} finally {
|
|
108
|
+
if (onReadSnapshot) {
|
|
109
|
+
await runtime.run("RESET ducklake_max_retry_count").catch(() => {
|
|
110
|
+
// The setting is session state on a shared connection, and the commit already succeeded
|
|
111
|
+
// or failed durably: never change that outcome, but never reuse the connection either.
|
|
112
|
+
onReadSnapshot.poisonRuntime()
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reruns a version transaction that the catalog moved under, before anything was stored. The
|
|
120
|
+
* rerun reads the latest version again: one guarded on an expected latest version checks its guard
|
|
121
|
+
* against what moved, and fails as an optimistic commit if this dataset is what moved.
|
|
122
|
+
*/
|
|
123
|
+
export async function rerunWhenCatalogMoves<T>(
|
|
124
|
+
datasetId: string,
|
|
125
|
+
transaction: (attempt: number) => Promise<T>,
|
|
126
|
+
signal?: AbortSignal
|
|
127
|
+
): Promise<T> {
|
|
128
|
+
for (let attempt = 1; ; attempt++) {
|
|
129
|
+
try {
|
|
130
|
+
return await transaction(attempt)
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (!(error instanceof CatalogMovedError)) {
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (attempt === VERSION_RERUNS.maxAttempts) {
|
|
137
|
+
throw new LakeConcurrencyError(
|
|
138
|
+
`[SixbDuckLake] Concurrent writers moved the catalog during ${attempt} attempts to commit dataset '${datasetId}'. Retry the write.`,
|
|
139
|
+
{ cause: error }
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await waitBeforeRerun(rerunDelayMillis(attempt), signal)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The wait before the given rerun: none before the first, then full jitter on a doubling window. */
|
|
149
|
+
function rerunDelayMillis(rerun: number): number {
|
|
150
|
+
if (rerun === 1) {
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const window = Math.min(
|
|
155
|
+
VERSION_RERUNS.maxDelayMillis,
|
|
156
|
+
VERSION_RERUNS.baseDelayMillis * 2 ** (rerun - 1)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return Math.random() * window
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function waitBeforeRerun(millis: number, signal: AbortSignal | undefined): Promise<void> {
|
|
163
|
+
signal?.throwIfAborted()
|
|
164
|
+
if (millis === 0) {
|
|
165
|
+
return Promise.resolve()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return new Promise((resolve, reject) => {
|
|
169
|
+
const abort = () => {
|
|
170
|
+
clearTimeout(timer)
|
|
171
|
+
reject(signal?.reason)
|
|
172
|
+
}
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
signal?.removeEventListener("abort", abort)
|
|
175
|
+
resolve()
|
|
176
|
+
}, millis)
|
|
177
|
+
|
|
178
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function isSqliteCatalog(catalog: DuckLakeCatalogOptions): boolean {
|
|
183
|
+
return (
|
|
184
|
+
catalog.type === "sqlite" ||
|
|
185
|
+
(catalog.type === "custom" && catalog.uri.toLowerCase().startsWith("sqlite:"))
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* DuckLake refuses a commit that conflicts with one committed since the transaction began, or that
|
|
191
|
+
* exhausted its retries against concurrent commits, before it stores anything. Other errors, such
|
|
192
|
+
* as a lost connection during COMMIT, are not classified: their outcome is ambiguous.
|
|
193
|
+
*/
|
|
194
|
+
function catalogMovedError(error: unknown, datasetId: string): CatalogMovedError | undefined {
|
|
195
|
+
if (
|
|
196
|
+
error instanceof Error &&
|
|
197
|
+
error.message.startsWith(
|
|
198
|
+
"TransactionContext Error: Failed to commit: Failed to commit DuckLake transaction."
|
|
199
|
+
) &&
|
|
200
|
+
(error.message.includes("Transaction conflict - ") ||
|
|
201
|
+
error.message.includes("Exceeded the maximum retry count"))
|
|
202
|
+
) {
|
|
203
|
+
return new CatalogMovedError(
|
|
204
|
+
`[SixbDuckLake] Concurrent catalog commit for dataset '${datasetId}'.`,
|
|
205
|
+
{ cause: error }
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function currentSnapshotId(
|
|
213
|
+
options: DuckLakeStorageOptions,
|
|
214
|
+
runtime: DuckDbQueryRuntime
|
|
215
|
+
): Promise<string> {
|
|
216
|
+
const [row] = await runtime.query(
|
|
217
|
+
`SELECT id FROM ${quoteIdentifier(duckLakeAlias(options))}.current_snapshot()`
|
|
218
|
+
)
|
|
219
|
+
if (row === undefined) {
|
|
220
|
+
throw new LakeStorageError("[SixbDuckLake] DuckLake did not return a current snapshot.")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return String(getBigIntLike(row, "id"))
|
|
224
|
+
}
|