@sixb/ducklake 0.1.2 → 0.1.4

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.
@@ -10,7 +10,14 @@ import type {
10
10
  LakeMergeSession,
11
11
  LakeWriteSession,
12
12
  } from "@sixb/core/lake-storage"
13
- import { getDatasetPrimaryKeyColumns, LakeStorageError } from "@sixb/core/lake-storage"
13
+ import {
14
+ assertUnsequencedDatasetWrite,
15
+ getDatasetPrimaryKeyColumns,
16
+ hasDatasetInputChanges,
17
+ LakeConcurrencyError,
18
+ LakeStorageError,
19
+ retryDatasetMergeCommit,
20
+ } from "@sixb/core/lake-storage"
14
21
  import type { DuckLakeStorageOptions } from "../types"
15
22
  import { localCatalogCoordinationKey } from "./catalog-key"
16
23
  import {
@@ -27,13 +34,20 @@ import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
27
34
  import { createDuckLakeMergeSession, type DuckLakeCommitMergeInput } from "./ducklake-merge-session"
28
35
  import type { DuckLakeSnapshotReader, DuckLakeVersionSummary } from "./ducklake-snapshot-reader"
29
36
  import { createDuckLakeWriteSession, type DuckLakeCommitWriteInput } from "./ducklake-write-session"
30
- import { duckLakeAlias, duckLakeMetadataTableName, quoteIdentifier, quoteSqlString } from "./sql"
37
+ import { encodeDatasetTableName } from "./names"
38
+ import {
39
+ duckLakeAlias,
40
+ duckLakeMetadataTableName,
41
+ qualifiedTableName,
42
+ quoteIdentifier,
43
+ quoteSqlString,
44
+ } from "./sql"
31
45
  import { parseCommitMetadata, type SixbCommitMetadata } from "./versions"
32
46
 
33
47
  export interface DuckLakeCommitDatasetVersionInput {
34
48
  readonly dataset: DatasetDefinition
35
49
  readonly mode: DatasetWriteMode
36
- readonly expectedLatestVersionId?: string
50
+ readonly expectedLatestVersionId?: string | null
37
51
  readonly commitMessage: string
38
52
  readonly producer?: BeginDatasetWriteInput["producer"]
39
53
  readonly inputs?: BeginDatasetWriteInput["inputs"]
@@ -48,6 +62,7 @@ interface DuckLakeCommitVersionOutcomeInput
48
62
  readonly mode: DatasetWriteMode | "merge"
49
63
  readonly expectedLatestVersionId?: string | null
50
64
  readonly allowInitialNoOp?: boolean
65
+ readonly signal?: AbortSignal
51
66
  readonly disableRetries?: boolean
52
67
  readonly optimisticOperation?: "commit" | "merge commit"
53
68
  }
@@ -86,6 +101,7 @@ export class DuckLakeWriteCoordinator {
86
101
  }
87
102
 
88
103
  this.datasets.assertSchema(definition)
104
+ assertUnsequencedDatasetWrite(definition)
89
105
  const mode = input.mode ?? "snapshot"
90
106
  assertDatasetWriteMode(mode, "write")
91
107
 
@@ -126,7 +142,7 @@ export class DuckLakeWriteCoordinator {
126
142
  input.expectedLatestVersionId !== undefined &&
127
143
  latestVersion?.versionId !== input.expectedLatestVersionId
128
144
  ) {
129
- throw new LakeStorageError(
145
+ throw new LakeConcurrencyError(
130
146
  `[SixbDuckLake] Optimistic merge start failed for dataset '${definition.id}': expected latest version '${input.expectedLatestVersionId}', found '${latestVersion?.versionId ?? "none"}'.`
131
147
  )
132
148
  }
@@ -175,35 +191,43 @@ export class DuckLakeWriteCoordinator {
175
191
  )
176
192
  }
177
193
 
178
- return this.withCommitRuntime((runtime) =>
179
- this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
180
- dataset: definition,
181
- mode: "merge",
182
- expectedLatestVersionId: input.baseVersionId,
183
- allowInitialNoOp: true,
184
- disableRetries: true,
185
- optimisticOperation: "merge commit",
186
- commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
187
- producer: input.merge.producer,
188
- inputs: input.merge.inputs,
189
- applyChanges: async (commitRuntime, context) => {
190
- const result = await applyDatasetMergeFromRelation({
191
- options: this.options,
192
- runtime: commitRuntime,
193
- dataset: definition,
194
- stagingTableName: input.stagingTableName,
195
- sequenceColumnName: input.sequenceColumnName,
196
- kindColumnName: input.kindColumnName,
197
- previousRowCount: context.previousRowCount,
198
- validatedPrimaryKeyColumns: context.validatedPrimaryKeyColumns,
199
- })
200
- if (result.sourceRowCount !== input.changesWritten) {
201
- throw new LakeStorageError(
202
- `[SixbDuckLake] Staged merge for dataset '${definition.id}' accepted ${input.changesWritten} change(s), but DuckLake saw ${result.sourceRowCount} source change(s) at commit time.`
203
- )
204
- }
205
- return result
206
- },
194
+ return retryDatasetMergeCommit(input.merge, input.commit, (rebase) =>
195
+ this.withCommitRuntime(async (runtime) => {
196
+ input.commit?.signal?.throwIfAborted()
197
+ const baseVersionId = rebase
198
+ ? ((await this.snapshots.getLatestVersionForDefinition(runtime, definition))?.versionId ??
199
+ null)
200
+ : input.baseVersionId
201
+ return this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
202
+ dataset: definition,
203
+ mode: "merge",
204
+ expectedLatestVersionId: baseVersionId,
205
+ allowInitialNoOp: !input.commit?.createInitialVersion,
206
+ signal: input.commit?.signal,
207
+ disableRetries: true,
208
+ optimisticOperation: "merge commit",
209
+ commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
210
+ producer: input.merge.producer,
211
+ inputs: input.merge.inputs,
212
+ applyChanges: async (commitRuntime, context) => {
213
+ const result = await applyDatasetMergeFromRelation({
214
+ options: this.options,
215
+ runtime: commitRuntime,
216
+ dataset: definition,
217
+ stagingTableName: input.stagingTableName,
218
+ sequenceColumnName: input.sequenceColumnName,
219
+ kindColumnName: input.kindColumnName,
220
+ previousRowCount: context.previousRowCount,
221
+ validatedPrimaryKeyColumns: context.validatedPrimaryKeyColumns,
222
+ })
223
+ if (result.sourceRowCount !== input.changesWritten) {
224
+ throw new LakeStorageError(
225
+ `[SixbDuckLake] Staged merge for dataset '${definition.id}' accepted ${input.changesWritten} change(s), but DuckLake saw ${result.sourceRowCount} source change(s) at commit time.`
226
+ )
227
+ }
228
+ return result
229
+ },
230
+ })
207
231
  })
208
232
  )
