@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.
- package/README.md +59 -4
- package/dist/ducklake-storage.d.ts +1 -0
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +1817 -511
- package/dist/index.js.map +24 -14
- package/dist/internal/dataset-row-commit.d.ts +10 -0
- package/dist/internal/dataset-row-commit.d.ts.map +1 -1
- package/dist/internal/dataset-row-merge.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-catalog.d.ts +20 -3
- package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -1
- package/dist/internal/ducklake-dataset-table-ref.d.ts +13 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -1
- package/dist/internal/ducklake-maintenance.d.ts +21 -1
- package/dist/internal/ducklake-maintenance.d.ts.map +1 -1
- package/dist/internal/ducklake-snapshot-reader.d.ts +63 -16
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-sql-executor.d.ts +9 -1
- package/dist/internal/ducklake-sql-executor.d.ts.map +1 -1
- package/dist/internal/ducklake-version-transaction.d.ts +54 -0
- package/dist/internal/ducklake-version-transaction.d.ts.map +1 -0
- package/dist/internal/ducklake-write-coordinator.d.ts +30 -3
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
- package/dist/internal/lineage-encoding.d.ts +8 -0
- package/dist/internal/lineage-encoding.d.ts.map +1 -0
- package/dist/internal/sql-lineage/analysis.d.ts +32 -0
- package/dist/internal/sql-lineage/analysis.d.ts.map +1 -0
- package/dist/internal/sql-lineage/expressions.d.ts +9 -0
- package/dist/internal/sql-lineage/expressions.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-json.d.ts +34 -0
- package/dist/internal/sql-lineage/plan-json.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts +28 -0
- package/dist/internal/sql-lineage/plan-relation.d.ts.map +1 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts +18 -0
- package/dist/internal/sql-lineage/plan-walker.d.ts.map +1 -0
- package/dist/internal/sql-lineage/rejection.d.ts +11 -0
- package/dist/internal/sql-lineage/rejection.d.ts.map +1 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts +21 -0
- package/dist/internal/sql-lineage/statement-gate.d.ts.map +1 -0
- package/dist/internal/sql-lineage/table-functions.d.ts +6 -0
- package/dist/internal/sql-lineage/table-functions.d.ts.map +1 -0
- package/dist/internal/sql-transform-relations.d.ts +20 -5
- package/dist/internal/sql-transform-relations.d.ts.map +1 -1
- package/dist/internal/versions.d.ts +10 -1
- package/dist/internal/versions.d.ts.map +1 -1
- package/dist/types.d.ts +10 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/ducklake-storage.ts +9 -2
- package/src/internal/dataset-row-commit.ts +28 -2
- package/src/internal/dataset-row-merge.ts +4 -0
- package/src/internal/ducklake-dataset-catalog.ts +162 -49
- package/src/internal/ducklake-dataset-table-ref.ts +75 -17
- package/src/internal/ducklake-maintenance.ts +110 -48
- package/src/internal/ducklake-snapshot-reader.ts +352 -304
- package/src/internal/ducklake-sql-executor.ts +214 -14
- package/src/internal/ducklake-version-transaction.ts +224 -0
- package/src/internal/ducklake-write-coordinator.ts +226 -148
- package/src/internal/lineage-encoding.ts +136 -0
- package/src/internal/sql-lineage/analysis.ts +257 -0
- package/src/internal/sql-lineage/expressions.ts +208 -0
- package/src/internal/sql-lineage/plan-json.ts +130 -0
- package/src/internal/sql-lineage/plan-relation.ts +59 -0
- package/src/internal/sql-lineage/plan-walker.ts +727 -0
- package/src/internal/sql-lineage/rejection.ts +16 -0
- package/src/internal/sql-lineage/statement-gate.ts +164 -0
- package/src/internal/sql-lineage/table-functions.ts +11 -0
- package/src/internal/sql-transform-relations.ts +90 -40
- package/src/internal/versions.ts +37 -14
- 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
|
-
|
|
18
|
+
hasDatasetProvenanceChanges,
|
|
17
19
|
LakeConcurrencyError,
|
|
18
20
|
LakeStorageError,
|
|
21
|
+
lineageAtCommit,
|
|
19
22
|
retryDatasetMergeCommit,
|
|
20
23
|
} from "@sixb/core/lake-storage"
|
|
21
|
-
import type {
|
|
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
|
-
|
|
277
|
-
return
|
|
278
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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 (
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
610
|
-
return latestVersion
|
|
611
|
-
}
|
|
612
|
-
await input.runtime.run("ROLLBACK")
|
|
617
|
+
return undefined
|
|
613
618
|
}
|
|
614
619
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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
|
+
}
|