@sixb/ducklake 0.1.4 → 0.1.6
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/dist/ducklake-storage.d.ts +3 -1
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +691 -567
- package/dist/index.js.map +13 -12
- package/dist/internal/ducklake-change-reader.d.ts +13 -0
- package/dist/internal/ducklake-change-reader.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/ducklake-storage.ts +13 -0
- package/src/internal/ducklake-change-reader.ts +157 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type DatasetChanges, type ReadDatasetChangesInput } from "@sixb/core/lake-storage";
|
|
2
|
+
import type { DuckLakeStorageOptions } from "../types";
|
|
3
|
+
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager";
|
|
4
|
+
import type { DuckLakeSnapshotReader } from "./ducklake-snapshot-reader";
|
|
5
|
+
/** Compare once in DuckDB, then page only the delta without holding the connection between yields. */
|
|
6
|
+
export declare class DuckLakeChangeReader {
|
|
7
|
+
private readonly options;
|
|
8
|
+
private readonly connections;
|
|
9
|
+
private readonly snapshots;
|
|
10
|
+
constructor(options: DuckLakeStorageOptions, connections: DuckLakeConnectionManager, snapshots: DuckLakeSnapshotReader);
|
|
11
|
+
readChanges(input: ReadDatasetChangesInput): Promise<DatasetChanges | null>;
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=ducklake-change-reader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ducklake-change-reader.d.ts","sourceRoot":"","sources":["../../src/internal/ducklake-change-reader.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,uBAAuB,EAE7B,MAAM,yBAAyB,CAAA;AAChC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAA;AAGtD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,+BAA+B,CAAA;AAE9E,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAA;AAOxE,sGAAsG;AACtG,qBAAa,oBAAoB;IAE7B,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAFT,OAAO,EAAE,sBAAsB,EAC/B,WAAW,EAAE,yBAAyB,EACtC,SAAS,EAAE,sBAAsB;IAG9C,WAAW,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC;CA2FlF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sixb/ducklake",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "DuckDB/DuckLake lake storage provider for Sixb",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sixb",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"typecheck": "tsc --noEmit",
|
|
37
37
|
"test": "bun test",
|
|
38
38
|
"test:e2e": "bun run test:e2e:local && bun run test:e2e:remote",
|
|
39
|
-
"test:e2e:local": "bun test ./tests/ducklake-contract.e2e.ts ./tests/ducklake-file-refs.e2e.ts ./tests/ducklake-catalog-state.e2e.ts ./tests/ducklake-catalogs.e2e.ts ./tests/ducklake-concurrency.e2e.ts ./tests/ducklake-datasets.e2e.ts ./tests/ducklake-driver.e2e.ts ./tests/ducklake-maintenance.e2e.ts ./tests/ducklake-merges.e2e.ts ./tests/ducklake-sql-transforms.e2e.ts ./tests/ducklake-versions.e2e.ts ./tests/ducklake-writes.e2e.ts",
|
|
39
|
+
"test:e2e:local": "bun test ./tests/ducklake-changes.e2e.ts ./tests/ducklake-contract.e2e.ts ./tests/ducklake-file-refs.e2e.ts ./tests/ducklake-catalog-state.e2e.ts ./tests/ducklake-catalogs.e2e.ts ./tests/ducklake-concurrency.e2e.ts ./tests/ducklake-datasets.e2e.ts ./tests/ducklake-driver.e2e.ts ./tests/ducklake-maintenance.e2e.ts ./tests/ducklake-merges.e2e.ts ./tests/ducklake-sql-transforms.e2e.ts ./tests/ducklake-versions.e2e.ts ./tests/ducklake-writes.e2e.ts",
|
|
40
40
|
"test:e2e:remote": "bun test --preload ./tests/setup.ts ./tests/ducklake-remote.e2e.ts",
|
|
41
41
|
"prepack": "bun run build"
|
|
42
42
|
},
|
|
@@ -44,16 +44,16 @@
|
|
|
44
44
|
"@duckdb/node-api": "^1.5.2-r.1"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"@sixb/core": "^0.1.
|
|
47
|
+
"@sixb/core": "^0.1.10"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@sixb/core": "0.1.
|
|
50
|
+
"@sixb/core": "0.1.10",
|
|
51
51
|
"@types/bun": "latest",
|
|
52
52
|
"typescript": "^5.7.0"
|
|
53
53
|
},
|
|
54
54
|
"license": "MIT",
|
|
55
55
|
"engines": {
|
|
56
|
-
"bun": ">=1.
|
|
56
|
+
"bun": ">=1.4.2"
|
|
57
57
|
},
|
|
58
58
|
"publishConfig": {
|
|
59
59
|
"access": "public"
|
package/src/ducklake-storage.ts
CHANGED
|
@@ -3,11 +3,14 @@ import type {
|
|
|
3
3
|
BeginDatasetMergeInput,
|
|
4
4
|
BeginDatasetWriteInput,
|
|
5
5
|
DatasetCatalogState,
|
|
6
|
+
DatasetChanges,
|
|
6
7
|
DatasetVersion,
|
|
7
8
|
LakeMergeSession,
|
|
8
9
|
LakeWriteSession,
|
|
10
|
+
ReadDatasetChangesInput,
|
|
9
11
|
ReadDatasetRowsInput,
|
|
10
12
|
} from "@sixb/core/lake-storage"
|
|
13
|
+
import { DuckLakeChangeReader } from "./internal/ducklake-change-reader"
|
|
11
14
|
import { DuckLakeConnectionManager } from "./internal/ducklake-connection-manager"
|
|
12
15
|
import { DuckLakeDatasetCatalog } from "./internal/ducklake-dataset-catalog"
|
|
13
16
|
import { DuckLakeMaintenance } from "./internal/ducklake-maintenance"
|
|
@@ -36,6 +39,7 @@ export class DuckLakeStorage implements LakeStorageWithSql<"duckdb"> {
|
|
|
36
39
|
private readonly datasets: DuckLakeDatasetCatalog
|
|
37
40
|
private readonly maintenance: DuckLakeMaintenance
|
|
38
41
|
private readonly rows: DuckLakeRowReader
|
|
42
|
+
private readonly changes: DuckLakeChangeReader
|
|
39
43
|
private readonly snapshotReader: DuckLakeSnapshotReader
|
|
40
44
|
private readonly writes: DuckLakeWriteCoordinator
|
|
41
45
|
|
|
@@ -59,6 +63,11 @@ export class DuckLakeStorage implements LakeStorageWithSql<"duckdb"> {
|
|
|
59
63
|
this.datasets
|
|
60
64
|
)
|
|
61
65
|
this.rows = new DuckLakeRowReader(normalizedOptions, this.connections, this.snapshotReader)
|
|
66
|
+
this.changes = new DuckLakeChangeReader(
|
|
67
|
+
normalizedOptions,
|
|
68
|
+
this.connections,
|
|
69
|
+
this.snapshotReader
|
|
70
|
+
)
|
|
62
71
|
this.writes = new DuckLakeWriteCoordinator(
|
|
63
72
|
normalizedOptions,
|
|
64
73
|
this.connections,
|
|
@@ -122,6 +131,10 @@ export class DuckLakeStorage implements LakeStorageWithSql<"duckdb"> {
|
|
|
122
131
|
return this.rows.readRows(input)
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
readChanges(input: ReadDatasetChangesInput): Promise<DatasetChanges | null> {
|
|
135
|
+
return this.changes.readChanges(input)
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
async runMaintenance(options?: DuckLakeMaintenanceOptions): Promise<DuckLakeMaintenanceReport> {
|
|
126
139
|
return this.maintenance.runMaintenance(options)
|
|
127
140
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { DatasetColumnDefinition, DatasetRow } from "@sixb/core"
|
|
3
|
+
import {
|
|
4
|
+
type DatasetChanges,
|
|
5
|
+
type DatasetRowChange,
|
|
6
|
+
LakeStorageError,
|
|
7
|
+
type ReadDatasetChangesInput,
|
|
8
|
+
resolveDatasetChangeColumns,
|
|
9
|
+
} from "@sixb/core/lake-storage"
|
|
10
|
+
import type { DuckLakeStorageOptions } from "../types"
|
|
11
|
+
import { getBigIntLike } from "./duckdb-row"
|
|
12
|
+
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
13
|
+
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
14
|
+
import { resolveDatasetTableRef } from "./ducklake-dataset-table-ref"
|
|
15
|
+
import type { DuckLakeSnapshotReader } from "./ducklake-snapshot-reader"
|
|
16
|
+
import { normalizeReadValue } from "./schema"
|
|
17
|
+
import { qualifiedTableName, quoteIdentifier } from "./sql"
|
|
18
|
+
import { parseVersionId } from "./versions"
|
|
19
|
+
|
|
20
|
+
const PAGE_ROWS = 5_000
|
|
21
|
+
|
|
22
|
+
/** Compare once in DuckDB, then page only the delta without holding the connection between yields. */
|
|
23
|
+
export class DuckLakeChangeReader {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly options: DuckLakeStorageOptions,
|
|
26
|
+
private readonly connections: DuckLakeConnectionManager,
|
|
27
|
+
private readonly snapshots: DuckLakeSnapshotReader
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
async readChanges(input: ReadDatasetChangesInput): Promise<DatasetChanges | null> {
|
|
31
|
+
input.signal?.throwIfAborted()
|
|
32
|
+
const before = await this.snapshots.getVersion(input.datasetId, input.fromVersionId)
|
|
33
|
+
const after = await this.snapshots.getVersion(input.datasetId, input.toVersionId)
|
|
34
|
+
const columns = resolveDatasetChangeColumns(input, before, after)
|
|
35
|
+
if (!columns) return null
|
|
36
|
+
const lease = await this.connections.acquireAttachedRuntime()
|
|
37
|
+
const runtime = lease.runtime
|
|
38
|
+
const temporary = quoteIdentifier(`sixb_delta_${randomUUID().replaceAll("-", "")}`)
|
|
39
|
+
let retained = false
|
|
40
|
+
let created = false
|
|
41
|
+
try {
|
|
42
|
+
const ref = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
|
|
43
|
+
if (!ref) return null
|
|
44
|
+
const table = qualifiedTableName(this.options, ref.tableName)
|
|
45
|
+
const oldRelation = `${table} AT (VERSION => ${parseVersionId(input.fromVersionId)})`
|
|
46
|
+
const newRelation = `${table} AT (VERSION => ${parseVersionId(input.toVersionId)})`
|
|
47
|
+
const previousCount = await uniqueRowCount(runtime, oldRelation, input.keyColumns)
|
|
48
|
+
const nextCount = await uniqueRowCount(runtime, newRelation, input.keyColumns)
|
|
49
|
+
if (previousCount === null || nextCount === null) return null
|
|
50
|
+
input.signal?.throwIfAborted()
|
|
51
|
+
const key = quoteIdentifier(input.keyColumns[0]!)
|
|
52
|
+
const values = columns.flatMap((column, index) => [
|
|
53
|
+
`b.${quoteIdentifier(column.name)} AS b${index}`,
|
|
54
|
+
`a.${quoteIdentifier(column.name)} AS a${index}`,
|
|
55
|
+
])
|
|
56
|
+
const join = input.keyColumns
|
|
57
|
+
.map((name) => `b.${quoteIdentifier(name)} = a.${quoteIdentifier(name)}`)
|
|
58
|
+
.join(" AND ")
|
|
59
|
+
const changed = columns
|
|
60
|
+
.map(
|
|
61
|
+
(column) =>
|
|
62
|
+
`b.${quoteIdentifier(column.name)} IS DISTINCT FROM a.${quoteIdentifier(column.name)}`
|
|
63
|
+
)
|
|
64
|
+
.join(" OR ")
|
|
65
|
+
// The native change feed reflects DELETE/INSERT writes: snapshot ingestion can produce 2N
|
|
66
|
+
// physical changes for one logical edit. Compare the pinned states instead of replaying it.
|
|
67
|
+
await runtime.run(`CREATE TEMP TABLE ${temporary} AS
|
|
68
|
+
SELECT b.${key} IS NOT NULL AS before_present, a.${key} IS NOT NULL AS after_present,
|
|
69
|
+
${values.join(", ")}
|
|
70
|
+
FROM (SELECT * FROM ${oldRelation}) b
|
|
71
|
+
FULL OUTER JOIN (SELECT * FROM ${newRelation}) a ON ${join}
|
|
72
|
+
WHERE b.${key} IS NULL OR a.${key} IS NULL OR ${changed}`)
|
|
73
|
+
created = true
|
|
74
|
+
const [count] = await runtime.query(`SELECT count(*) AS count FROM ${temporary}`)
|
|
75
|
+
const changeCount = safeCount(count!, "count")
|
|
76
|
+
input.signal?.throwIfAborted()
|
|
77
|
+
let closePromise: Promise<void> | undefined
|
|
78
|
+
const close = () => {
|
|
79
|
+
closePromise ??= runtime.run(`DROP TABLE IF EXISTS ${temporary}`)
|
|
80
|
+
return closePromise
|
|
81
|
+
}
|
|
82
|
+
const changes = (async function* (): AsyncIterable<DatasetRowChange> {
|
|
83
|
+
try {
|
|
84
|
+
let cursor = -1n
|
|
85
|
+
let delivered = 0
|
|
86
|
+
while (delivered < changeCount) {
|
|
87
|
+
input.signal?.throwIfAborted()
|
|
88
|
+
if (closePromise) throw new LakeStorageError("[SixbDuckLake] Change reader is closed.")
|
|
89
|
+
const page = await runtime.query(`SELECT rowid AS position, * FROM ${temporary}
|
|
90
|
+
WHERE rowid > ${cursor} ORDER BY rowid LIMIT ${PAGE_ROWS}`)
|
|
91
|
+
if (page.length === 0)
|
|
92
|
+
throw new LakeStorageError(
|
|
93
|
+
"[SixbDuckLake] Change read ended before its declared count."
|
|
94
|
+
)
|
|
95
|
+
for (const row of page) {
|
|
96
|
+
input.signal?.throwIfAborted()
|
|
97
|
+
delivered++
|
|
98
|
+
yield {
|
|
99
|
+
before: row.before_present === true ? rowImage(row, "b", columns) : null,
|
|
100
|
+
after: row.after_present === true ? rowImage(row, "a", columns) : null,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
cursor = getBigIntLike(page[page.length - 1]!, "position")
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
await close()
|
|
107
|
+
}
|
|
108
|
+
})()
|
|
109
|
+
retained = true
|
|
110
|
+
return { fromRowCount: previousCount, toRowCount: nextCount, changeCount, changes, close }
|
|
111
|
+
} finally {
|
|
112
|
+
// The delta is a DuckDB temp table, independent of the attached DuckLake catalog. Release
|
|
113
|
+
// the local attachment lock before returning it, otherwise consumer lake operations deadlock.
|
|
114
|
+
try {
|
|
115
|
+
if (!retained && created) await runtime.run(`DROP TABLE IF EXISTS ${temporary}`)
|
|
116
|
+
} finally {
|
|
117
|
+
await lease.release()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function uniqueRowCount(
|
|
124
|
+
runtime: DuckDbQueryRuntime,
|
|
125
|
+
relation: string,
|
|
126
|
+
keys: readonly string[]
|
|
127
|
+
): Promise<number | null> {
|
|
128
|
+
const names = keys.map(quoteIdentifier)
|
|
129
|
+
const invalid = names.map((name) => `${name} IS NULL OR trim(${name}) = ''`).join(" OR ")
|
|
130
|
+
const [row] = await runtime.query(`SELECT count(*) AS total,
|
|
131
|
+
count(DISTINCT (${names.join(", ")})) AS unique_keys,
|
|
132
|
+
count(*) FILTER (WHERE ${invalid}) AS invalid_keys FROM ${relation}`)
|
|
133
|
+
const count = safeCount(row!, "total")
|
|
134
|
+
return safeCount(row!, "unique_keys") === count && safeCount(row!, "invalid_keys") === 0
|
|
135
|
+
? count
|
|
136
|
+
: null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function safeCount(row: Readonly<Record<string, unknown>>, name: string): number {
|
|
140
|
+
const count = Number(getBigIntLike(row, name))
|
|
141
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
142
|
+
throw new LakeStorageError("[SixbDuckLake] Change count exceeds the supported integer range.")
|
|
143
|
+
return count
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function rowImage(
|
|
147
|
+
row: Readonly<Record<string, unknown>>,
|
|
148
|
+
prefix: "a" | "b",
|
|
149
|
+
columns: readonly DatasetColumnDefinition[]
|
|
150
|
+
): DatasetRow {
|
|
151
|
+
return Object.fromEntries(
|
|
152
|
+
columns.map((column, index) => [
|
|
153
|
+
column.name,
|
|
154
|
+
normalizeReadValue(row[`${prefix}${index}`], column),
|
|
155
|
+
])
|
|
156
|
+
)
|
|
157
|
+
}
|