209
233
  }
@@ -228,6 +252,7 @@ export class DuckLakeWriteCoordinator {
228
252
  runtime: DuckDbQueryRuntime,
229
253
  input: DuckLakeCommitDatasetVersionInput
230
254
  ): Promise<DatasetWriteCommitResult> {
255
+ assertUnsequencedDatasetWrite(input.dataset)
231
256
  const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
232
257
  ...input,
233
258
  // DuckLake has no primary-key constraint. Do not let its automatic retry replay a keyed
@@ -325,6 +350,8 @@ export class DuckLakeWriteCoordinator {
325
350
  previousRowCount: latestVersion?.rowCount,
326
351
  validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns,
327
352
  })
353
+ input.signal?.throwIfAborted()
354
+ await this.ensureNoOpHasSnapshot(input, changeResult, latestVersion)
328
355
  const commitId = randomUUID()
329
356
  await this.setCommitMetadata(
330
357
  input,
@@ -350,6 +377,24 @@ export class DuckLakeWriteCoordinator {
350
377
  } catch (error) {
351
378
  if (!committed) {
352
379
  await this.rollbackTransaction(input.runtime)
380
+ // DuckLake's snapshot-id uniqueness constraint is its catalog commit CAS. With native
381
+ // retries disabled, this error proves the transaction lost to another writer. Do not
382
+ // classify generic connection/COMMIT errors: their durable outcome may be ambiguous.
383
+ if (
384
+ error instanceof Error &&
385
+ error.message.startsWith(
386
+ "TransactionContext Error: Failed to commit: Failed to commit DuckLake transaction."
387
+ ) &&
388
+ error.message.includes("Exceeded the maximum retry count of 0") &&
389
+ error.message.includes(
390
+ 'duplicate key value violates unique constraint "ducklake_snapshot_pkey"'
391
+ )
392
+ ) {
393
+ throw new LakeConcurrencyError(
394
+ `[SixbDuckLake] Concurrent catalog commit for dataset '${input.dataset.id}'.`,
395
+ { cause: error }
396
+ )
397
+ }
353
398
  }
354
399
  throw error
355
400
  }
@@ -479,6 +524,36 @@ export class DuckLakeWriteCoordinator {
479
524
  )
480
525
  }
