@sixb/ducklake 0.1.3 → 0.1.5

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 {
@@ -40,7 +47,7 @@ import { parseCommitMetadata, type SixbCommitMetadata } from "./versions"
40
47
  export interface DuckLakeCommitDatasetVersionInput {
41
48
  readonly dataset: DatasetDefinition
42
49
  readonly mode: DatasetWriteMode
43
- readonly expectedLatestVersionId?: string
50
+ readonly expectedLatestVersionId?: string | null
44
51
  readonly commitMessage: string
45
52
  readonly producer?: BeginDatasetWriteInput["producer"]
46
53
  readonly inputs?: BeginDatasetWriteInput["inputs"]
@@ -55,6 +62,7 @@ interface DuckLakeCommitVersionOutcomeInput
55
62
  readonly mode: DatasetWriteMode | "merge"
56
63
  readonly expectedLatestVersionId?: string | null
57
64
  readonly allowInitialNoOp?: boolean
65
+ readonly signal?: AbortSignal
58
66
  readonly disableRetries?: boolean
59
67
  readonly optimisticOperation?: "commit" | "merge commit"
60
68
  }
@@ -93,6 +101,7 @@ export class DuckLakeWriteCoordinator {
93
101
  }
94
102
 
95
103
  this.datasets.assertSchema(definition)
104
+ assertUnsequencedDatasetWrite(definition)
96
105
  const mode = input.mode ?? "snapshot"
97
106
  assertDatasetWriteMode(mode, "write")
98
107
 
@@ -133,7 +142,7 @@ export class DuckLakeWriteCoordinator {
133
142
  input.expectedLatestVersionId !== undefined &&
134
143
  latestVersion?.versionId !== input.expectedLatestVersionId
135
144
  ) {
136
- throw new LakeStorageError(
145
+ throw new LakeConcurrencyError(
137
146
  `[SixbDuckLake] Optimistic merge start failed for dataset '${definition.id}': expected latest version '${input.expectedLatestVersionId}', found '${latestVersion?.versionId ?? "none"}'.`
138
147
  )
139
148
  }
@@ -182,35 +191,43 @@ export class DuckLakeWriteCoordinator {
182
191
  )
183
192
  }
184
193
 
185
- return this.withCommitRuntime((runtime) =>
186
- this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
187
- dataset: definition,
188
- mode: "merge",
189
- expectedLatestVersionId: input.baseVersionId,
190
- allowInitialNoOp: true,
191
- disableRetries: true,
192
- optimisticOperation: "merge commit",
193
- commitMessage: input.commit?.commitMessage ?? `merge dataset ${definition.id}`,
194
- producer: input.merge.producer,
195
- inputs: input.merge.inputs,
196
- applyChanges: async (commitRuntime, context) => {
197
- const result = await applyDatasetMergeFromRelation({
198
- options: this.options,
199
- runtime: commitRuntime,
200
- dataset: definition,
201
- stagingTableName: input.stagingTableName,
202
- sequenceColumnName: input.sequenceColumnName,
203
- kindColumnName: input.kindColumnName,
204
- previousRowCount: context.previousRowCount,
205
- validatedPrimaryKeyColumns: context.validatedPrimaryKeyColumns,
206
- })
207
- if (result.sourceRowCount !== input.changesWritten) {
208
- throw new LakeStorageError(
209
- `[SixbDuckLake] Staged merge for dataset '${definition.id}' accepted ${input.changesWritten} change(s), but DuckLake saw ${result.sourceRowCount} source change(s) at commit time.`
210
- )
211
- }
212
- return result
213
- },
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
+ })
214
231
  })
215
232
  )
216
233
  }
@@ -235,6 +252,7 @@ export class DuckLakeWriteCoordinator {
235
252
  runtime: DuckDbQueryRuntime,
236
253
  input: DuckLakeCommitDatasetVersionInput
237
254
  ): Promise<DatasetWriteCommitResult> {
255
+ assertUnsequencedDatasetWrite(input.dataset)
238
256
  const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
239
257
  ...input,
240
258
  // DuckLake has no primary-key constraint. Do not let its automatic retry replay a keyed
@@ -332,7 +350,8 @@ export class DuckLakeWriteCoordinator {
332
350
  previousRowCount: latestVersion?.rowCount,
333
351
  validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns,
334
352
  })
335
- await this.ensureInitialNoOpHasSnapshot(input, changeResult, latestVersion)
353
+ input.signal?.throwIfAborted()
354
+ await this.ensureNoOpHasSnapshot(input, changeResult, latestVersion)
336
355
  const commitId = randomUUID()
337
356
  await this.setCommitMetadata(
338
357
  input,
@@ -358,6 +377,24 @@ export class DuckLakeWriteCoordinator {
358
377
  } catch (error) {
359
378
  if (!committed) {
360
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
+ }
361
398
  }
362
399
  throw error
363
400
  }
@@ -487,26 +524,30 @@ export class DuckLakeWriteCoordinator {
487
524
  )
488
525
  }
489
526
 
490
- private async ensureInitialNoOpHasSnapshot(
527
+ private async ensureNoOpHasSnapshot(
491
528
  input: DuckLakeCommitVersionOutcomeRuntimeInput,
492
529
  changeResult: ApplyDatasetRowsResult,
493
530
  knownLatestVersion: DuckLakeVersionSummary | null
494
531
  ): Promise<void> {
495
- if (changeResult.dataChangeExpected || input.allowInitialNoOp) {
532
+ if (changeResult.dataChangeExpected) {
496
533
  return
497
534
  }
498
535
 
499
536
  const latestVersion =
500
537
  knownLatestVersion ??
501
538
  (await this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset))
502
- if (latestVersion) {
539
+ if (
540
+ latestVersion
541
+ ? input.mode === "merge" || !hasDatasetInputChanges(latestVersion.inputs, input.inputs)
542
+ : input.allowInitialNoOp
543
+ ) {
503
544
  return
504
545
  }
505
546
 
506
547
  // DuckLake 1.5.2 does not create a snapshot for a transaction whose data and metadata are
507
548
  // unchanged, and set_commit_message alone cannot force one. Reapplying the current table
508
549
  // comment is an idempotent metadata write: it leaves the visible DatasetDefinition unchanged
509
- // while giving the first empty write a real DuckLake snapshot for time travel and lineage.
550
+ // while giving initial empty writes and changed input lineage a real DuckLake snapshot.
510
551
  const table = qualifiedTableName(this.options, encodeDatasetTableName(input.dataset.id))
511
552
  const descriptionSql =
512
553
  input.dataset.description === undefined ? "NULL" : quoteSqlString(input.dataset.description)
@@ -633,7 +674,7 @@ export class DuckLakeWriteCoordinator {
633
674
  }
634
675
 
635
676
  if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
636
- throw new LakeStorageError(
677
+ throw new LakeConcurrencyError(
637
678
  `[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`
638
679
  )
639
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
  )