@sixb/ducklake 0.0.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/LICENSE +21 -0
- package/README.md +490 -0
- package/dist/ducklake-storage.d.ts +42 -0
- package/dist/ducklake-storage.d.ts.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3112 -0
- package/dist/index.js.map +34 -0
- package/dist/internal/catalog-key.d.ts +11 -0
- package/dist/internal/catalog-key.d.ts.map +1 -0
- package/dist/internal/dataset-row-commit.d.ts +26 -0
- package/dist/internal/dataset-row-commit.d.ts.map +1 -0
- package/dist/internal/duckdb-column-types.d.ts +45 -0
- package/dist/internal/duckdb-column-types.d.ts.map +1 -0
- package/dist/internal/duckdb-decimal.d.ts +6 -0
- package/dist/internal/duckdb-decimal.d.ts.map +1 -0
- package/dist/internal/duckdb-row.d.ts +8 -0
- package/dist/internal/duckdb-row.d.ts.map +1 -0
- package/dist/internal/duckdb-runtime.d.ts +63 -0
- package/dist/internal/duckdb-runtime.d.ts.map +1 -0
- package/dist/internal/duckdb-value-converter.d.ts +7 -0
- package/dist/internal/duckdb-value-converter.d.ts.map +1 -0
- package/dist/internal/ducklake-catalog-schema.d.ts +12 -0
- package/dist/internal/ducklake-catalog-schema.d.ts.map +1 -0
- package/dist/internal/ducklake-connection-manager.d.ts +67 -0
- package/dist/internal/ducklake-connection-manager.d.ts.map +1 -0
- package/dist/internal/ducklake-current-dataset-definitions.d.ts +10 -0
- package/dist/internal/ducklake-current-dataset-definitions.d.ts.map +1 -0
- package/dist/internal/ducklake-dataset-catalog.d.ts +33 -0
- package/dist/internal/ducklake-dataset-catalog.d.ts.map +1 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts +9 -0
- package/dist/internal/ducklake-dataset-table-ref.d.ts.map +1 -0
- package/dist/internal/ducklake-maintenance.d.ts +9 -0
- package/dist/internal/ducklake-maintenance.d.ts.map +1 -0
- package/dist/internal/ducklake-row-reader.d.ts +24 -0
- package/dist/internal/ducklake-row-reader.d.ts.map +1 -0
- package/dist/internal/ducklake-snapshot-reader.d.ts +73 -0
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -0
- package/dist/internal/ducklake-sql-executor.d.ts +33 -0
- package/dist/internal/ducklake-sql-executor.d.ts.map +1 -0
- package/dist/internal/ducklake-write-coordinator.d.ts +53 -0
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -0
- package/dist/internal/ducklake-write-session.d.ts +17 -0
- package/dist/internal/ducklake-write-session.d.ts.map +1 -0
- package/dist/internal/names.d.ts +13 -0
- package/dist/internal/names.d.ts.map +1 -0
- package/dist/internal/row-appender.d.ts +7 -0
- package/dist/internal/row-appender.d.ts.map +1 -0
- package/dist/internal/schema.d.ts +35 -0
- package/dist/internal/schema.d.ts.map +1 -0
- package/dist/internal/sql-transform-relations.d.ts +13 -0
- package/dist/internal/sql-transform-relations.d.ts.map +1 -0
- package/dist/internal/sql.d.ts +62 -0
- package/dist/internal/sql.d.ts.map +1 -0
- package/dist/internal/versions.d.ts +22 -0
- package/dist/internal/versions.d.ts.map +1 -0
- package/dist/tsconfig.json +5 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/ducklake-storage.ts +130 -0
- package/src/index.ts +14 -0
- package/src/internal/catalog-key.ts +28 -0
- package/src/internal/dataset-row-commit.ts +128 -0
- package/src/internal/duckdb-column-types.ts +68 -0
- package/src/internal/duckdb-decimal.ts +36 -0
- package/src/internal/duckdb-row.ts +83 -0
- package/src/internal/duckdb-runtime.ts +347 -0
- package/src/internal/duckdb-value-converter.ts +27 -0
- package/src/internal/ducklake-catalog-schema.ts +116 -0
- package/src/internal/ducklake-connection-manager.ts +510 -0
- package/src/internal/ducklake-current-dataset-definitions.ts +228 -0
- package/src/internal/ducklake-dataset-catalog.ts +363 -0
- package/src/internal/ducklake-dataset-table-ref.ts +35 -0
- package/src/internal/ducklake-maintenance.ts +116 -0
- package/src/internal/ducklake-row-reader.ts +143 -0
- package/src/internal/ducklake-snapshot-reader.ts +826 -0
- package/src/internal/ducklake-sql-executor.ts +334 -0
- package/src/internal/ducklake-write-coordinator.ts +532 -0
- package/src/internal/ducklake-write-session.ts +170 -0
- package/src/internal/names.ts +111 -0
- package/src/internal/row-appender.ts +73 -0
- package/src/internal/schema.ts +149 -0
- package/src/internal/sql-transform-relations.ts +99 -0
- package/src/internal/sql.ts +440 -0
- package/src/internal/versions.ts +140 -0
- package/src/types.ts +159 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { DatasetDefinition } from "@sixb/core"
|
|
3
|
+
import type {
|
|
4
|
+
BeginDatasetWriteInput,
|
|
5
|
+
DatasetVersion,
|
|
6
|
+
DatasetWriteCommitResult,
|
|
7
|
+
DatasetWriteMode,
|
|
8
|
+
LakeWriteSession,
|
|
9
|
+
} from "@sixb/core/lake-storage"
|
|
10
|
+
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
11
|
+
import type { DuckLakeStorageOptions } from "../types"
|
|
12
|
+
import { localCatalogCoordinationKey } from "./catalog-key"
|
|
13
|
+
import {
|
|
14
|
+
type ApplyDatasetRowsResult,
|
|
15
|
+
applyDatasetRowsFromRelation,
|
|
16
|
+
assertDatasetWriteMode,
|
|
17
|
+
type CommitRowCount,
|
|
18
|
+
} from "./dataset-row-commit"
|
|
19
|
+
import { getBigIntLike } from "./duckdb-row"
|
|
20
|
+
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
21
|
+
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
22
|
+
import type { DuckLakeDatasetCatalog } from "./ducklake-dataset-catalog"
|
|
23
|
+
import type { DuckLakeSnapshotReader, DuckLakeVersionSummary } from "./ducklake-snapshot-reader"
|
|
24
|
+
import { createDuckLakeWriteSession, type DuckLakeCommitWriteInput } from "./ducklake-write-session"
|
|
25
|
+
import { duckLakeAlias, duckLakeMetadataTableName, quoteIdentifier, quoteSqlString } from "./sql"
|
|
26
|
+
import { parseCommitMetadata, type SixbCommitMetadata } from "./versions"
|
|
27
|
+
|
|
28
|
+
export interface DuckLakeCommitDatasetVersionInput {
|
|
29
|
+
readonly dataset: DatasetDefinition
|
|
30
|
+
readonly mode: DatasetWriteMode
|
|
31
|
+
readonly expectedLatestVersionId?: string
|
|
32
|
+
readonly commitMessage: string
|
|
33
|
+
readonly producer?: BeginDatasetWriteInput["producer"]
|
|
34
|
+
readonly inputs?: BeginDatasetWriteInput["inputs"]
|
|
35
|
+
applyChanges(
|
|
36
|
+
runtime: DuckDbQueryRuntime,
|
|
37
|
+
context: DuckLakeApplyChangesContext
|
|
38
|
+
): Promise<ApplyDatasetRowsResult>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DuckLakeCommitDatasetVersionRuntimeInput extends DuckLakeCommitDatasetVersionInput {
|
|
42
|
+
readonly runtime: DuckDbQueryRuntime
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DuckLakeApplyChangesContext {
|
|
46
|
+
readonly previousRowCount?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Owns the durable DuckLake transaction for a staged Sixb write.
|
|
51
|
+
*
|
|
52
|
+
* Write sessions keep validation and temp staging table mechanics close to the
|
|
53
|
+
* caller. This coordinator handles everything that changes shared DuckLake
|
|
54
|
+
* state: attachment refresh, optimistic guards, row replacement/append,
|
|
55
|
+
* commit_extra_info, no-op detection, and DatasetVersion hydration.
|
|
56
|
+
*/
|
|
57
|
+
export class DuckLakeWriteCoordinator {
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly options: DuckLakeStorageOptions,
|
|
60
|
+
private readonly connections: DuckLakeConnectionManager,
|
|
61
|
+
private readonly datasets: DuckLakeDatasetCatalog,
|
|
62
|
+
private readonly snapshots: DuckLakeSnapshotReader
|
|
63
|
+
) {}
|
|
64
|
+
|
|
65
|
+
async beginWrite(input: BeginDatasetWriteInput): Promise<LakeWriteSession> {
|
|
66
|
+
this.connections.assertOpen()
|
|
67
|
+
|
|
68
|
+
const definition = await this.datasets.getDataset(input.dataset.id)
|
|
69
|
+
if (!definition) {
|
|
70
|
+
throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.dataset.id}'.`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.datasets.assertSchema(definition)
|
|
74
|
+
const mode = input.mode ?? "snapshot"
|
|
75
|
+
assertDatasetWriteMode(mode, "write")
|
|
76
|
+
|
|
77
|
+
// Staging tables are connection-scoped in DuckDB. In the single-runtime
|
|
78
|
+
// model every active write session creates a uniquely named temp table on
|
|
79
|
+
// the shared runtime; the durable commit later runs on that same
|
|
80
|
+
// connection under an exclusive queue slot.
|
|
81
|
+
const runtime = await this.connections.stagingRuntime()
|
|
82
|
+
return createDuckLakeWriteSession({
|
|
83
|
+
commitWrite: (options) => this.commitWrite(options),
|
|
84
|
+
runtime,
|
|
85
|
+
write: {
|
|
86
|
+
...input,
|
|
87
|
+
dataset: definition,
|
|
88
|
+
mode,
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private async commitWrite(input: DuckLakeCommitWriteInput): Promise<DatasetWriteCommitResult> {
|
|
94
|
+
const definition = await this.datasets.getDataset(input.write.dataset.id)
|
|
95
|
+
if (!definition) {
|
|
96
|
+
throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.write.dataset.id}'.`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const mode = input.write.mode ?? "snapshot"
|
|
100
|
+
assertDatasetWriteMode(mode, "write")
|
|
101
|
+
|
|
102
|
+
return this.commitDatasetVersion({
|
|
103
|
+
dataset: definition,
|
|
104
|
+
mode,
|
|
105
|
+
expectedLatestVersionId: input.commit?.expectedLatestVersionId,
|
|
106
|
+
commitMessage: input.commit?.commitMessage ?? `write dataset ${input.write.dataset.id}`,
|
|
107
|
+
producer: input.write.producer,
|
|
108
|
+
inputs: input.write.inputs,
|
|
109
|
+
applyChanges: (runtime, context) =>
|
|
110
|
+
this.applyStagedRows({
|
|
111
|
+
...input,
|
|
112
|
+
runtime,
|
|
113
|
+
previousRowCount: context.previousRowCount,
|
|
114
|
+
}),
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async commitDatasetVersion(
|
|
119
|
+
input: DuckLakeCommitDatasetVersionInput
|
|
120
|
+
): Promise<DatasetWriteCommitResult> {
|
|
121
|
+
return this.withCommitRuntime((runtime) =>
|
|
122
|
+
this.commitDatasetVersionOnExclusiveRuntime(runtime, input)
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async withCommitRuntime<T>(run: (runtime: DuckDbQueryRuntime) => Promise<T>): Promise<T> {
|
|
127
|
+
return withCatalogCommitLock(this.options, async () => {
|
|
128
|
+
// `withExclusiveAttached` refreshes local catalogs before the exclusive
|
|
129
|
+
// slot, then holds the local attachment boundary through the commit.
|
|
130
|
+
return this.connections.withExclusiveAttached(run)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async commitDatasetVersionOnExclusiveRuntime(
|
|
135
|
+
runtime: DuckDbQueryRuntime,
|
|
136
|
+
input: DuckLakeCommitDatasetVersionInput
|
|
137
|
+
): Promise<DatasetWriteCommitResult> {
|
|
138
|
+
const runtimeInput = { ...input, runtime }
|
|
139
|
+
return this.withGuardedRetrySetting(runtimeInput, () =>
|
|
140
|
+
this.commitDatasetVersionUnlocked(runtimeInput)
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async withGuardedRetrySetting<T>(
|
|
145
|
+
input: DuckLakeCommitDatasetVersionRuntimeInput,
|
|
146
|
+
run: () => Promise<T>
|
|
147
|
+
): Promise<T> {
|
|
148
|
+
if (input.expectedLatestVersionId === undefined) {
|
|
149
|
+
return run()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// DuckLake can retry transactions internally. Guarded Sixb commits need a
|
|
153
|
+
// strict compare-and-swap check, so disable retries only for this exclusive
|
|
154
|
+
// commit and always restore the runtime setting before releasing the queue.
|
|
155
|
+
await input.runtime.run("SET ducklake_max_retry_count = 0")
|
|
156
|
+
let outcome:
|
|
157
|
+
| { readonly kind: "success"; readonly value: T }
|
|
158
|
+
| {
|
|
159
|
+
readonly kind: "error"
|
|
160
|
+
readonly error: unknown
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
outcome = { kind: "success", value: await run() }
|
|
165
|
+
} catch (error) {
|
|
166
|
+
outcome = { kind: "error", error }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await input.runtime.run("RESET ducklake_max_retry_count")
|
|
171
|
+
} catch {
|
|
172
|
+
// The retry count is session state on a shared, long-lived connection.
|
|
173
|
+
// If it cannot be restored, recycle the connection so a leftover value of
|
|
174
|
+
// 0 cannot silently disable DuckLake auto-retry for a later unguarded
|
|
175
|
+
// commit. The commit already succeeded or failed durably, so this cleanup
|
|
176
|
+
// must never change the outcome reported to the caller.
|
|
177
|
+
this.connections.poisonRuntime()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (outcome.kind === "error") {
|
|
181
|
+
throw outcome.error
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return outcome.value
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async commitDatasetVersionUnlocked(
|
|
188
|
+
input: DuckLakeCommitDatasetVersionRuntimeInput
|
|
189
|
+
): Promise<DatasetWriteCommitResult> {
|
|
190
|
+
// Capture the write connection's last committed DuckLake snapshot before
|
|
191
|
+
// and after COMMIT. The commitId in DuckLake commit_extra_info proves
|
|
192
|
+
// which snapshot belongs to this transaction.
|
|
193
|
+
const previousWriteSnapshotId = await this.lastCommittedSnapshotId(input.runtime)
|
|
194
|
+
|
|
195
|
+
await input.runtime.run("BEGIN TRANSACTION")
|
|
196
|
+
let committed = false
|
|
197
|
+
try {
|
|
198
|
+
// Guarded Sixb commits need compare-and-swap semantics. DuckLake can
|
|
199
|
+
// automatically retry non-conflicting commits, so guarded transactions
|
|
200
|
+
// disable retries and re-check the dataset head after BEGIN.
|
|
201
|
+
const latestVersion = await this.latestVersionForCommit(input)
|
|
202
|
+
if (input.expectedLatestVersionId !== undefined) {
|
|
203
|
+
this.assertExpectedLatestVersion({
|
|
204
|
+
datasetId: input.dataset.id,
|
|
205
|
+
expectedLatestVersionId: input.expectedLatestVersionId,
|
|
206
|
+
actualLatestVersionId: latestVersion?.versionId ?? null,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const changeResult = await input.applyChanges(input.runtime, {
|
|
211
|
+
previousRowCount: latestVersion?.rowCount,
|
|
212
|
+
})
|
|
213
|
+
const commitId = randomUUID()
|
|
214
|
+
await this.setCommitMetadata(
|
|
215
|
+
input,
|
|
216
|
+
commitId,
|
|
217
|
+
commitRowCount(input, changeResult, latestVersion)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await input.runtime.run("COMMIT")
|
|
221
|
+
committed = true
|
|
222
|
+
|
|
223
|
+
const committedWriteSnapshotId = await this.lastCommittedSnapshotId(input.runtime)
|
|
224
|
+
try {
|
|
225
|
+
return await this.versionForCommittedWrite({
|
|
226
|
+
...input,
|
|
227
|
+
commitId,
|
|
228
|
+
previousWriteSnapshotId,
|
|
229
|
+
committedWriteSnapshotId,
|
|
230
|
+
changeResult,
|
|
231
|
+
})
|
|
232
|
+
} finally {
|
|
233
|
+
await this.connections.detachLocalCatalogAfterCommit(input.runtime)
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!committed) {
|
|
237
|
+
await this.rollbackTransaction(input.runtime)
|
|
238
|
+
}
|
|
239
|
+
throw error
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async versionForCommittedWrite(
|
|
244
|
+
input: DuckLakeCommitDatasetVersionRuntimeInput & {
|
|
245
|
+
readonly commitId: string
|
|
246
|
+
readonly previousWriteSnapshotId: string | null
|
|
247
|
+
readonly committedWriteSnapshotId: string | null
|
|
248
|
+
readonly changeResult: ApplyDatasetRowsResult
|
|
249
|
+
}
|
|
250
|
+
): Promise<DatasetWriteCommitResult> {
|
|
251
|
+
// Hydrate the committed version on the same exclusive runtime. That keeps
|
|
252
|
+
// snapshot matching and row-count fallback inside the same serialized
|
|
253
|
+
// connection state that just committed.
|
|
254
|
+
const ownSnapshotId = await this.findOwnSnapshotId(
|
|
255
|
+
input.runtime,
|
|
256
|
+
input.dataset.id,
|
|
257
|
+
input.commitId,
|
|
258
|
+
input.previousWriteSnapshotId,
|
|
259
|
+
input.committedWriteSnapshotId
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if (ownSnapshotId !== null) {
|
|
263
|
+
const version = await this.snapshots.getVersionForSnapshot(
|
|
264
|
+
input.runtime,
|
|
265
|
+
input.dataset,
|
|
266
|
+
ownSnapshotId
|
|
267
|
+
)
|
|
268
|
+
if (version) {
|
|
269
|
+
return { ...version, outcome: "created" }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
throw new LakeStorageError(
|
|
273
|
+
`[SixbDuckLake] DuckLake committed snapshot '${ownSnapshotId}' for dataset '${input.dataset.id}', but Sixb could not hydrate it as a dataset version.`
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!input.changeResult.dataChangeExpected) {
|
|
278
|
+
return {
|
|
279
|
+
...(await this.versionForNoOpCommit(input.runtime, input.dataset)),
|
|
280
|
+
outcome: "unchanged",
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (input.committedWriteSnapshotId === input.previousWriteSnapshotId) {
|
|
285
|
+
throw new LakeStorageError(
|
|
286
|
+
`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed without producing a snapshot for this non-empty write.`
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
throw new LakeStorageError(
|
|
291
|
+
`[SixbDuckLake] DuckLake commit for dataset '${input.dataset.id}' completed, but Sixb could not find a matching snapshot for this write.`
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private async applyStagedRows(
|
|
296
|
+
input: DuckLakeCommitWriteInput & {
|
|
297
|
+
readonly runtime: DuckDbQueryRuntime
|
|
298
|
+
readonly previousRowCount?: number
|
|
299
|
+
}
|
|
300
|
+
): Promise<ApplyDatasetRowsResult> {
|
|
301
|
+
const mode = input.write.mode ?? "snapshot"
|
|
302
|
+
const result = await applyDatasetRowsFromRelation({
|
|
303
|
+
options: this.options,
|
|
304
|
+
runtime: input.runtime,
|
|
305
|
+
dataset: input.write.dataset,
|
|
306
|
+
mode,
|
|
307
|
+
sourceRelationSql: quoteIdentifier(input.stagingTableName),
|
|
308
|
+
previousRowCount: input.previousRowCount,
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
if (result.sourceRowCount !== input.rowsWritten) {
|
|
312
|
+
throw new LakeStorageError(
|
|
313
|
+
`[SixbDuckLake] Staged write for dataset '${input.write.dataset.id}' accepted ${input.rowsWritten} row(s), but DuckLake saw ${result.sourceRowCount} source row(s) at commit time.`
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return result
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private async setCommitMetadata(
|
|
321
|
+
input: DuckLakeCommitDatasetVersionRuntimeInput,
|
|
322
|
+
commitId: string,
|
|
323
|
+
rowCount: CommitRowCount
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
// DuckLake stores this JSON on the snapshot it creates for the current
|
|
326
|
+
// transaction. Version id, timestamp, and data-change detection still come
|
|
327
|
+
// from DuckLake itself. commitId is only a transaction correlation token so
|
|
328
|
+
// concurrent writers do not accidentally hydrate each other's snapshots.
|
|
329
|
+
const metadata: SixbCommitMetadata = {
|
|
330
|
+
kind: "datasetVersion",
|
|
331
|
+
datasetId: input.dataset.id,
|
|
332
|
+
commitId,
|
|
333
|
+
mode: input.mode,
|
|
334
|
+
producer: input.producer ? structuredClone(input.producer) : undefined,
|
|
335
|
+
inputs: input.inputs ? structuredClone(input.inputs) : undefined,
|
|
336
|
+
...(rowCount.kind === "exact" ? { rowCount: rowCount.value } : {}),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
await input.runtime.run(
|
|
340
|
+
`CALL ${quoteIdentifier(duckLakeAlias(this.options))}.set_commit_message(${quoteSqlString(
|
|
341
|
+
"Sixb"
|
|
342
|
+
)}, ${quoteSqlString(input.commitMessage)}, extra_info => ${quoteSqlString(
|
|
343
|
+
JSON.stringify({ sixb: metadata })
|
|
344
|
+
)})`
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async versionForNoOpCommit(
|
|
349
|
+
runtime: DuckDbQueryRuntime,
|
|
350
|
+
definition: DatasetDefinition
|
|
351
|
+
): Promise<DatasetVersion> {
|
|
352
|
+
const latestVersion = await this.snapshots.getLatestVersionForDefinition(runtime, definition)
|
|
353
|
+
if (latestVersion) {
|
|
354
|
+
return latestVersion
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
throw new LakeStorageError(
|
|
358
|
+
`[SixbDuckLake] No DuckLake changes were committed for dataset '${definition.id}', and no previous version exists.`
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private async rollbackTransaction(runtime: DuckDbQueryRuntime): Promise<void> {
|
|
363
|
+
try {
|
|
364
|
+
await runtime.run("ROLLBACK")
|
|
365
|
+
} catch {
|
|
366
|
+
// Preserve the original write failure. DuckDB may already have closed
|
|
367
|
+
// the transaction if COMMIT itself failed.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private async latestVersionForCommit(
|
|
372
|
+
input: DuckLakeCommitDatasetVersionRuntimeInput
|
|
373
|
+
): Promise<DuckLakeVersionSummary | null> {
|
|
374
|
+
if (input.expectedLatestVersionId === undefined && input.mode !== "append") {
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async lastCommittedSnapshotId(runtime: DuckDbQueryRuntime): Promise<string | null> {
|
|
382
|
+
// DuckLake reports the last snapshot committed by this connection.
|
|
383
|
+
const [row] = await runtime.query(
|
|
384
|
+
`SELECT id FROM ${quoteIdentifier(duckLakeAlias(this.options))}.last_committed_snapshot()`
|
|
385
|
+
)
|
|
386
|
+
if (row === undefined || row.id === null || row.id === undefined) {
|
|
387
|
+
return null
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return String(getBigIntLike(row, "id"))
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private async findSnapshotIdByCommitId(
|
|
394
|
+
runtime: DuckDbQueryRuntime,
|
|
395
|
+
datasetId: string,
|
|
396
|
+
commitId: string,
|
|
397
|
+
snapshotId?: string
|
|
398
|
+
): Promise<string | null> {
|
|
399
|
+
if (snapshotId !== undefined) {
|
|
400
|
+
assertDuckLakeSnapshotId(snapshotId)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const ducklakeSnapshotChanges = duckLakeMetadataTableName(
|
|
404
|
+
this.options,
|
|
405
|
+
"ducklake_snapshot_changes"
|
|
406
|
+
)
|
|
407
|
+
const whereSql = snapshotId === undefined ? "" : `WHERE snapshot_id = ${snapshotId}`
|
|
408
|
+
const orderSql = snapshotId === undefined ? "ORDER BY snapshot_id DESC" : ""
|
|
409
|
+
const rows = await runtime.query(`
|
|
410
|
+
SELECT snapshot_id, commit_extra_info
|
|
411
|
+
FROM ${ducklakeSnapshotChanges}
|
|
412
|
+
${whereSql}
|
|
413
|
+
${orderSql}
|
|
414
|
+
`)
|
|
415
|
+
|
|
416
|
+
for (const row of rows) {
|
|
417
|
+
const metadata = parseCommitMetadata(row.commit_extra_info)
|
|
418
|
+
if (metadata?.datasetId === datasetId && metadata.commitId === commitId) {
|
|
419
|
+
return String(getBigIntLike(row, "snapshot_id"))
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return null
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private async findOwnSnapshotId(
|
|
427
|
+
runtime: DuckDbQueryRuntime,
|
|
428
|
+
datasetId: string,
|
|
429
|
+
commitId: string,
|
|
430
|
+
previousWriteSnapshotId: string | null,
|
|
431
|
+
committedWriteSnapshotId: string | null
|
|
432
|
+
): Promise<string | null> {
|
|
433
|
+
if (committedWriteSnapshotId === null || committedWriteSnapshotId === previousWriteSnapshotId) {
|
|
434
|
+
return null
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const exactMatch = await this.findSnapshotIdByCommitId(
|
|
438
|
+
runtime,
|
|
439
|
+
datasetId,
|
|
440
|
+
commitId,
|
|
441
|
+
committedWriteSnapshotId
|
|
442
|
+
)
|
|
443
|
+
if (exactMatch !== null) {
|
|
444
|
+
return exactMatch
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Defensive fallback for driver/version edge cases. The expected hot path
|
|
448
|
+
// above reads a single metadata row keyed by last_committed_snapshot().
|
|
449
|
+
return this.findSnapshotIdByCommitId(runtime, datasetId, commitId)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private assertExpectedLatestVersion(input: {
|
|
453
|
+
readonly datasetId: string
|
|
454
|
+
readonly expectedLatestVersionId?: string
|
|
455
|
+
readonly actualLatestVersionId: string | null
|
|
456
|
+
}): void {
|
|
457
|
+
if (input.expectedLatestVersionId === undefined) {
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (input.actualLatestVersionId !== input.expectedLatestVersionId) {
|
|
462
|
+
throw new LakeStorageError(
|
|
463
|
+
`[SixbDuckLake] Optimistic commit failed for dataset '${input.datasetId}': expected latest version '${input.expectedLatestVersionId}', found '${input.actualLatestVersionId ?? "none"}'.`
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function commitRowCount(
|
|
470
|
+
input: Pick<DuckLakeCommitDatasetVersionInput, "expectedLatestVersionId" | "mode">,
|
|
471
|
+
changeResult: ApplyDatasetRowsResult,
|
|
472
|
+
latestVersion: DuckLakeVersionSummary | null
|
|
473
|
+
): CommitRowCount {
|
|
474
|
+
// Unguarded append commits can be retried by DuckLake after this transaction
|
|
475
|
+
// read the previous latest version. In that case previousRowCount +
|
|
476
|
+
// sourceRowCount can underreport the committed snapshot, so omit metadata
|
|
477
|
+
// and let hydration count the exact committed snapshot instead.
|
|
478
|
+
if (input.mode === "append" && input.expectedLatestVersionId === undefined) {
|
|
479
|
+
return { kind: "unknown" }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (changeResult.resultingRowCount.kind === "exact") {
|
|
483
|
+
return changeResult.resultingRowCount
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (input.mode === "append" && latestVersion === null) {
|
|
487
|
+
return { kind: "exact", value: changeResult.sourceRowCount }
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return { kind: "unknown" }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function assertDuckLakeSnapshotId(snapshotId: string): void {
|
|
494
|
+
if (!/^\d+$/.test(snapshotId)) {
|
|
495
|
+
throw new LakeStorageError(`[SixbDuckLake] Invalid DuckLake snapshot id '${snapshotId}'.`)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Local metadata catalogs are useful for dev/test, but they do not have the
|
|
500
|
+
// same multi-connection conflict behavior as PostgreSQL. Serialize local
|
|
501
|
+
// commits within this process so a losing writer cannot hydrate another
|
|
502
|
+
// connection's snapshot during DuckLake conflict handling.
|
|
503
|
+
const localCatalogCommitLocks = new Map<string, Promise<void>>()
|
|
504
|
+
|
|
505
|
+
async function withCatalogCommitLock<T>(
|
|
506
|
+
options: DuckLakeStorageOptions,
|
|
507
|
+
run: () => Promise<T>
|
|
508
|
+
): Promise<T> {
|
|
509
|
+
const key = localCatalogCoordinationKey(options)
|
|
510
|
+
if (key === undefined) {
|
|
511
|
+
return run()
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const previous = localCatalogCommitLocks.get(key) ?? Promise.resolve()
|
|
515
|
+
const ready = previous.catch(() => {})
|
|
516
|
+
let release!: () => void
|
|
517
|
+
const next = new Promise<void>((resolve) => {
|
|
518
|
+
release = resolve
|
|
519
|
+
})
|
|
520
|
+
const current = ready.then(() => next)
|
|
521
|
+
localCatalogCommitLocks.set(key, current)
|
|
522
|
+
|
|
523
|
+
await ready
|
|
524
|
+
try {
|
|
525
|
+
return await run()
|
|
526
|
+
} finally {
|
|
527
|
+
release()
|
|
528
|
+
if (localCatalogCommitLocks.get(key) === current) {
|
|
529
|
+
localCatalogCommitLocks.delete(key)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { DatasetRow } from "@sixb/core"
|
|
3
|
+
import { getDatasetRowValidationError } from "@sixb/core"
|
|
4
|
+
import type {
|
|
5
|
+
BeginDatasetWriteInput,
|
|
6
|
+
CommitDatasetWriteInput,
|
|
7
|
+
DatasetWriteCommitResult,
|
|
8
|
+
LakeWriteSession,
|
|
9
|
+
} from "@sixb/core/lake-storage"
|
|
10
|
+
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
11
|
+
import type { DuckDbRuntime } from "./duckdb-runtime"
|
|
12
|
+
import { appendDatasetRow } from "./row-appender"
|
|
13
|
+
import { datasetSchemaToDuckDbColumnsSql } from "./schema"
|
|
14
|
+
import { quoteIdentifier } from "./sql"
|
|
15
|
+
|
|
16
|
+
export interface DuckLakeCommitWriteInput {
|
|
17
|
+
readonly write: BeginDatasetWriteInput
|
|
18
|
+
readonly commit?: CommitDatasetWriteInput
|
|
19
|
+
readonly stagingTableName: string
|
|
20
|
+
readonly rowsWritten: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type DuckLakeCommitWrite = (options: DuckLakeCommitWriteInput) => Promise<DatasetWriteCommitResult>
|
|
24
|
+
|
|
25
|
+
// Rows are staged in bounded in-memory batches so a slow source iterable never
|
|
26
|
+
// holds the DuckDB runtime queue. Only the per-batch appender flush takes a
|
|
27
|
+
// queue slot. 1000 keeps memory bounded while amortizing appender open/close.
|
|
28
|
+
const WRITE_ROW_BATCH_SIZE = 1000
|
|
29
|
+
|
|
30
|
+
interface CreateDuckLakeWriteSessionInput {
|
|
31
|
+
readonly commitWrite: DuckLakeCommitWrite
|
|
32
|
+
readonly runtime: DuckDbRuntime
|
|
33
|
+
readonly write: BeginDatasetWriteInput
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createDuckLakeWriteSession(
|
|
37
|
+
input: CreateDuckLakeWriteSessionInput
|
|
38
|
+
): Promise<LakeWriteSession> {
|
|
39
|
+
const stagingTableName = `sixb_write_${randomUUID().replaceAll("-", "")}`
|
|
40
|
+
|
|
41
|
+
// The temp table lives only on this runtime connection. That keeps
|
|
42
|
+
// partially written rows invisible until DuckLakeStorage commits them into
|
|
43
|
+
// the durable dataset table.
|
|
44
|
+
await input.runtime.run(
|
|
45
|
+
`CREATE TEMP TABLE ${quoteIdentifier(stagingTableName)} (${datasetSchemaToDuckDbColumnsSql(
|
|
46
|
+
input.write.dataset.schema
|
|
47
|
+
)})`
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return new DuckLakeWriteSession(input.commitWrite, input.runtime, input.write, stagingTableName)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Package-private LakeWriteSession implementation for DuckLakeStorage.
|
|
55
|
+
*
|
|
56
|
+
* The session owns row validation, the connection-scoped staging table, and
|
|
57
|
+
* cleanup. DuckLakeStorage owns the durable transaction because it has the
|
|
58
|
+
* dataset/version context needed to build the returned DatasetVersion.
|
|
59
|
+
*/
|
|
60
|
+
class DuckLakeWriteSession implements LakeWriteSession {
|
|
61
|
+
private closed = false
|
|
62
|
+
private cleanedUp = false
|
|
63
|
+
private rowsWritten = 0
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly commitWrite: DuckLakeCommitWrite,
|
|
67
|
+
private readonly runtime: DuckDbRuntime,
|
|
68
|
+
private readonly input: BeginDatasetWriteInput,
|
|
69
|
+
private readonly stagingTableName: string
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
async writeRows(rows: Iterable<DatasetRow> | AsyncIterable<DatasetRow>): Promise<void> {
|
|
73
|
+
this.assertOpen()
|
|
74
|
+
|
|
75
|
+
// Drain and validate the source OUTSIDE the DuckDB queue so slow external
|
|
76
|
+
// reads (pagination, APIs, SFTP, retries) never hold a runtime slot. Only
|
|
77
|
+
// appendBatchToStagingTable below briefly enters the queue.
|
|
78
|
+
let batch: DatasetRow[] = []
|
|
79
|
+
for await (const row of rows) {
|
|
80
|
+
const validationError = getDatasetRowValidationError(row, this.input.dataset)
|
|
81
|
+
if (validationError) {
|
|
82
|
+
throw new LakeStorageError(`[SixbDuckLake] ${validationError}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Snapshot the validated row. Callers may reuse and mutate one row object
|
|
86
|
+
// between yields, so the batch must not retain a live reference.
|
|
87
|
+
batch.push(structuredClone(row))
|
|
88
|
+
|
|
89
|
+
if (batch.length >= WRITE_ROW_BATCH_SIZE) {
|
|
90
|
+
await this.appendBatchToStagingTable(batch)
|
|
91
|
+
batch = []
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (batch.length > 0) {
|
|
96
|
+
await this.appendBatchToStagingTable(batch)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async appendBatchToStagingTable(batch: readonly DatasetRow[]): Promise<void> {
|
|
101
|
+
// Synchronous callback: no awaits inside the queue slot. The appender
|
|
102
|
+
// buffers and flushes the whole batch into the staging temp table on close.
|
|
103
|
+
await this.runtime.withAppender(this.stagingTableName, (appender) => {
|
|
104
|
+
for (const row of batch) {
|
|
105
|
+
appendDatasetRow(appender, this.input.dataset.schema, row)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
// Count only rows actually appended -- commit asserts rowsWritten ===
|
|
109
|
+
// sourceRowCount in the write coordinator.
|
|
110
|
+
this.rowsWritten += batch.length
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async commit(input?: CommitDatasetWriteInput): Promise<DatasetWriteCommitResult> {
|
|
114
|
+
this.assertOpen()
|
|
115
|
+
this.closed = true
|
|
116
|
+
|
|
117
|
+
let committed = false
|
|
118
|
+
try {
|
|
119
|
+
const result = await this.commitWrite({
|
|
120
|
+
write: this.input,
|
|
121
|
+
commit: input,
|
|
122
|
+
rowsWritten: this.rowsWritten,
|
|
123
|
+
stagingTableName: this.stagingTableName,
|
|
124
|
+
})
|
|
125
|
+
committed = true
|
|
126
|
+
// Even successful commits no longer need the staging table once rows
|
|
127
|
+
// have been inserted into the durable DuckLake table.
|
|
128
|
+
await this.cleanup()
|
|
129
|
+
return result
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!committed) {
|
|
132
|
+
await this.cleanup()
|
|
133
|
+
}
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async abort(): Promise<void> {
|
|
139
|
+
if (this.closed) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.closed = true
|
|
144
|
+
await this.cleanup()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private assertOpen(): void {
|
|
148
|
+
if (this.closed) {
|
|
149
|
+
throw new LakeStorageError("[SixbDuckLake] Write session is already closed.")
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async cleanup(): Promise<void> {
|
|
154
|
+
if (this.cleanedUp) {
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.cleanedUp = true
|
|
159
|
+
await this.dropStagingTable()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async dropStagingTable(): Promise<void> {
|
|
163
|
+
try {
|
|
164
|
+
await this.runtime.run(`DROP TABLE IF EXISTS ${quoteIdentifier(this.stagingTableName)}`)
|
|
165
|
+
} catch {
|
|
166
|
+
// Preserve the commit/abort failure. Temp tables are connection-scoped
|
|
167
|
+
// and will disappear when the storage runtime closes.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|