@sixb/ducklake 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +60 -5
  2. package/dist/ducklake-storage.d.ts +3 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1963 -480
  5. package/dist/index.js.map +25 -15
  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 +81 -9
  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/sql.d.ts +11 -2
  44. package/dist/internal/sql.d.ts.map +1 -1
  45. package/dist/internal/versions.d.ts +10 -1
  46. package/dist/internal/versions.d.ts.map +1 -1
  47. package/dist/types.d.ts +10 -1
  48. package/dist/types.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/ducklake-storage.ts +17 -2
  51. package/src/internal/dataset-row-commit.ts +28 -2
  52. package/src/internal/dataset-row-merge.ts +4 -0
  53. package/src/internal/ducklake-dataset-catalog.ts +162 -49
  54. package/src/internal/ducklake-dataset-table-ref.ts +75 -17
  55. package/src/internal/ducklake-maintenance.ts +110 -48
  56. package/src/internal/ducklake-snapshot-reader.ts +549 -254
  57. package/src/internal/ducklake-sql-executor.ts +214 -14
  58. package/src/internal/ducklake-version-transaction.ts +224 -0
  59. package/src/internal/ducklake-write-coordinator.ts +226 -148
  60. package/src/internal/lineage-encoding.ts +136 -0
  61. package/src/internal/sql-lineage/analysis.ts +257 -0
  62. package/src/internal/sql-lineage/expressions.ts +208 -0
  63. package/src/internal/sql-lineage/plan-json.ts +130 -0
  64. package/src/internal/sql-lineage/plan-relation.ts +59 -0
  65. package/src/internal/sql-lineage/plan-walker.ts +727 -0
  66. package/src/internal/sql-lineage/rejection.ts +16 -0
  67. package/src/internal/sql-lineage/statement-gate.ts +164 -0
  68. package/src/internal/sql-lineage/table-functions.ts +11 -0
  69. package/src/internal/sql-transform-relations.ts +90 -40
  70. package/src/internal/sql.ts +35 -6
  71. package/src/internal/versions.ts +37 -14
  72. package/src/types.ts +11 -1