481
526
 
527
+ private async ensureNoOpHasSnapshot(
528
+ input: DuckLakeCommitVersionOutcomeRuntimeInput,
529
+ changeResult: ApplyDatasetRowsResult,
530
+ knownLatestVersion: DuckLakeVersionSummary | null
531
+ ): Promise<void> {
532
+ if (changeResult.dataChangeExpected) {
533
+ return
534
+ }
535
+
536
+ const latestVersion =
537
+ knownLatestVersion ??
538
+ (await this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset))
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.
551
+ const table = qualifiedTableName(this.options, encodeDatasetTableName(input.dataset.id))
552
+ const descriptionSql =
553
+ input.dataset.description === undefined ? "NULL" : quoteSqlString(input.dataset.description)
554
+ await input.runtime.run(`COMMENT ON TABLE ${table} IS ${descriptionSql}`)
555
+ }
556
+
482
557
  private async versionForNoOpCommit(
483
558
  runtime: DuckDbQueryRuntime,
484
559
  definition: DatasetDefinition,
@@ -599,7 +674,7 @@ export class DuckLakeWriteCoordinator {
599
674
  }
600
675
 
601
676
  if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
602
- throw new LakeStorageError(
677
+ throw new LakeConcurrencyError(
603
678
  `[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`
604
679
  )
605
680
  }
@@ -99,6 +99,11 @@ export function encodeDatasetTableName(datasetId: string): string {
99
99
  return `${DATASET_TABLE_PREFIX}${encodeDatasetId(datasetId)}`
100
100
  }
101
101
 
102
+ /** Current source-ordering state, deliberately outside the dataset table namespace. */
103
+ export function encodeDatasetSequenceTableName(datasetId: string): string {
104
+ return `sixb__seq__${encodeDatasetTableName(datasetId)}`
105
+ }
106
+
102
107
  /**
103
108
  * Decode a physical DuckLake table name back to a Sixb dataset id.
104
109
  */
@@ -113,7 +113,7 @@ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
113
113
  function isDatasetProducer(value: unknown): value is NonNullable<DatasetVersion["producer"]> {
114
114
  return (
115
115
  isRecord(value) &&
116
- (value.kind === "sync" || value.kind === "pipeline") &&
116
+ (value.kind === "sync" || value.kind === "pipeline" || value.kind === "ingest") &&
117
117
  optionalString(value.id) &&
118
118
  optionalString(value.runId)
119
119
  )