@sixb/ducklake 0.1.0 → 0.1.1
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 +37 -0
- package/dist/ducklake-storage.d.ts +2 -1
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +598 -43
- package/dist/index.js.map +15 -13
- package/dist/internal/dataset-row-commit.d.ts +6 -0
- package/dist/internal/dataset-row-commit.d.ts.map +1 -1
- package/dist/internal/dataset-row-merge.d.ts +16 -0
- package/dist/internal/dataset-row-merge.d.ts.map +1 -0
- package/dist/internal/ducklake-catalog-schema.d.ts +4 -1
- package/dist/internal/ducklake-catalog-schema.d.ts.map +1 -1
- package/dist/internal/ducklake-current-dataset-definitions.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-catalog.d.ts +1 -0
- package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
- package/dist/internal/ducklake-merge-session.d.ts +21 -0
- package/dist/internal/ducklake-merge-session.d.ts.map +1 -0
- package/dist/internal/ducklake-snapshot-reader.d.ts +1 -0
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-write-coordinator.d.ts +5 -1
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
- package/dist/internal/ducklake-write-session.d.ts.map +1 -1
- package/dist/internal/schema.d.ts +2 -0
- package/dist/internal/schema.d.ts.map +1 -1
- package/dist/internal/versions.d.ts +1 -0
- package/dist/internal/versions.d.ts.map +1 -1
- package/package.json +7 -4
- package/src/ducklake-storage.ts +6 -0
- package/src/internal/dataset-row-commit.ts +95 -2
- package/src/internal/dataset-row-merge.ts +268 -0
- package/src/internal/ducklake-catalog-schema.ts +90 -1
- package/src/internal/ducklake-current-dataset-definitions.ts +14 -5
- package/src/internal/ducklake-dataset-catalog.ts +43 -9
- package/src/internal/ducklake-merge-session.ts +187 -0
- package/src/internal/ducklake-snapshot-reader.ts +9 -2
- package/src/internal/ducklake-write-coordinator.ts +160 -20
- package/src/internal/ducklake-write-session.ts +28 -5
- package/src/internal/schema.ts +7 -0
- package/src/internal/versions.ts +17 -1
|
@@ -60,6 +60,7 @@ export interface DuckLakeVersionSummary {
|
|
|
60
60
|
readonly datasetId: string
|
|
61
61
|
readonly versionId: string
|
|
62
62
|
readonly rowCount?: number
|
|
63
|
+
readonly validatedPrimaryKeyColumns?: readonly string[]
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/** One snapshot row from the shared catalog scan, before per-dataset filtering. */
|
|
@@ -444,6 +445,9 @@ export class DuckLakeSnapshotReader {
|
|
|
444
445
|
datasetId: tableRef.datasetId,
|
|
445
446
|
versionId: toVersionId(row.snapshotId),
|
|
446
447
|
...(row.metadata?.rowCount !== undefined ? { rowCount: row.metadata.rowCount } : {}),
|
|
448
|
+
...(row.metadata?.validatedPrimaryKeyColumns !== undefined
|
|
449
|
+
? { validatedPrimaryKeyColumns: row.metadata.validatedPrimaryKeyColumns }
|
|
450
|
+
: {}),
|
|
447
451
|
}
|
|
448
452
|
}
|
|
449
453
|
|
|
@@ -549,7 +553,10 @@ export class DuckLakeSnapshotReader {
|
|
|
549
553
|
return null
|
|
550
554
|
}
|
|
551
555
|
|
|
552
|
-
if (
|
|
556
|
+
if (
|
|
557
|
+
!options.includeParent ||
|
|
558
|
+
(row.mode !== "append" && row.mode !== "merge" && row.mode !== "schema")
|
|
559
|
+
) {
|
|
553
560
|
return row
|
|
554
561
|
}
|
|
555
562
|
|
|
@@ -807,7 +814,7 @@ export class DuckLakeSnapshotReader {
|
|
|
807
814
|
// Parent ids track versions that reuse the previous dataset state.
|
|
808
815
|
// Snapshot versions replace the rows, so time travel stands on the snapshot id.
|
|
809
816
|
const parentSnapshotId =
|
|
810
|
-
snapshot.mode === "append" || snapshot.mode === "schema"
|
|
817
|
+
snapshot.mode === "append" || snapshot.mode === "merge" || snapshot.mode === "schema"
|
|
811
818
|
? snapshots[index + 1]?.snapshotId
|
|
812
819
|
: undefined
|
|
813
820
|
return parentSnapshotId === undefined ? snapshot : { ...snapshot, parentSnapshotId }
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
2
|
import type { DatasetDefinition } from "@sixb/core"
|
|
3
3
|
import type {
|
|
4
|
+
BeginDatasetMergeInput,
|
|
4
5
|
BeginDatasetWriteInput,
|
|
6
|
+
DatasetMergeCommitResult,
|
|
5
7
|
DatasetVersion,
|
|
6
8
|
DatasetWriteCommitResult,
|
|
7
9
|
DatasetWriteMode,
|
|
10
|
+
LakeMergeSession,
|
|
8
11
|
LakeWriteSession,
|
|
9
12
|
} from "@sixb/core/lake-storage"
|
|
10
|
-
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
13
|
+
import { getDatasetPrimaryKeyColumns, LakeStorageError } from "@sixb/core/lake-storage"
|
|
11
14
|
import type { DuckLakeStorageOptions } from "../types"
|
|
12
15
|
import { localCatalogCoordinationKey } from "./catalog-key"
|
|
13
16
|
import {
|
|
@@ -16,10 +19,12 @@ import {
|
|
|
16
19
|
assertDatasetWriteMode,
|
|
17
20
|
type CommitRowCount,
|
|
18
21
|
} from "./dataset-row-commit"
|
|
22
|
+
import { applyDatasetMergeFromRelation } from "./dataset-row-merge"
|
|
19
23
|
import { getBigIntLike } from "./duckdb-row"
|
|
20
24
|
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
21
25
|
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
22
26
|
import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
|
|
27
|
+
import { createDuckLakeMergeSession, type DuckLakeCommitMergeInput } from "./ducklake-merge-session"
|
|
23
28
|
import type { DuckLakeSnapshotReader, DuckLakeVersionSummary } from "./ducklake-snapshot-reader"
|
|
24
29
|
import { createDuckLakeWriteSession, type DuckLakeCommitWriteInput } from "./ducklake-write-session"
|
|
25
30
|
import { duckLakeAlias, duckLakeMetadataTableName, quoteIdentifier, quoteSqlString } from "./sql"
|
|
@@ -38,12 +43,22 @@ export interface DuckLakeCommitDatasetVersionInput {
|
|
|
38
43
|
): Promise<ApplyDatasetRowsResult>
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
interface
|
|
46
|
+
interface DuckLakeCommitVersionOutcomeInput
|
|
47
|
+
extends Omit<DuckLakeCommitDatasetVersionInput, "expectedLatestVersionId" | "mode"> {
|
|
48
|
+
readonly mode: DatasetWriteMode | "merge"
|
|
49
|
+
readonly expectedLatestVersionId?: string | null
|
|
50
|
+
readonly allowInitialNoOp?: boolean
|
|
51
|
+
readonly disableRetries?: boolean
|
|
52
|
+
readonly optimisticOperation?: "commit" | "merge commit"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DuckLakeCommitVersionOutcomeRuntimeInput extends DuckLakeCommitVersionOutcomeInput {
|
|
42
56
|
readonly runtime: DuckDbQueryRuntime
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
export interface DuckLakeApplyChangesContext {
|
|
46
60
|
readonly previousRowCount?: number
|
|
61
|
+
readonly validatedPrimaryKeyColumns?: readonly string[]
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
/**
|
|
@@ -90,6 +105,40 @@ export class DuckLakeWriteCoordinator {
|
|
|
90
105
|
})
|
|
91
106
|
}
|
|
92
107
|
|
|
108
|
+
async beginMerge(input: BeginDatasetMergeInput): Promise<LakeMergeSession> {
|
|
109
|
+
this.connections.assertOpen()
|
|
110
|
+
|
|
111
|
+
const definition = await this.datasets.getDataset(input.dataset.id)
|
|
112
|
+
if (!definition) {
|
|
113
|
+
throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`)
|
|
114
|
+
}
|
|
115
|
+
if (getDatasetPrimaryKeyColumns(definition) === null) {
|
|
116
|
+
throw new LakeStorageError(
|
|
117
|
+
`[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.datasets.assertSchema(definition)
|
|
122
|
+
const latestVersion = await this.connections.withAttachedRuntime((runtime) =>
|
|
123
|
+
this.snapshots.getLatestVersionForDefinition(runtime, definition)
|
|
124
|
+
)
|
|
125
|
+
if (
|
|
126
|
+
input.expectedLatestVersionId !== undefined &&
|
|
127
|
+
latestVersion?.versionId !== input.expectedLatestVersionId
|
|
128
|
+
) {
|
|
129
|
+
throw new LakeStorageError(
|
|
130
|
+
`[SixbDuckLake] Optimistic merge start failed for dataset '${definition.id}': expected latest version '${input.expectedLatestVersionId}', found '${latestVersion?.versionId ?? "none"}'.`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
const runtime = await this.connections.stagingRuntime()
|
|
134
|
+
return createDuckLakeMergeSession({
|
|
135
|
+
commitMerge: (options) => this.commitMerge(options),
|
|
136
|
+
runtime,
|
|
137
|
+
merge: { ...input, dataset: definition },
|
|
138
|
+
baseVersionId: latestVersion?.versionId ?? null,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
93
142
|
private async commitWrite(input: DuckLakeCommitWriteInput): Promise<DatasetWriteCommitResult> {
|
|
94
143
|
const definition = await this.datasets.getDataset(input.write.dataset.id)
|
|
95
144
|
if (!definition) {
|
|
@@ -115,6 +164,50 @@ export class DuckLakeWriteCoordinator {
|
|
|
115
164
|
})
|
|
116
165
|
}
|
|
117
166
|
|
|
167
|
+
private async commitMerge(input: DuckLakeCommitMergeInput): Promise<DatasetMergeCommitResult> {
|
|
168
|
+
const definition = await this.datasets.getDataset(input.merge.dataset.id)
|
|
169
|
+
if (!definition) {
|
|
170
|
+
throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.merge.dataset.id}'.`)
|
|
171
|
+
}
|
|
172
|
+
if (getDatasetPrimaryKeyColumns(definition) === null) {
|
|
173
|
+
throw new LakeStorageError(
|
|
174
|
+
`[SixbDuckLake] Dataset '${definition.id}' must define a primaryKey before it can be merged.`
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
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
|
+
},
|
|
207
|
+
})
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
118
211
|
async commitDatasetVersion(
|
|
119
212
|
input: DuckLakeCommitDatasetVersionInput
|
|
120
213
|
): Promise<DatasetWriteCommitResult> {
|
|
@@ -135,6 +228,26 @@ export class DuckLakeWriteCoordinator {
|
|
|
135
228
|
runtime: DuckDbQueryRuntime,
|
|
136
229
|
input: DuckLakeCommitDatasetVersionInput
|
|
137
230
|
): Promise<DatasetWriteCommitResult> {
|
|
231
|
+
const result = await this.commitVersionOutcomeOnExclusiveRuntime(runtime, {
|
|
232
|
+
...input,
|
|
233
|
+
// DuckLake has no primary-key constraint. Do not let its automatic retry replay a keyed
|
|
234
|
+
// append after the collision checks ran against an older transaction snapshot.
|
|
235
|
+
disableRetries:
|
|
236
|
+
input.mode === "append" && getDatasetPrimaryKeyColumns(input.dataset) !== null,
|
|
237
|
+
})
|
|
238
|
+
if (result.version === null) {
|
|
239
|
+
throw new LakeStorageError(
|
|
240
|
+
`[SixbDuckLake] No DuckLake changes were committed for dataset '${input.dataset.id}', and no previous version exists.`
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { ...result.version, outcome: result.outcome }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async commitVersionOutcomeOnExclusiveRuntime(
|
|
248
|
+
runtime: DuckDbQueryRuntime,
|
|
249
|
+
input: DuckLakeCommitVersionOutcomeInput
|
|
250
|
+
): Promise<DatasetMergeCommitResult> {
|
|
138
251
|
const runtimeInput = { ...input, runtime }
|
|
139
252
|
return this.withGuardedRetrySetting(runtimeInput, () =>
|
|
140
253
|
this.commitDatasetVersionUnlocked(runtimeInput)
|
|
@@ -142,16 +255,16 @@ export class DuckLakeWriteCoordinator {
|
|
|
142
255
|
}
|
|
143
256
|
|
|
144
257
|
private async withGuardedRetrySetting<T>(
|
|
145
|
-
input:
|
|
258
|
+
input: DuckLakeCommitVersionOutcomeRuntimeInput,
|
|
146
259
|
run: () => Promise<T>
|
|
147
260
|
): Promise<T> {
|
|
148
|
-
if (input.expectedLatestVersionId === undefined) {
|
|
261
|
+
if (input.expectedLatestVersionId === undefined && !input.disableRetries) {
|
|
149
262
|
return run()
|
|
150
263
|
}
|
|
151
264
|
|
|
152
|
-
// DuckLake can retry transactions internally.
|
|
153
|
-
//
|
|
154
|
-
// commit and
|
|
265
|
+
// DuckLake can retry transactions internally. Optimistic guards and keyed append constraint
|
|
266
|
+
// checks must not be replayed against a different snapshot, so disable retries for this
|
|
267
|
+
// exclusive commit and restore the runtime setting before releasing the queue.
|
|
155
268
|
await input.runtime.run("SET ducklake_max_retry_count = 0")
|
|
156
269
|
let outcome:
|
|
157
270
|
| { readonly kind: "success"; readonly value: T }
|
|
@@ -185,8 +298,8 @@ export class DuckLakeWriteCoordinator {
|
|
|
185
298
|
}
|
|
186
299
|
|
|
187
300
|
private async commitDatasetVersionUnlocked(
|
|
188
|
-
input:
|
|
189
|
-
): Promise<
|
|
301
|
+
input: DuckLakeCommitVersionOutcomeRuntimeInput
|
|
302
|
+
): Promise<DatasetMergeCommitResult> {
|
|
190
303
|
// Capture the write connection's last committed DuckLake snapshot before
|
|
191
304
|
// and after COMMIT. The commitId in DuckLake commit_extra_info proves
|
|
192
305
|
// which snapshot belongs to this transaction.
|
|
@@ -204,11 +317,13 @@ export class DuckLakeWriteCoordinator {
|
|
|
204
317
|
datasetId: input.dataset.id,
|
|
205
318
|
expectedLatestVersionId: input.expectedLatestVersionId,
|
|
206
319
|
actualLatestVersionId: latestVersion?.versionId ?? null,
|
|
320
|
+
operation: input.optimisticOperation ?? "commit",
|
|
207
321
|
})
|
|
208
322
|
}
|
|
209
323
|
|
|
210
324
|
const changeResult = await input.applyChanges(input.runtime, {
|
|
211
325
|
previousRowCount: latestVersion?.rowCount,
|
|
326
|
+
validatedPrimaryKeyColumns: latestVersion?.validatedPrimaryKeyColumns,
|
|
212
327
|
})
|
|
213
328
|
const commitId = randomUUID()
|
|
214
329
|
await this.setCommitMetadata(
|
|
@@ -241,13 +356,13 @@ export class DuckLakeWriteCoordinator {
|
|
|
241
356
|
}
|
|
242
357
|
|
|
243
358
|
private async versionForCommittedWrite(
|
|
244
|
-
input:
|
|
359
|
+
input: DuckLakeCommitVersionOutcomeRuntimeInput & {
|
|
245
360
|
readonly commitId: string
|
|
246
361
|
readonly previousWriteSnapshotId: string | null
|
|
247
362
|
readonly committedWriteSnapshotId: string | null
|
|
248
363
|
readonly changeResult: ApplyDatasetRowsResult
|
|
249
364
|
}
|
|
250
|
-
): Promise<
|
|
365
|
+
): Promise<DatasetMergeCommitResult> {
|
|
251
366
|
// Hydrate the committed version on the same exclusive runtime. That keeps
|
|
252
367
|
// snapshot matching and row-count fallback inside the same serialized
|
|
253
368
|
// connection state that just committed.
|
|
@@ -266,7 +381,7 @@ export class DuckLakeWriteCoordinator {
|
|
|
266
381
|
ownSnapshotId
|
|
267
382
|
)
|
|
268
383
|
if (version) {
|
|
269
|
-
return {
|
|
384
|
+
return { outcome: "created", version }
|
|
270
385
|
}
|
|
271
386
|
|
|
272
387
|
throw new LakeStorageError(
|
|
@@ -275,9 +390,22 @@ export class DuckLakeWriteCoordinator {
|
|
|
275
390
|
}
|
|
276
391
|
|
|
277
392
|
if (!input.changeResult.dataChangeExpected) {
|
|
393
|
+
const version = await this.versionForNoOpCommit(
|
|
394
|
+
input.runtime,
|
|
395
|
+
input.dataset,
|
|
396
|
+
input.allowInitialNoOp ?? false
|
|
397
|
+
)
|
|
398
|
+
if (input.expectedLatestVersionId !== undefined) {
|
|
399
|
+
this.assertExpectedLatestVersion({
|
|
400
|
+
datasetId: input.dataset.id,
|
|
401
|
+
expectedLatestVersionId: input.expectedLatestVersionId,
|
|
402
|
+
actualLatestVersionId: version?.versionId ?? null,
|
|
403
|
+
operation: input.optimisticOperation ?? "commit",
|
|
404
|
+
})
|
|
405
|
+
}
|
|
278
406
|
return {
|
|
279
|
-
...(await this.versionForNoOpCommit(input.runtime, input.dataset)),
|
|
280
407
|
outcome: "unchanged",
|
|
408
|
+
version,
|
|
281
409
|
}
|
|
282
410
|
}
|
|
283
411
|
|
|
@@ -318,7 +446,7 @@ export class DuckLakeWriteCoordinator {
|
|
|
318
446
|
}
|
|
319
447
|
|
|
320
448
|
private async setCommitMetadata(
|
|
321
|
-
input:
|
|
449
|
+
input: DuckLakeCommitVersionOutcomeRuntimeInput,
|
|
322
450
|
commitId: string,
|
|
323
451
|
rowCount: CommitRowCount
|
|
324
452
|
): Promise<void> {
|
|
@@ -326,6 +454,9 @@ export class DuckLakeWriteCoordinator {
|
|
|
326
454
|
// transaction. Version id, timestamp, and data-change detection still come
|
|
327
455
|
// from DuckLake itself. commitId is only a transaction correlation token so
|
|
328
456
|
// concurrent writers do not accidentally hydrate each other's snapshots.
|
|
457
|
+
// Every keyed data path either replaces the baseline or validates its uniqueness before this
|
|
458
|
+
// marker is persisted. Merge commits can therefore avoid repeating that full-table audit.
|
|
459
|
+
const validatedPrimaryKeyColumns = getDatasetPrimaryKeyColumns(input.dataset)
|
|
329
460
|
const metadata: SixbCommitMetadata = {
|
|
330
461
|
kind: "datasetVersion",
|
|
331
462
|
datasetId: input.dataset.id,
|
|
@@ -334,6 +465,9 @@ export class DuckLakeWriteCoordinator {
|
|
|
334
465
|
producer: input.producer ? structuredClone(input.producer) : undefined,
|
|
335
466
|
inputs: input.inputs ? structuredClone(input.inputs) : undefined,
|
|
336
467
|
...(rowCount.kind === "exact" ? { rowCount: rowCount.value } : {}),
|
|
468
|
+
...(validatedPrimaryKeyColumns !== null
|
|
469
|
+
? { validatedPrimaryKeyColumns: [...validatedPrimaryKeyColumns] }
|
|
470
|
+
: {}),
|
|
337
471
|
}
|
|
338
472
|
|
|
339
473
|
await input.runtime.run(
|
|
@@ -347,13 +481,18 @@ export class DuckLakeWriteCoordinator {
|
|
|
347
481
|
|
|
348
482
|
private async versionForNoOpCommit(
|
|
349
483
|
runtime: DuckDbQueryRuntime,
|
|
350
|
-
definition: DatasetDefinition
|
|
351
|
-
|
|
484
|
+
definition: DatasetDefinition,
|
|
485
|
+
allowInitialNoOp: boolean
|
|
486
|
+
): Promise<DatasetVersion | null> {
|
|
352
487
|
const latestVersion = await this.snapshots.getLatestVersionForDefinition(runtime, definition)
|
|
353
488
|
if (latestVersion) {
|
|
354
489
|
return latestVersion
|
|
355
490
|
}
|
|
356
491
|
|
|
492
|
+
if (allowInitialNoOp) {
|
|
493
|
+
return null
|
|
494
|
+
}
|
|
495
|
+
|
|
357
496
|
throw new LakeStorageError(
|
|
358
497
|
`[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`
|
|
359
498
|
)
|
|
@@ -369,7 +508,7 @@ export class DuckLakeWriteCoordinator {
|
|
|
369
508
|
}
|
|
370
509
|
|
|
371
510
|
private async latestVersionForCommit(
|
|
372
|
-
input:
|
|
511
|
+
input: DuckLakeCommitVersionOutcomeRuntimeInput
|
|
373
512
|
): Promise<DuckLakeVersionSummary | null> {
|
|
374
513
|
if (input.expectedLatestVersionId === undefined && input.mode !== "append") {
|
|
375
514
|
return null
|
|
@@ -451,8 +590,9 @@ export class DuckLakeWriteCoordinator {
|
|
|
451
590
|
|
|
452
591
|
private assertExpectedLatestVersion(input: {
|
|
453
592
|
readonly datasetId: string
|
|
454
|
-
readonly expectedLatestVersionId?: string
|
|
593
|
+
readonly expectedLatestVersionId?: string | null
|
|
455
594
|
readonly actualLatestVersionId: string | null
|
|
595
|
+
readonly operation: "commit" | "merge commit"
|
|
456
596
|
}): void {
|
|
457
597
|
if (input.expectedLatestVersionId === undefined) {
|
|
458
598
|
return
|
|
@@ -460,14 +600,14 @@ export class DuckLakeWriteCoordinator {
|
|
|
460
600
|
|
|
461
601
|
if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
|
|
462
602
|
throw new LakeStorageError(
|
|
463
|
-
`[SixbDuckLake] Optimistic
|
|
603
|
+
`[SixbDuckLake] Optimistic ${input.operation} failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId ?? "none"}', found '${input.actualLatestVersionId ?? "none"}'.`
|
|
464
604
|
)
|
|
465
605
|
}
|
|
466
606
|
}
|
|
467
607
|
}
|
|
468
608
|
|
|
469
609
|
function commitRowCount(
|
|
470
|
-
input: Pick<
|
|
610
|
+
input: Pick<DuckLakeCommitVersionOutcomeInput, "expectedLatestVersionId" | "mode">,
|
|
471
611
|
changeResult: ApplyDatasetRowsResult,
|
|
472
612
|
latestVersion: DuckLakeVersionSummary | null
|
|
473
613
|
): CommitRowCount {
|
|
@@ -7,7 +7,11 @@ import type {
|
|
|
7
7
|
DatasetWriteCommitResult,
|
|
8
8
|
LakeWriteSession,
|
|
9
9
|
} from "@sixb/core/lake-storage"
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
encodeDatasetPrimaryKey,
|
|
12
|
+
getDatasetPrimaryKeyColumns,
|
|
13
|
+
LakeStorageError,
|
|
14
|
+
} from "@sixb/core/lake-storage"
|
|
11
15
|
import type { DuckDbRuntime } from "./duckdb-runtime"
|
|
12
16
|
import { appendDatasetRow } from "./row-appender"
|
|
13
17
|
import { datasetSchemaToDuckDbColumnsSql } from "./schema"
|
|
@@ -61,6 +65,7 @@ class DuckLakeWriteSession implements LakeWriteSession {
|
|
|
61
65
|
private closed = false
|
|
62
66
|
private cleanedUp = false
|
|
63
67
|
private rowsWritten = 0
|
|
68
|
+
private readonly stagedPrimaryKeys = new Set<string>()
|
|
64
69
|
|
|
65
70
|
constructor(
|
|
66
71
|
private readonly commitWrite: DuckLakeCommitWrite,
|
|
@@ -76,6 +81,7 @@ class DuckLakeWriteSession implements LakeWriteSession {
|
|
|
76
81
|
// reads (pagination, APIs, SFTP, retries) never hold a runtime slot. Only
|
|
77
82
|
// appendBatchToStagingTable below briefly enters the queue.
|
|
78
83
|
let batch: DatasetRow[] = []
|
|
84
|
+
let batchPrimaryKeys = new Set<string>()
|
|
79
85
|
for await (const row of rows) {
|
|
80
86
|
const validationError = getDatasetRowValidationError(row, this.input.dataset)
|
|
81
87
|
if (validationError) {
|
|
@@ -84,20 +90,34 @@ class DuckLakeWriteSession implements LakeWriteSession {
|
|
|
84
90
|
|
|
85
91
|
// Snapshot the validated row. Callers may reuse and mutate one row object
|
|
86
92
|
// between yields, so the batch must not retain a live reference.
|
|
87
|
-
|
|
93
|
+
const clonedRow = structuredClone(row)
|
|
94
|
+
if (getDatasetPrimaryKeyColumns(this.input.dataset) !== null) {
|
|
95
|
+
const primaryKey = encodeDatasetPrimaryKey(this.input.dataset, clonedRow)
|
|
96
|
+
if (this.stagedPrimaryKeys.has(primaryKey) || batchPrimaryKeys.has(primaryKey)) {
|
|
97
|
+
throw new LakeStorageError(
|
|
98
|
+
`[SixbDuckLake] Dataset '${this.input.dataset.id}' ${this.input.mode ?? "snapshot"} source contains duplicate primary key.`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
batchPrimaryKeys.add(primaryKey)
|
|
102
|
+
}
|
|
103
|
+
batch.push(clonedRow)
|
|
88
104
|
|
|
89
105
|
if (batch.length >= WRITE_ROW_BATCH_SIZE) {
|
|
90
|
-
await this.appendBatchToStagingTable(batch)
|
|
106
|
+
await this.appendBatchToStagingTable(batch, batchPrimaryKeys)
|
|
91
107
|
batch = []
|
|
108
|
+
batchPrimaryKeys = new Set()
|
|
92
109
|
}
|
|
93
110
|
}
|
|
94
111
|
|
|
95
112
|
if (batch.length > 0) {
|
|
96
|
-
await this.appendBatchToStagingTable(batch)
|
|
113
|
+
await this.appendBatchToStagingTable(batch, batchPrimaryKeys)
|
|
97
114
|
}
|
|
98
115
|
}
|
|
99
116
|
|
|
100
|
-
private async appendBatchToStagingTable(
|
|
117
|
+
private async appendBatchToStagingTable(
|
|
118
|
+
batch: readonly DatasetRow[],
|
|
119
|
+
primaryKeys: ReadonlySet<string>
|
|
120
|
+
): Promise<void> {
|
|
101
121
|
// Synchronous callback: no awaits inside the queue slot. The appender
|
|
102
122
|
// buffers and flushes the whole batch into the staging temp table on close.
|
|
103
123
|
await this.runtime.withAppender(this.stagingTableName, (appender) => {
|
|
@@ -108,6 +128,9 @@ class DuckLakeWriteSession implements LakeWriteSession {
|
|
|
108
128
|
// Count only rows actually appended -- commit asserts rowsWritten ===
|
|
109
129
|
// sourceRowCount in the write coordinator.
|
|
110
130
|
this.rowsWritten += batch.length
|
|
131
|
+
for (const primaryKey of primaryKeys) {
|
|
132
|
+
this.stagedPrimaryKeys.add(primaryKey)
|
|
133
|
+
}
|
|
111
134
|
}
|
|
112
135
|
|
|
113
136
|
async commit(input?: CommitDatasetWriteInput): Promise<DatasetWriteCommitResult> {
|
package/src/internal/schema.ts
CHANGED
|
@@ -49,6 +49,13 @@ export function datasetSchemaToDuckDbColumnsSql(schema: DatasetSchema): string {
|
|
|
49
49
|
return schema.columns.map((column) => datasetColumnToDuckDbSql(column)).join(", ")
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Render a dataset schema for merge staging, where delete rows leave non-key values null. */
|
|
53
|
+
export function datasetSchemaToDuckDbNullableColumnsSql(schema: DatasetSchema): string {
|
|
54
|
+
return schema.columns
|
|
55
|
+
.map((column) => `${quoteIdentifier(column.name)} ${datasetColumnTypeToDuckDbSql(column.type)}`)
|
|
56
|
+
.join(", ")
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
/**
|
|
53
60
|
* Render the column list used by INSERT ... SELECT operations.
|
|
54
61
|
*/
|
package/src/internal/versions.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface SixbCommitMetadata {
|
|
|
13
13
|
readonly producer?: DatasetVersion["producer"]
|
|
14
14
|
readonly inputs?: DatasetVersion["inputs"]
|
|
15
15
|
readonly rowCount?: number
|
|
16
|
+
readonly validatedPrimaryKeyColumns?: readonly string[]
|
|
16
17
|
readonly schemaChange?: SixbSchemaChangeMetadata
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -57,15 +58,21 @@ export function parseCommitMetadata(value: unknown): SixbCommitMetadata | undefi
|
|
|
57
58
|
const mode = parsed.sixb.mode
|
|
58
59
|
const commitId = parsed.sixb.commitId
|
|
59
60
|
const rowCount = parsed.sixb.rowCount
|
|
61
|
+
const validatedPrimaryKeyColumns = parsed.sixb.validatedPrimaryKeyColumns
|
|
60
62
|
|
|
61
63
|
return {
|
|
62
64
|
kind: "datasetVersion",
|
|
63
65
|
datasetId: parsed.sixb.datasetId,
|
|
64
66
|
...(typeof commitId === "string" ? { commitId } : {}),
|
|
65
|
-
...(mode === "snapshot" || mode === "append" || mode === "
|
|
67
|
+
...(mode === "snapshot" || mode === "append" || mode === "merge" || mode === "schema"
|
|
68
|
+
? { mode }
|
|
69
|
+
: {}),
|
|
66
70
|
...(isDatasetProducer(parsed.sixb.producer) ? { producer: parsed.sixb.producer } : {}),
|
|
67
71
|
...(isDatasetVersionRefs(parsed.sixb.inputs) ? { inputs: parsed.sixb.inputs } : {}),
|
|
68
72
|
...(typeof commitId === "string" && isRowCount(rowCount) ? { rowCount } : {}),
|
|
73
|
+
...(typeof commitId === "string" && isValidatedPrimaryKeyColumns(validatedPrimaryKeyColumns)
|
|
74
|
+
? { validatedPrimaryKeyColumns }
|
|
75
|
+
: {}),
|
|
69
76
|
...(isSchemaChangeMetadata(parsed.sixb.schemaChange)
|
|
70
77
|
? { schemaChange: parsed.sixb.schemaChange }
|
|
71
78
|
: {}),
|
|
@@ -138,3 +145,12 @@ function optionalString(value: unknown): boolean {
|
|
|
138
145
|
function isRowCount(value: unknown): value is number {
|
|
139
146
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
140
147
|
}
|
|
148
|
+
|
|
149
|
+
function isValidatedPrimaryKeyColumns(value: unknown): value is readonly string[] {
|
|
150
|
+
return (
|
|
151
|
+
Array.isArray(value) &&
|
|
152
|
+
value.length > 0 &&
|
|
153
|
+
value.every((column) => typeof column === "string" && column.trim().length > 0) &&
|
|
154
|
+
new Set(value).size === value.length
|
|
155
|
+
)
|
|
156
|
+
}
|