@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.
Files changed (69) hide show
  1. package/README.md +59 -4
  2. package/dist/ducklake-storage.d.ts +1 -0
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1817 -511
  5. package/dist/index.js.map +24 -14
  6. package/dist/internal/dataset-row-commit.d.ts +10 -0
  7. package/dist/internal/dataset-row-commit.d.ts.map +1 -1
  8. package/dist/internal/dataset-row-merge.d.ts.map +1 -1
  9. package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
  10. package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
  11. package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
  12. package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
  13. package/dist/internal/ducklake-maintenance.d.ts +21 -1
  14. package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
  15. package/dist/internal/ducklake-snapshot-reader.d.ts +63 -16
  16. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-sql-executor.d.ts +9 -1
  18. package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
  19. package/dist/internal/ducklake-version-transaction.d.ts +54 -0
  20. package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
  21. package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
  22. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  23. package/dist/internal/lineage-encoding.d.ts +8 -0
  24. package/dist/internal/lineage-encoding.d.ts.map +1 -0
  25. package/dist/internal/sql-lineage/analysis.d.ts +32 -0
  26. package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
  27. package/dist/internal/sql-lineage/expressions.d.ts +9 -0
  28. package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
  29. package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
  30. package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
  31. package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
  32. package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
  33. package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
  34. package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
  35. package/dist/internal/sql-lineage/rejection.d.ts +11 -0
  36. package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
  37. package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
  38. package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
  39. package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
  40. package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
  41. package/dist/internal/sql-transform-relations.d.ts +20 -5
  42. package/dist/internal/sql-transform-relations.d.ts.map +1 -1
  43. package/dist/internal/versions.d.ts +10 -1
  44. package/dist/internal/versions.d.ts.map +1 -1
  45. package/dist/types.d.ts +10 -1
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +6 -6
  48. package/src/ducklake-storage.ts +9 -2
  49. package/src/internal/dataset-row-commit.ts +28 -2
  50. package/src/internal/dataset-row-merge.ts +4 -0
  51. package/src/internal/ducklake-dataset-catalog.ts +162 -49
  52. package/src/internal/ducklake-dataset-table-ref.ts +75 -17
  53. package/src/internal/ducklake-maintenance.ts +110 -48
  54. package/src/internal/ducklake-snapshot-reader.ts +352 -304
  55. package/src/internal/ducklake-sql-executor.ts +214 -14
  56. package/src/internal/ducklake-version-transaction.ts +224 -0
  57. package/src/internal/ducklake-write-coordinator.ts +226 -148
  58. package/src/internal/lineage-encoding.ts +136 -0
  59. package/src/internal/sql-lineage/analysis.ts +257 -0
  60. package/src/internal/sql-lineage/expressions.ts +208 -0
  61. package/src/internal/sql-lineage/plan-json.ts +130 -0
  62. package/src/internal/sql-lineage/plan-relation.ts +59 -0
  63. package/src/internal/sql-lineage/plan-walker.ts +727 -0
  64. package/src/internal/sql-lineage/rejection.ts +16 -0
  65. package/src/internal/sql-lineage/statement-gate.ts +164 -0
  66. package/src/internal/sql-lineage/table-functions.ts +11 -0
  67. package/src/internal/sql-transform-relations.ts +90 -40
  68. package/src/internal/versions.ts +37 -14
  69. 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
- renderDuckLakeSqlTransformSql,
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 = renderDuckLakeSqlTransformSql({
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 = renderDuckLakeSqlTransformSql({
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 checkedCasts = await this.assertResultSchemaMatchesTarget(runtime, target, sql)
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<DatasetVersionRef> {
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
+ }