@@ -4,7 +4,9 @@ import type {
4
4
  BeginDatasetMergeInput,
5
5
  BeginDatasetWriteInput,
6
6
  DatasetMergeCommitResult,
7
+ DatasetProducer,
7
8
  DatasetVersion,
9
+ DatasetVersionLineage,
8
10
  DatasetWriteCommitResult,
9
11
  DatasetWriteMode,
10
12
  LakeMergeSession,
@@ -13,12 +15,13 @@ import type {
13
15
  import {
14
16
  assertUnsequencedDatasetWrite,
15
17
  getDatasetPrimaryKeyColumns,
16
- hasDatasetInputChanges,
18
+ hasDatasetProvenanceChanges,
17
19
  LakeConcurrencyError,
18
20
  LakeStorageError,
21
+ lineageAtCommit,
19
22
  retryDatasetMergeCommit,
20
23
  } from "@sixb/core/lake-storage"
21
- import type { DuckLakeCatalogOptions, DuckLakeStorageOptions } from "../types"
24
+ import type { DuckLakeStorageOptions } from "../types"
22
25
  import { localCatalogCoordinationKey } from "./catalog-key"
23
26
  import {
24
27
  type ApplyDatasetRowsResult,
@@ -33,7 +36,15 @@ import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
33
36
  import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
34
37
  import { createDuckLakeMergeSession, type DuckLakeCommitMergeInput } from "./ducklake-merge-session"
35
38
  import type { DuckLakeSnapshotReader, DuckLakeVersionSummary } from "./ducklake-snapshot-reader"
39
+ import {
40
+ beginTransactionOnLatestVersion,
41
+ CatalogMovedError,
42
+ commitVersionTransaction,
43
+ isSqliteCatalog,
44
+ rerunWhenCatalogMoves,
45
+ } from "./ducklake-version-transaction"
36
46
  import { createDuckLakeWriteSession, type DuckLakeCommitWriteInput } from "./ducklake-write-session"
47
+ import { encodeLineage } from "./lineage-encoding"
37
48
  import { encodeDatasetTableName } from "./names"
38
49
  import {
39
50
  duckLakeAlias,
@@ -42,7 +53,7 @@ import {
42
53
  quoteIdentifier,
43
54
  quoteSqlString,
44
55
  } from "./sql"
45
- import { parseCommitMetadata, type SixbCommitMetadata } from "./versions"
56
+ import { parseCommitMetadata, parseVersionId, type SixbCommitMetadata } from "./versions"
46
57
 
47
58
  export interface DuckLakeCommitDatasetVersionInput {
48
59
  readonly dataset: DatasetDefinition
@@ -51,6 +62,8 @@ export interface DuckLakeCommitDatasetVersionInput {
51
62
  readonly commitMessage: string
52
63
  readonly producer?: BeginDatasetWriteInput["producer"]
53
64
  readonly inputs?: BeginDatasetWriteInput["inputs"]
65
+ /** The lineage of the rows written, as `BeginDatasetWriteInput.lineage`. */
66
+ readonly lineage?: BeginDatasetWriteInput["lineage"]
54
67
  applyChanges(
55
68
  runtime: DuckDbQueryRuntime,
56
69
  context: DuckLakeApplyChangesContext
@@ -63,7 +76,6 @@ interface DuckLakeCommitVersionOutcomeInput
63
76
  readonly expectedLatestVersionId?: string | null
64
77
  readonly allowInitialNoOp?: boolean
65
78
  readonly signal?: AbortSignal
66
- readonly disableRetries?: boolean
67
79
  readonly optimisticOperation?: "commit" | "merge commit"
68
80
  }
69
81
 
@@ -171,6 +183,7 @@ export class DuckLakeWriteCoordinator {
171
183
  commitMessage: input.commit?.commitMessage ?? `write dataset ${input.write.dataset.id}`,
172
184
  producer: input.write.producer,
173
185
  inputs: input.write.inputs,
186
+ lineage: input.write.lineage,
174
187
  applyChanges: (runtime, context) =>
175
188
  this.applyStagedRows({
176
189
  ...input,
@@ -204,7 +217,6 @@ export class DuckLakeWriteCoordinator {
204
217
  expectedLatestVersionId: baseVersionId,
205
218
  allowInitialNoOp: !input.commit?.createInitialVersion,
206
219
  signal: input.commit?.signal,
207
- disableRetries: true,
208
220
  optimisticOperation: "merge commit",
209
221
  commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
210
222
  producer: input.merge.producer,
@@ -253,13 +265,7 @@ export class DuckLakeWriteCoordinator {
253
265
  input: DuckLakeCommitDatasetVersionInput
254
266
  ): Promise<DatasetWriteCommitResult> {
255
267
  assertUnsequencedDatasetWrite(input.dataset)
256
- const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
257
- ...input,
258
- // DuckLake has no primary-key constraint. Do not let its automatic retry replay a keyed
259
- // append after the collision checks ran against an older transaction snapshot.
260
- disableRetries:
261
- input.mode === "append" && getDatasetPrimaryKeyColumns(input.dataset) !== null,
262
- })
268
+ const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, input)
263
269
  if (result.version === null) {
264
270
  throw new LakeStorageError(
265
271
  `[SixbDuckLake] No DuckLake changes were committed for dataset '${input.dataset.id}', and no previous version exists.`
@@ -273,55 +279,14 @@ export class DuckLakeWriteCoordinator {
273
279
  runtime: DuckDbQueryRuntime,
274
280
  input: DuckLakeCommitVersionOutcomeInput
275
281
  ): Promise<DatasetMergeCommitResult> {
276
- const runtimeInput = { ...input, runtime }
277
- return this.withGuardedRetrySetting(runtimeInput, () =>
278
- this.commitDatasetVersionUnlocked(runtimeInput)
282
+ // Staged rows outlive a failed transaction, so a rerun applies exactly the same changes.
283
+ return rerunWhenCatalogMoves(
284
+ input.dataset.id,
285
+ () => this.commitDatasetVersionUnlocked({ ...input, runtime }),
286
+ input.signal
279
287
  )
280
288
  }
281
289
 
282
- private async withGuardedRetrySetting<T>(
283
- input: DuckLakeCommitVersionOutcomeRuntimeInput,
284
- run: () => Promise<T>
285
- ): Promise<T> {
286
- if (input.expectedLatestVersionId === undefined && !input.disableRetries) {
287
- return run()
288
- }
289
-
290
- // DuckLake can retry transactions internally. Optimistic guards and keyed append constraint
291
- // checks must not be replayed against a different snapshot, so disable retries for this
292
- // exclusive commit and restore the runtime setting before releasing the queue.
293
- await input.runtime.run("SET ducklake_max_retry_count = 0")
294
- let outcome:
295
- | { readonly kind: "success"; readonly value: T }
296
- | {
297
- readonly kind: "error"
298
- readonly error: unknown
299
- }
300
-
301
- try {
302
- outcome = { kind: "success", value: await run() }
303
- } catch (error) {
304
- outcome = { kind: "error", error }
305
- }
306
-
307
- try {
308
- await input.runtime.run("RESET ducklake_max_retry_count")
309
- } catch {
310
- // The retry count is session state on a shared, long-lived connection.
311
- // If it cannot be restored, recycle the connection so a leftover value of
312
- // 0 cannot silently disable DuckLake auto-retry for a later unguarded
313
- // commit. The commit already succeeded or failed durably, so this cleanup
314
- // must never change the outcome reported to the caller.
315
- this.connections.poisonRuntime()
316
- }
317
-
318
- if (outcome.kind === "error") {
319
- throw outcome.error
320
- }
321
-
322
- return outcome.value
323
- }
324
-
325
290
  private async commitDatasetVersionUnlocked(
326
291
  input: DuckLakeCommitVersionOutcomeRuntimeInput
327
292
  ): Promise<DatasetMergeCommitResult> {
@@ -332,9 +297,6 @@ export class DuckLakeWriteCoordinator {
332
297
 
333
298
  let committed = false
334
299
  try {
335
- // Guarded Sixb commits need compare-and-swap semantics. DuckLake can
336
- // automatically retry non-conflicting commits, so guarded transactions
337
- // disable retries and validate a head belonging to the native write snapshot.
338
300
  const latestVersion = await this.beginVersionTransaction(input)
339
301
  if (input.expectedLatestVersionId !== undefined) {
340
302
  this.assertExpectedLatestVersion({
@@ -345,20 +307,48 @@ export class DuckLakeWriteCoordinator {
345
307
  })
346
308
  }
347
309
 
310
+ const lineage = lineageAtCommit({
311
+ mode: input.mode,
312
+ schema: input.dataset.schema,
313
+ producer: input.producer,
314
+ written: input.lineage,
315
+ parent: latestVersion,
316
+ })
317
+
348
318
  const changeResult = await input.applyChanges(input.runtime, {
349
319
  previousRowCount: latestVersion?.rowCount,
350
320
  validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns,
351
321
  })
352
322
  input.signal?.throwIfAborted()
353
- await this.ensureNoOpHasSnapshot(input, changeResult, latestVersion)
323
+
324
+ // A commit that changes no rows is decided against the latest version it compared with.
325
+ const noOpBase = changeResult.dataChangeExpected
326
+ ? undefined
327
+ : latestVersion === undefined
328
+ ? await this.latestVersionAtReadSnapshot(input)
329
+ : latestVersion
330
+
331
+ const createsVersion =
332
+ noOpBase === undefined || this.noOpCreatesVersion(input, noOpBase, lineage)
333
+ if (createsVersion && mustClaimTable(input, changeResult, lineage)) {
334
+ await this.claimTable(input)
335
+ }
336
+
354
337
  const commitId = randomUUID()
355
338
  await this.setCommitMetadata(
356
339
  input,
357
340
  commitId,
358
- commitRowCount(input, changeResult, latestVersion)
341
+ commitRowCount(input, changeResult, latestVersion),
342
+ lineage
359
343
  )
360
344
 
361
- await input.runtime.run("COMMIT")
345
+ await commitVersionTransaction(
346
+ input.runtime,
347
+ input.dataset.id,
348
+ createsVersion && mustCommitOnReadSnapshot(input, changeResult)
349
+ ? { poisonRuntime: () => this.connections.poisonRuntime() }
350
+ : undefined
351
+ )
362
352
  committed = true
363
353
 
364
354
  const committedWriteSnapshotId = await this.lastCommittedSnapshotId(input.runtime)
@@ -369,6 +359,7 @@ export class DuckLakeWriteCoordinator {
369
359
  previousWriteSnapshotId,
370
360
  committedWriteSnapshotId,
371
361
  changeResult,
362
+ noOpBaseVersionId: noOpBase?.versionId ?? null,
372
363
  })
373
364
  } finally {
374
365
  await this.connections.detachLocalCatalogAfterCommit(input.runtime)
@@ -376,24 +367,6 @@ export class DuckLakeWriteCoordinator {
376
367
  } catch (error) {
377
368
  if (!committed) {
378
369
  await this.rollbackTransaction(input.runtime)
379
- // DuckLake's snapshot-id uniqueness constraint is its catalog commit CAS. With native
380
- // retries disabled, this error proves the transaction lost to another writer. Do not
381
- // classify generic connection/COMMIT errors: their durable outcome may be ambiguous.
382
- if (
383
- error instanceof Error &&
384
- error.message.startsWith(
385
- "TransactionContext Error: Failed to commit: Failed to commit DuckLake transaction."
386
- ) &&
387
- error.message.includes("Exceeded the maximum retry count of 0") &&
388
- error.message.includes(
389
- 'duplicate key value violates unique constraint "ducklake_snapshot_pkey"'
390
- )
391
- ) {
392
- throw new LakeConcurrencyError(
393
- `[SixbDuckLake] Concurrent catalog commit for dataset '${input.dataset.id}'.`,
394
- { cause: error }
395
- )
396
- }
397
370
  }
398
371
  throw error
399
372
  }
@@ -405,6 +378,8 @@ export class DuckLakeWriteCoordinator {
405
378
  readonly previousWriteSnapshotId: string | null
406
379
  readonly committedWriteSnapshotId: string | null
407
380
  readonly changeResult: ApplyDatasetRowsResult
381
+ /** The latest version a commit that changes no rows was compared with. */
382
+ readonly noOpBaseVersionId: string | null
408
383
  }
409
384
  ): Promise<DatasetMergeCommitResult> {
410
385
  // Hydrate the committed version on the same exclusive runtime. That keeps
@@ -447,6 +422,15 @@ export class DuckLakeWriteCoordinator {
447
422
  operation: input.optimisticOperation ?? "commit",
448
423
  })
449
424
  }
425
+
426
+ // Nothing was committed: the write matched the version this transaction read. Another
427
+ // writer moved the latest version since, and the write may not match that one.
428
+ if ((version?.versionId ?? null) !== input.noOpBaseVersionId) {
429
+ throw new CatalogMovedError(
430
+ `[SixbDuckLake] Dataset '${input.dataset.id}' changed while an unchanged write was compared with it.`
431
+ )
432
+ }
433
+
450
434
  return {
451
435
  outcome: "unchanged",
452
436
  version,
@@ -492,7 +476,8 @@ export class DuckLakeWriteCoordinator {
492
476
  private async setCommitMetadata(
493
477
  input: DuckLakeCommitVersionOutcomeRuntimeInput,
494
478
  commitId: string,
495
- rowCount: CommitRowCount
479
+ rowCount: CommitRowCount,
480
+ lineage: DatasetVersionLineage | undefined
496
481
  ): Promise<void> {
497
482
  // DuckLake stores this JSON on the snapshot it creates for the current
498
483
  // transaction. Version id, timestamp, and data-change detection still come
@@ -512,6 +497,7 @@ export class DuckLakeWriteCoordinator {
512
497
  ...(validatedPrimaryKeyColumns !== null
513
498
  ? { validatedPrimaryKeyColumns: [...validatedPrimaryKeyColumns] }
514
499
  : {}),
500
+ ...(lineage === undefined ? {} : { lineage: encodeLineage(lineage) }),
515
501
  }
516
502
 
517
503
  await input.runtime.run(
@@ -523,37 +509,69 @@ export class DuckLakeWriteCoordinator {
523
509
  )
524
510
  }
525
511
 
526
- private async ensureNoOpHasSnapshot(
527
- input: DuckLakeCommitVersionOutcomeRuntimeInput,
528
- changeResult: ApplyDatasetRowsResult,
529
- knownLatestVersion: DuckLakeVersionSummary | null | undefined
530
- ): Promise<void> {
531
- if (changeResult.dataChangeExpected) {
532
- return
533
- }
534
-
535
- const latestVersion =
536
- knownLatestVersion === undefined
537
- ? await this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset)
538
- : knownLatestVersion
539
- if (
540
- latestVersion
541
- ? input.mode === "merge" || !hasDatasetInputChanges(latestVersion.inputs, input.inputs)
542
- : input.allowInitialNoOp
543
- ) {
544
- return
545
- }
546
-
547
- // DuckLake 1.5.2 does not create a snapshot for a transaction whose data and metadata are
548
- // unchanged, and set_commit_message alone cannot force one. Reapplying the current table
549
- // comment is an idempotent metadata write: it leaves the visible DatasetDefinition unchanged
550
- // while giving initial empty writes and changed input lineage a real DuckLake snapshot.
512
+ /**
513
+ * Claims the table for the version this transaction commits by reapplying its current comment:
514
+ * an idempotent metadata write, which DuckLake records as altering the table. A transaction
515
+ * that changes a table another one altered since it began then fails to commit, before storing
516
+ * anything, and reruns on the new latest version (see `mustClaimTable`).
517
+ *
518
+ * It also gives a version that changes no rows, an initial empty write or a change of
519
+ * provenance, a DuckLake snapshot: DuckLake 1.5.2 creates none for a transaction whose data and
520
+ * metadata are unchanged, and set_commit_message alone cannot force one.
521
+ *
522
+ * Altering a table bumps DuckLake's catalog-wide schema version: every connection reloads its
523
+ * catalog, and concurrent writers to other tables slow down. Versions claim only when needed.
524
+ */
525
+ private async claimTable(input: DuckLakeCommitVersionOutcomeRuntimeInput): Promise<void> {
551
526
  const table = qualifiedTableName(this.options, encodeDatasetTableName(input.dataset.id))
552
527
  const descriptionSql =
553
528
  input.dataset.description === undefined ? "NULL" : quoteSqlString(input.dataset.description)
554
529
  await input.runtime.run(`COMMENT ON TABLE ${table} IS ${descriptionSql}`)
555
530
  }
556
531
 
532
+ /** A commit that changes no rows still creates a version for a first version or new provenance. */
533
+ private noOpCreatesVersion(
534
+ input: DuckLakeCommitVersionOutcomeRuntimeInput,
535
+ latestVersion: DuckLakeVersionSummary | null,
536
+ lineage: DatasetVersionLineage | undefined
537
+ ): boolean {
538
+ if (!latestVersion) {
539
+ return !input.allowInitialNoOp
540
+ }
541
+
542
+ return (
543
+ input.mode !== "merge" &&
544
+ hasDatasetProvenanceChanges(latestVersion, {
545
+ datasetId: input.dataset.id,
546
+ schema: input.dataset.schema,
547
+ producer: input.producer,
548
+ inputs: input.inputs,
549
+ lineage,
550
+ })
551
+ )
552
+ }
553
+
554
+ /**
555
+ * The latest version a write that began without reading it compared its rows with: the one at
556
+ * the lake snapshot its first read fixed. The version metadata also shows versions committed
557
+ * since. One of this dataset means the rows were compared with an older version, so the write
558
+ * reruns.
559
+ */
560
+ private async latestVersionAtReadSnapshot(
561
+ input: DuckLakeCommitVersionOutcomeRuntimeInput
562
+ ): Promise<DuckLakeVersionSummary | null> {
563
+ const { snapshotId, latestVersion } =
564
+ await this.snapshots.getLatestVersionSummaryAtCurrentSnapshot(input.runtime, input.dataset)
565
+
566
+ if (latestVersion && BigInt(parseVersionId(latestVersion.versionId)) > BigInt(snapshotId)) {
567
+ throw new CatalogMovedError(
568
+ `[SixbDuckLake] Dataset '${input.dataset.id}' changed while an unchanged write was compared with it.`
569
+ )
570
+ }
571
+
572
+ return latestVersion
573
+ }
574
+
557
575
  private async versionForNoOpCommit(
558
576
  runtime: DuckDbQueryRuntime,
559
577
  definition: DatasetDefinition,
@@ -582,49 +600,31 @@ export class DuckLakeWriteCoordinator {
582
600
  }
583
601
  }
584
602
 
603
+ /**
604
+ * Every version but a snapshot keeps its parent's rows, and the lineage it records merges the
605
+ * parent's. A snapshot replaces its parent: it reads it only to guard on it, or on SQLite, whose
606
+ * later reads inside the transaction would block its commit.
607
+ */
585
608
  private async beginVersionTransaction(
586
609
  input: DuckLakeCommitVersionOutcomeRuntimeInput
587
610
  ): Promise<DuckLakeVersionSummary | null | undefined> {
588
- if (!isSqliteCatalog(this.options.catalog)) {
589
- await input.runtime.run("BEGIN TRANSACTION")
590
- if (input.expectedLatestVersionId === undefined && input.mode !== "append") {
591
- return undefined
592
- }
593
- return this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset)
594
- }
595
-
596
- // Direct metadata reads enlist a second SQLite transaction on the outer DuckDB connection.
597
- // Its read lock blocks DuckLake's own metadata connection at COMMIT. Read in autocommit,
598
- // then pin the native DuckLake snapshot and verify nothing changed in between. The summary
599
- // is now valid for this write transaction, including null (no previous version).
600
- // Only preparation can repeat: staged mutations and COMMIT are never replayed here.
601
- for (let attempt = 0; attempt < 3; attempt++) {
602
- input.signal?.throwIfAborted()
603
- const snapshotId = await this.currentSnapshotId(input.runtime)
604
- const latestVersion = await this.snapshots.getLatestVersionSummaryForDefinition(
605
- input.runtime,
606
- input.dataset
607
- )
611
+ if (
612
+ input.mode === "snapshot" &&
613
+ input.expectedLatestVersionId === undefined &&
614
+ !isSqliteCatalog(this.options.catalog)
615
+ ) {
608
616
  await input.runtime.run("BEGIN TRANSACTION")
609
- if ((await this.currentSnapshotId(input.runtime)) === snapshotId) {
610
- return latestVersion
611
- }
612
- await input.runtime.run("ROLLBACK")
617
+ return undefined
613
618
  }
614
619
 
615
- throw new LakeConcurrencyError(
616
- `[SixbDuckLake] Catalog changed during three write preparations for dataset '${input.dataset.id}'. Retry the write.`
617
- )
618
- }
619
-
620
- private async currentSnapshotId(runtime: DuckDbQueryRuntime): Promise<string> {
621
- const [row] = await runtime.query(
622
- `SELECT id FROM ${quoteIdentifier(duckLakeAlias(this.options))}.current_snapshot()`
623
- )
624
- if (row === undefined) {
625
- throw new LakeStorageError("[SixbDuckLake] DuckLake did not return a current snapshot.")
626
- }
627
- return String(getBigIntLike(row, "id"))
620
+ return beginTransactionOnLatestVersion({
621
+ options: this.options,
622
+ runtime: input.runtime,
623
+ datasetId: input.dataset.id,
624
+ signal: input.signal,
625
+ readLatestVersion: () =>
626
+ this.snapshots.getLatestVersionSummaryAtCurrentSnapshot(input.runtime, input.dataset),
627
+ })
628
628
  }
629
629
 
630
630
  private async lastCommittedSnapshotId(runtime: DuckDbQueryRuntime): Promise<string | null> {
@@ -716,13 +716,91 @@ export class DuckLakeWriteCoordinator {
716
716
  }
717
717
  }
718
718
 
719
- function isSqliteCatalog(catalog: DuckLakeCatalogOptions): boolean {
719
+ /**
720
+ * Whether a version must claim its table so that a concurrent commit cannot build on it unseen.
721
+ *
722
+ * DuckLake commits a transaction on top of snapshots committed since it began unless one of them
723
+ * altered the table or deleted rows it held. Only the earlier commit decides: a claim protects the
724
+ * version that makes it from concurrent commits that land after it, never the reverse. A commit
725
+ * that builds on a version it did not see keeps that version's rows, but stores the lineage of
726
+ * the parent it read. For lineage, that is only wrong when the unseen version's stored lineage
727
+ * carries dependencies: rows that depend on their own columns only add nothing to any lineage, so
728
+ * two unkeyed appends to a dataset no pipeline writes may commit in any order. Commits that record
729
+ * more of their parent than its lineage do not build on unseen versions (see
730
+ * `mustCommitOnReadSnapshot`).
731
+ *
732
+ * - A version that changes no rows claims: it is also how it gets a snapshot.
733
+ * - A write into an empty table claims, whatever it holds. It deletes nothing, so a snapshot that
734
+ * also read the table empty would delete nothing either, and keep the rows it replaces.
735
+ * - A snapshot that deleted rows needs no claim: any concurrent change to the table conflicts with
736
+ * its deletes.
737
+ * - A keyed append or merge claims. DuckLake has no primary key: two writes that insert one new
738
+ * key both commit unless the earlier one claims.
739
+ * - An unkeyed append into a table with rows claims when the lineage it stores carries a
740
+ * dependency, or is unknown. A concurrent snapshot of that table deletes rows, and conflicts.
741
+ */
742
+ function mustClaimTable(
743
+ input: DuckLakeCommitVersionOutcomeRuntimeInput,
744
+ changeResult: ApplyDatasetRowsResult,
745
+ lineage: DatasetVersionLineage | undefined
746
+ ): boolean {
747
+ if (!changeResult.dataChangeExpected || changeResult.targetWasEmpty) {
748
+ return true
749
+ }
750
+
751
+ if (input.mode === "snapshot") {
752
+ return false
753
+ }
754
+
755
+ return (
756
+ getDatasetPrimaryKeyColumns(input.dataset) !== null ||
757
+ carriesDependencies(input.producer, lineage)
758
+ )
759
+ }
760
+
761
+ /**
762
+ * Whether a version must fail on any snapshot committed since its transaction began, rather than
763
+ * commit on top of one that does not conflict with it. A version that did not claim its table (see
764
+ * `mustClaimTable`) can only be built on unseen by a commit that records nothing of its parent but
765
+ * its lineage, which that version cannot change. Two kinds of commit record more:
766
+ *
767
+ * - One that inserts and deletes no rows of its table, such as a version of new provenance, a
768
+ * merge that only advances source ordering, or a schema change. DuckLake checks it against
769
+ * nothing, and it would stand for rows it never compared.
770
+ * - An unkeyed append guarded on the version it read, which an unclaimed append could move.
771
+ */
772
+ function mustCommitOnReadSnapshot(
773
+ input: DuckLakeCommitVersionOutcomeRuntimeInput,
774
+ changeResult: ApplyDatasetRowsResult
775
+ ): boolean {
776
+ if (!changeResult.changedTableRows) {
777
+ return true
778
+ }
779
+
720
780
  return (
721
- catalog.type === "sqlite" ||
722
- (catalog.type === "custom" && catalog.uri.toLowerCase().startsWith("sqlite:"))
781
+ input.expectedLatestVersionId !== undefined &&
782
+ input.mode === "append" &&
783
+ getDatasetPrimaryKeyColumns(input.dataset) === null
723
784
  )
724
785
  }
725
786
 
787
+ /** Whether a stored lineage says the version's rows depend on any column but their own. */
788
+ function carriesDependencies(
789
+ producer: DatasetProducer | undefined,
790
+ lineage: DatasetVersionLineage | undefined
791
+ ): boolean {
792
+ if (lineage === undefined) {
793
+ // A pipeline version without lineage could hold anything.
794
+ return producer?.kind === "pipeline"
795
+ }
796
+
797
+ if ("unknown" in lineage) {
798
+ return true
799
+ }
800
+
801
+ return lineage.rows.length > 0 || Object.values(lineage.columns).some((refs) => refs.length > 0)
802
+ }
803
+
726
804
  function commitRowCount(
727
805
  input: Pick<DuckLakeCommitVersionOutcomeInput, "expectedLatestVersionId" | "mode">,
728
806
  changeResult: ApplyDatasetRowsResult,
@@ -0,0 +1,136 @@
1
+ import type {
2
+ DatasetColumnLineage,
3
+ DatasetColumnRef,
4
+ DatasetVersionLineage,
5
+ } from "@sixb/core/lake-storage"
6
+
7
+ /**
8
+ * How a version's resolved lineage is stored in DuckLake commit metadata.
9
+ *
10
+ * A resolved lineage repeats the same dataset ids and column names across thousands of refs, and
11
+ * every catalog scan reads the commit metadata of every snapshot it passes, wanted or not. So each
12
+ * distinct string is stored once and refs are index pairs, and the encoded lineage is one JSON
13
+ * string inside the metadata: parsing the metadata copies it without building its refs, which
14
+ * only the versions a read returns decode.
15
+ */
16
+ interface EncodedColumnLineage {
17
+ readonly v: 1
18
+ /** Every distinct dataset id, column name, and lineage column name. */
19
+ readonly strings: readonly string[]
20
+ /** Row refs as flat index pairs: dataset id, column. */
21
+ readonly rows: readonly number[]
22
+ /** One entry per column: its name's index, then its refs as flat index pairs. */
23
+ readonly columns: readonly (readonly number[])[]
24
+ }
25
+
26
+ export function encodeLineage(lineage: DatasetVersionLineage): string {
27
+ if ("unknown" in lineage) {
28
+ return JSON.stringify({ unknown: lineage.unknown })
29
+ }
30
+
31
+ const strings: string[] = []
32
+ const indexes = new Map<string, number>()
33
+ const indexOf = (value: string) => {
34
+ let index = indexes.get(value)
35
+ if (index === undefined) {
36
+ index = strings.length
37
+ strings.push(value)
38
+ indexes.set(value, index)
39
+ }
40
+ return index
41
+ }
42
+ const encodeRefs = (refs: readonly DatasetColumnRef[]) =>
43
+ refs.flatMap((ref) => [indexOf(ref.datasetId), indexOf(ref.column)])
44
+
45
+ const encoded: EncodedColumnLineage = {
46
+ v: 1,
47
+ strings,
48
+ rows: encodeRefs(lineage.rows),
49
+ columns: Object.entries(lineage.columns).map(([column, refs]) => [
50
+ indexOf(column),
51
+ ...encodeRefs(refs),
52
+ ]),
53
+ }
54
+
55
+ return JSON.stringify(encoded)
56
+ }
57
+
58
+ /**
59
+ * The stored lineage, as stored. One Sixb cannot read is unknown, as Sixb reads a malformed
60
+ * lineage: dropping it instead would classify a version outside pipelines by its own columns only.
61
+ */
62
+ export function decodeLineage(datasetId: string, stored: unknown): DatasetVersionLineage {
63
+ const parsed = typeof stored === "string" ? parseJson(stored) : undefined
64
+ if (isRecord(parsed) && Object.hasOwn(parsed, "unknown")) {
65
+ return typeof parsed.unknown === "string" ? { unknown: parsed.unknown } : unreadable(datasetId)
66
+ }
67
+
68
+ return decodeColumnLineage(parsed) ?? unreadable(datasetId)
69
+ }
70
+
71
+ function decodeColumnLineage(value: unknown): DatasetColumnLineage | undefined {
72
+ if (!isRecord(value) || value.v !== 1 || !isStringList(value.strings)) {
73
+ return undefined
74
+ }
75
+
76
+ const { strings } = value
77
+ const decodeRefs = (flat: readonly unknown[], from: number) => {
78
+ if ((flat.length - from) % 2 !== 0) {
79
+ return undefined
80
+ }
81
+
82
+ const refs: DatasetColumnRef[] = []
83
+ for (let index = from; index < flat.length; index += 2) {
84
+ const datasetId = stringAt(strings, flat[index])
85
+ const column = stringAt(strings, flat[index + 1])
86
+ if (datasetId === undefined || column === undefined) {
87
+ return undefined
88
+ }
89
+ refs.push({ datasetId, column })
90
+ }
91
+
92
+ return refs
93
+ }
94
+
95
+ const rows = Array.isArray(value.rows) ? decodeRefs(value.rows, 0) : undefined
96
+ if (rows === undefined || !Array.isArray(value.columns)) {
97
+ return undefined
98
+ }
99
+
100
+ // A column listed twice would read as its last entry: a tampered record could narrow it.
101
+ const columns = new Map<string, DatasetColumnRef[]>()
102
+ for (const entry of value.columns) {
103
+ const name = Array.isArray(entry) ? stringAt(strings, entry[0]) : undefined
104
+ const refs = name === undefined ? undefined : decodeRefs(entry, 1)
105
+ if (name === undefined || refs === undefined || columns.has(name)) {
106
+ return undefined
107
+ }
108
+ columns.set(name, refs)
109
+ }
110
+
111
+ return { rows, columns: Object.fromEntries(columns) }
112
+ }
113
+
114
+ function unreadable(datasetId: string): DatasetVersionLineage {
115
+ return { unknown: `a version of '${datasetId}' records a column lineage Sixb cannot read` }
116
+ }
117
+
118
+ function parseJson(text: string): unknown {
119
+ try {
120
+ return JSON.parse(text)
121
+ } catch {
122
+ return undefined
123
+ }
124
+ }
125
+
126
+ function stringAt(strings: readonly string[], index: unknown): string | undefined {
127
+ return typeof index === "number" && Number.isInteger(index) ? strings[index] : undefined
128
+ }
129
+
130
+ function isStringList(value: unknown): value is readonly string[] {
131
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
132
+ }
133
+
134
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value)
136
+ }