@sixb/ducklake 0.1.6 → 0.1.7
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 +24 -26
- package/dist/index.js +489 -174
- package/dist/index.js.map +12 -10
- package/dist/internal/duckdb-reader.d.ts +28 -0
- package/dist/internal/duckdb-reader.d.ts.map +1 -0
- package/dist/internal/duckdb-runtime.d.ts +5 -3
- package/dist/internal/duckdb-runtime.d.ts.map +1 -1
- package/dist/internal/ducklake-connection-manager.d.ts +19 -3
- package/dist/internal/ducklake-connection-manager.d.ts.map +1 -1
- package/dist/internal/ducklake-read-gate.d.ts +11 -0
- package/dist/internal/ducklake-read-gate.d.ts.map +1 -0
- package/dist/internal/ducklake-row-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
- package/dist/internal/ducklake-write-coordinator.d.ts +2 -1
- package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/internal/duckdb-reader.ts +129 -0
- package/src/internal/duckdb-runtime.ts +75 -51
- package/src/internal/ducklake-connection-manager.ts +117 -33
- package/src/internal/ducklake-maintenance.ts +1 -1
- package/src/internal/ducklake-read-gate.ts +58 -0
- package/src/internal/ducklake-row-reader.ts +84 -63
- package/src/internal/ducklake-snapshot-reader.ts +7 -2
- package/src/internal/ducklake-write-coordinator.ts +56 -15
- package/src/internal/ducklake-write-session.ts +1 -0
- package/src/types.ts +2 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
2
2
|
import type { DuckLakeStorageOptions } from "../types"
|
|
3
3
|
import { localCatalogCoordinationKey } from "./catalog-key"
|
|
4
|
+
import type { DuckDbReader } from "./duckdb-reader"
|
|
4
5
|
import {
|
|
5
6
|
createDuckDbRuntime,
|
|
6
7
|
type DuckDbExclusiveRuntime,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
installDuckLakeExtensions,
|
|
10
11
|
setupDuckLake,
|
|
11
12
|
} from "./duckdb-runtime"
|
|
13
|
+
import { DuckLakeReadGate } from "./ducklake-read-gate"
|
|
12
14
|
import {
|
|
13
15
|
buildAttachSql,
|
|
14
16
|
buildConfigurePostgresMetadataPoolSql,
|
|
@@ -37,14 +39,89 @@ export class DuckLakeConnectionManager {
|
|
|
37
39
|
// Local catalogs need DETACH/ATTACH to refresh metadata visibility. Keep
|
|
38
40
|
// those attachment changes outside whole read operations so a read cannot
|
|
39
41
|
// observe the DuckLake alias disappearing between its metadata queries.
|
|
40
|
-
private
|
|
42
|
+
private attachmentLock: Promise<void> = Promise.resolve()
|
|
41
43
|
private observedLocalCatalogGeneration: number
|
|
42
44
|
private attached = false
|
|
43
45
|
private closed = false
|
|
46
|
+
private closePromise: Promise<void> | undefined
|
|
44
47
|
private runtimePoisoned = false
|
|
48
|
+
private pinnedReaders = 0
|
|
49
|
+
private refreshRequested = false
|
|
50
|
+
private readonly shutdown = new AbortController()
|
|
51
|
+
private readonly reads = new DuckLakeReadGate()
|
|
52
|
+
private readonly maxStreamingReads: number
|
|
45
53
|
|
|
46
54
|
constructor(private readonly options: DuckLakeStorageOptions) {
|
|
47
55
|
this.observedLocalCatalogGeneration = currentLocalCatalogGeneration(options)
|
|
56
|
+
this.maxStreamingReads = options.maxStreamingReads ?? 4
|
|
57
|
+
if (!Number.isSafeInteger(this.maxStreamingReads) || this.maxStreamingReads < 1) {
|
|
58
|
+
throw new LakeStorageError("[SixbDuckLake] maxStreamingReads must be a positive integer.")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async acquireRead(signal?: AbortSignal): Promise<{
|
|
63
|
+
readonly signal: AbortSignal
|
|
64
|
+
readonly release: () => void
|
|
65
|
+
}> {
|
|
66
|
+
this.assertOpen()
|
|
67
|
+
const combined = signal ? AbortSignal.any([signal, this.shutdown.signal]) : this.shutdown.signal
|
|
68
|
+
return { signal: combined, release: await this.reads.acquire(combined) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async nativeReaderLimit(runtime: DuckDbRuntime): Promise<number> {
|
|
72
|
+
if (this.options.catalog.type === "duckdb") return this.maxStreamingReads
|
|
73
|
+
// SQLite metadata transactions keep a read lock for the native stream's lifetime.
|
|
74
|
+
// Unknown/custom catalogs use bounded queries too, without assuming their locking behavior.
|
|
75
|
+
if (this.options.catalog.type !== "postgres") return 0
|
|
76
|
+
const [setting] = await runtime.query(
|
|
77
|
+
"SELECT current_setting('pg_pool_max_connections') AS value"
|
|
78
|
+
)
|
|
79
|
+
const budget = Number(setting?.value)
|
|
80
|
+
// Reserve metadata/write headroom, including transient leases from bounded scans. A
|
|
81
|
+
// four-connection pool must stay paged: one paused native cursor can still starve an append.
|
|
82
|
+
// The extension can exceed its cache limit; this is not a server-wide connection guarantee.
|
|
83
|
+
return Number.isFinite(budget) ? Math.min(this.maxStreamingReads, Math.max(0, budget - 4)) : 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Caller holds an attachment lease until this query has started. */
|
|
87
|
+
async openReader(
|
|
88
|
+
runtime: DuckDbRuntime,
|
|
89
|
+
sql: string,
|
|
90
|
+
signal: AbortSignal,
|
|
91
|
+
releaseRead: () => void
|
|
92
|
+
): Promise<DuckDbReader | null> {
|
|
93
|
+
const limit = await this.nativeReaderLimit(runtime)
|
|
94
|
+
signal.throwIfAborted()
|
|
95
|
+
if (this.pinnedReaders >= limit) return null
|
|
96
|
+
this.pinnedReaders++
|
|
97
|
+
let starting = true
|
|
98
|
+
let released = false
|
|
99
|
+
const onClose = async () => {
|
|
100
|
+
if (released) return
|
|
101
|
+
released = true
|
|
102
|
+
this.pinnedReaders--
|
|
103
|
+
if (signal.aborted || this.closed || this.runtimePoisoned) releaseRead()
|
|
104
|
+
// On startup failure the caller still holds its lease and will detach on release.
|
|
105
|
+
if (starting || this.closed || this.runtimePoisoned) return
|
|
106
|
+
await this.withAttachmentLock(async () => {
|
|
107
|
+
if (this.pinnedReaders === 0 && this.refreshRequested) await this.resetRuntimeUnlocked()
|
|
108
|
+
await this.detachDuckDbCatalogAfterLease()
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
return await runtime.openReader(sql, { signal, onClose })
|
|
113
|
+
} catch (error) {
|
|
114
|
+
await onClose()
|
|
115
|
+
throw error
|
|
116
|
+
} finally {
|
|
117
|
+
starting = false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async withMaintenanceRuntime<T>(
|
|
122
|
+
run: (runtime: DuckDbExclusiveRuntime) => Promise<T>
|
|
123
|
+
): Promise<T> {
|
|
124
|
+
return this.reads.withMaintenance(this.shutdown.signal, () => this.withExclusiveAttached(run))
|
|
48
125
|
}
|
|
49
126
|
|
|
50
127
|
assertOpen(): void {
|
|
@@ -87,10 +164,9 @@ export class DuckLakeConnectionManager {
|
|
|
87
164
|
async acquireAttachedRuntime(): Promise<DuckLakeAttachedRuntimeLease> {
|
|
88
165
|
this.assertOpen()
|
|
89
166
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
const releaseLock = await this.acquireLocalAttachmentLock()
|
|
167
|
+
// Keep attachment changes and native-query preparation outside each other's critical section.
|
|
168
|
+
// The lease ends before rows are yielded; an active reader only retains the attachment.
|
|
169
|
+
const releaseLock = await this.acquireAttachmentLock()
|
|
94
170
|
let released = false
|
|
95
171
|
const release = async () => {
|
|
96
172
|
if (released) {
|
|
@@ -100,8 +176,8 @@ export class DuckLakeConnectionManager {
|
|
|
100
176
|
released = true
|
|
101
177
|
try {
|
|
102
178
|
if (this.runtimePoisoned) {
|
|
103
|
-
this.runtimePoisoned = false
|
|
104
179
|
await this.discardRuntimeUnlocked()
|
|
180
|
+
this.runtimePoisoned = false
|
|
105
181
|
} else {
|
|
106
182
|
await this.detachDuckDbCatalogAfterLease()
|
|
107
183
|
}
|
|
@@ -168,7 +244,7 @@ export class DuckLakeConnectionManager {
|
|
|
168
244
|
}
|
|
169
245
|
|
|
170
246
|
async resetRuntime(): Promise<void> {
|
|
171
|
-
const releaseLock = await this.
|
|
247
|
+
const releaseLock = await this.acquireAttachmentLock()
|
|
172
248
|
try {
|
|
173
249
|
await this.resetRuntimeUnlocked()
|
|
174
250
|
} finally {
|
|
@@ -177,6 +253,11 @@ export class DuckLakeConnectionManager {
|
|
|
177
253
|
}
|
|
178
254
|
|
|
179
255
|
private async resetRuntimeUnlocked(): Promise<void> {
|
|
256
|
+
if (this.pinnedReaders > 0) {
|
|
257
|
+
this.refreshRequested = true
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
this.refreshRequested = false
|
|
180
261
|
const runtimePromise = this.runtimePromise
|
|
181
262
|
if (runtimePromise === undefined) {
|
|
182
263
|
return
|
|
@@ -226,7 +307,7 @@ export class DuckLakeConnectionManager {
|
|
|
226
307
|
|
|
227
308
|
if (this.refreshPromise === undefined) {
|
|
228
309
|
let refreshPromise: Promise<void>
|
|
229
|
-
refreshPromise = this.
|
|
310
|
+
refreshPromise = this.withAttachmentLock(() =>
|
|
230
311
|
this.refreshForExternalChangesUnlocked()
|
|
231
312
|
).finally(() => {
|
|
232
313
|
if (this.refreshPromise === refreshPromise) {
|
|
@@ -253,8 +334,10 @@ export class DuckLakeConnectionManager {
|
|
|
253
334
|
}
|
|
254
335
|
|
|
255
336
|
try {
|
|
256
|
-
|
|
257
|
-
|
|
337
|
+
if (this.pinnedReaders === 0) {
|
|
338
|
+
await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
|
|
339
|
+
this.attached = false
|
|
340
|
+
}
|
|
258
341
|
} catch {
|
|
259
342
|
// The commit already succeeded. Keep the original write result and let a
|
|
260
343
|
// later refresh/close retry detaching this local catalog if needed.
|
|
@@ -278,12 +361,14 @@ export class DuckLakeConnectionManager {
|
|
|
278
361
|
return this.observedLocalCatalogGeneration !== currentLocalCatalogGeneration(this.options)
|
|
279
362
|
}
|
|
280
363
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
364
|
+
close(): Promise<void> {
|
|
365
|
+
this.closePromise ??= this.finishClose()
|
|
366
|
+
return this.closePromise
|
|
367
|
+
}
|
|
285
368
|
|
|
369
|
+
private async finishClose(): Promise<void> {
|
|
286
370
|
this.closed = true
|
|
371
|
+
this.shutdown.abort(new LakeStorageError("[SixbDuckLake] DuckLakeStorage is closed."))
|
|
287
372
|
|
|
288
373
|
const runtimePromise = this.runtimePromise
|
|
289
374
|
this.runtimePromise = undefined
|
|
@@ -364,7 +449,7 @@ export class DuckLakeConnectionManager {
|
|
|
364
449
|
}
|
|
365
450
|
|
|
366
451
|
private async detachRuntime(): Promise<void> {
|
|
367
|
-
await this.
|
|
452
|
+
await this.withAttachmentLock(() => this.detachRuntimeUnlocked())
|
|
368
453
|
}
|
|
369
454
|
|
|
370
455
|
/**
|
|
@@ -377,7 +462,7 @@ export class DuckLakeConnectionManager {
|
|
|
377
462
|
}
|
|
378
463
|
|
|
379
464
|
private async detachDuckDbCatalogAfterLease(): Promise<void> {
|
|
380
|
-
if (this.options.catalog.type !== "duckdb" || !this.attached) {
|
|
465
|
+
if (this.options.catalog.type !== "duckdb" || !this.attached || this.pinnedReaders > 0) {
|
|
381
466
|
return
|
|
382
467
|
}
|
|
383
468
|
|
|
@@ -403,6 +488,7 @@ export class DuckLakeConnectionManager {
|
|
|
403
488
|
}
|
|
404
489
|
|
|
405
490
|
private async detachRuntimeUnlocked(): Promise<void> {
|
|
491
|
+
if (this.pinnedReaders > 0) return
|
|
406
492
|
if (this.attachPromise !== undefined) {
|
|
407
493
|
await this.attachPromise
|
|
408
494
|
}
|
|
@@ -416,8 +502,8 @@ export class DuckLakeConnectionManager {
|
|
|
416
502
|
this.attached = false
|
|
417
503
|
}
|
|
418
504
|
|
|
419
|
-
private async
|
|
420
|
-
const releaseLock = await this.
|
|
505
|
+
private async withAttachmentLock<T>(run: () => Promise<T>): Promise<T> {
|
|
506
|
+
const releaseLock = await this.acquireAttachmentLock()
|
|
421
507
|
try {
|
|
422
508
|
return await run()
|
|
423
509
|
} finally {
|
|
@@ -425,17 +511,13 @@ export class DuckLakeConnectionManager {
|
|
|
425
511
|
}
|
|
426
512
|
}
|
|
427
513
|
|
|
428
|
-
private async
|
|
429
|
-
|
|
430
|
-
return () => {}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const previous = this.localAttachmentLock.catch(() => {})
|
|
514
|
+
private async acquireAttachmentLock(): Promise<() => void> {
|
|
515
|
+
const previous = this.attachmentLock.catch(() => {})
|
|
434
516
|
let release!: () => void
|
|
435
517
|
const current = new Promise<void>((resolve) => {
|
|
436
518
|
release = resolve
|
|
437
519
|
})
|
|
438
|
-
this.
|
|
520
|
+
this.attachmentLock = previous.then(() => current)
|
|
439
521
|
|
|
440
522
|
await previous
|
|
441
523
|
|
|
@@ -475,16 +557,18 @@ export class DuckLakeConnectionManager {
|
|
|
475
557
|
runtime: DuckDbRuntime,
|
|
476
558
|
options: { readonly detach: boolean }
|
|
477
559
|
): Promise<void> {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
560
|
+
try {
|
|
561
|
+
await runtime.stopReaders()
|
|
562
|
+
if (options.detach) {
|
|
563
|
+
try {
|
|
564
|
+
await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
|
|
565
|
+
} catch {
|
|
566
|
+
// Detaching a partially initialized or failed catalog can fail; still close the engine.
|
|
567
|
+
}
|
|
484
568
|
}
|
|
569
|
+
} finally {
|
|
570
|
+
await runtime.close()
|
|
485
571
|
}
|
|
486
|
-
|
|
487
|
-
await runtime.close()
|
|
488
572
|
}
|
|
489
573
|
}
|
|
490
574
|
|
|
@@ -37,7 +37,7 @@ export class DuckLakeMaintenance {
|
|
|
37
37
|
)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
return this.connections.
|
|
40
|
+
return this.connections.withMaintenanceRuntime(async (runtime) => {
|
|
41
41
|
const counts = await runMaintenanceProcedures(
|
|
42
42
|
runtime,
|
|
43
43
|
this.options,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Keeps destructive maintenance outside reads, including paged overflow readers. */
|
|
2
|
+
export class DuckLakeReadGate {
|
|
3
|
+
private active = 0
|
|
4
|
+
private maintenance = false
|
|
5
|
+
private readonly waiters = new Set<() => void>()
|
|
6
|
+
|
|
7
|
+
async acquire(signal: AbortSignal): Promise<() => void> {
|
|
8
|
+
while (true) {
|
|
9
|
+
signal.throwIfAborted()
|
|
10
|
+
if (!this.maintenance) {
|
|
11
|
+
this.active++
|
|
12
|
+
let released = false
|
|
13
|
+
return () => {
|
|
14
|
+
if (released) return
|
|
15
|
+
released = true
|
|
16
|
+
this.active--
|
|
17
|
+
this.wake()
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
await this.wait(signal)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async withMaintenance<T>(signal: AbortSignal, run: () => Promise<T>): Promise<T> {
|
|
25
|
+
// Waiting maintenance must let an active pipeline open the rest of its inputs. Only block
|
|
26
|
+
// new reads once maintenance can actually start; continuous reads may defer it indefinitely.
|
|
27
|
+
while (this.active > 0 || this.maintenance) await this.wait(signal)
|
|
28
|
+
signal.throwIfAborted()
|
|
29
|
+
this.maintenance = true
|
|
30
|
+
try {
|
|
31
|
+
return await run()
|
|
32
|
+
} finally {
|
|
33
|
+
this.maintenance = false
|
|
34
|
+
this.wake()
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private wait(signal: AbortSignal): Promise<void> {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const wake = () => {
|
|
41
|
+
signal.removeEventListener("abort", abort)
|
|
42
|
+
this.waiters.delete(wake)
|
|
43
|
+
resolve()
|
|
44
|
+
}
|
|
45
|
+
const abort = () => {
|
|
46
|
+
this.waiters.delete(wake)
|
|
47
|
+
reject(signal.reason)
|
|
48
|
+
}
|
|
49
|
+
signal.throwIfAborted()
|
|
50
|
+
this.waiters.add(wake)
|
|
51
|
+
signal.addEventListener("abort", abort, { once: true })
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private wake(): void {
|
|
56
|
+
for (const wake of this.waiters) wake()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { DatasetColumnDefinition, DatasetRow } from "@sixb/core"
|
|
|
2
2
|
import type { DatasetVersion, ReadDatasetRowsInput } from "@sixb/core/lake-storage"
|
|
3
3
|
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
4
4
|
import type { DuckLakeStorageOptions } from "../types"
|
|
5
|
+
import type { DuckDbReader } from "./duckdb-reader"
|
|
5
6
|
import { getBigIntLike } from "./duckdb-row"
|
|
6
7
|
import type { DuckDbQueryRuntime } from "./duckdb-runtime"
|
|
7
8
|
import type { DuckLakeConnectionManager } from "./ducklake-connection-manager"
|
|
@@ -11,10 +12,7 @@ import { normalizeReadValue } from "./schema"
|
|
|
11
12
|
import { qualifiedTableName, quoteIdentifier } from "./sql"
|
|
12
13
|
import { parseVersionId } from "./versions"
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
// bounding converted-row memory, this lets a JavaScript pipeline feed one DuckLake dataset into
|
|
16
|
-
// another: the destination appender can use the runtime after each page instead of waiting forever
|
|
17
|
-
// behind its own still-open source stream.
|
|
15
|
+
// Bounded fallback for catalogs whose metadata transactions cannot stay open during a read.
|
|
18
16
|
const READ_PAGE_ROWS = 5_000
|
|
19
17
|
const PHYSICAL_ROW_ID_ALIAS = "__sixb_physical_row_id"
|
|
20
18
|
|
|
@@ -33,78 +31,88 @@ export class DuckLakeRowReader {
|
|
|
33
31
|
) {}
|
|
34
32
|
|
|
35
33
|
async *readRows(input: ReadDatasetRowsInput): AsyncIterable<DatasetRow> {
|
|
36
|
-
this.connections.
|
|
37
|
-
|
|
38
|
-
// Keep the attachment lease across pages so local catalogs do not pay DETACH/ATTACH for every
|
|
39
|
-
// page. Individual query() calls still release the DuckDB queue before rows are yielded.
|
|
40
|
-
const lease = await this.connections.acquireAttachedRuntime()
|
|
34
|
+
const read = await this.connections.acquireRead(input.signal)
|
|
35
|
+
let reader: DuckDbReader | null = null
|
|
41
36
|
try {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
const prepared = await this.connections.withAttachedRuntime(async (runtime) => {
|
|
38
|
+
read.signal.throwIfAborted()
|
|
39
|
+
const tableRef = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
|
|
40
|
+
if (!tableRef) {
|
|
41
|
+
throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.datasetId}'.`)
|
|
42
|
+
}
|
|
43
|
+
// Resolve latest once, before yielding any rows. Every query names the same snapshot.
|
|
44
|
+
const version = await this.resolveVersion(runtime, tableRef, input.versionId)
|
|
45
|
+
const columns = this.resolveReadColumns(tableRef.datasetId, version.schema, input.columns)
|
|
46
|
+
const names = new Set(version.schema.columns.map((column) => column.name.toLowerCase()))
|
|
47
|
+
const physicalOrder = !names.has("rowid") && !names.has(PHYSICAL_ROW_ID_ALIAS)
|
|
48
|
+
const relation = `${qualifiedTableName(this.options, tableRef.tableName)} AT (VERSION => ${parseVersionId(version.versionId)})`
|
|
49
|
+
const columnsSql = columns.map((column) => quoteIdentifier(column.name)).join(", ")
|
|
50
|
+
const offset = Math.max(0, Math.trunc(input.offset ?? 0))
|
|
51
|
+
const limit = input.limit === undefined ? undefined : Math.max(0, Math.trunc(input.limit))
|
|
52
|
+
const sql = `SELECT ${columnsSql} FROM ${relation}${physicalOrder ? " ORDER BY rowid" : ""}${limit === undefined ? "" : ` LIMIT ${limit}`}${offset > 0 ? ` OFFSET ${offset}` : ""}`
|
|
53
|
+
const cursor =
|
|
54
|
+
limit === undefined || limit > READ_PAGE_ROWS
|
|
55
|
+
? await this.connections.openReader(runtime, sql, read.signal, read.release)
|
|
56
|
+
: null
|
|
57
|
+
const query = { columnsSql, relation, physicalOrder }
|
|
58
|
+
const firstPage =
|
|
59
|
+
cursor || limit === 0
|
|
60
|
+
? []
|
|
61
|
+
: await runtime.query(
|
|
62
|
+
pageSql(query, Math.min(limit ?? READ_PAGE_ROWS, READ_PAGE_ROWS), offset, null)
|
|
63
|
+
)
|
|
64
|
+
return { columns, ...query, offset, limit, cursor, firstPage }
|
|
65
|
+
})
|
|
66
|
+
reader = prepared.cursor
|
|
67
|
+
// A paused paged reader owns no native query. Cancellation can release its read lease without
|
|
68
|
+
// waiting for the consumer to call next()/return(); native readers release after cleanup.
|
|
69
|
+
if (!reader) read.signal.addEventListener("abort", read.release, { once: true })
|
|
70
|
+
read.signal.throwIfAborted()
|
|
71
|
+
if (reader) {
|
|
72
|
+
yield* this.normalizeRows(reader.rows(), prepared.columns, read.signal)
|
|
73
|
+
return
|
|
48
74
|
}
|
|
49
|
-
const version = await this.resolveVersion(runtime, tableRef, input.versionId)
|
|
50
|
-
const selectedColumns = this.resolveReadColumns(
|
|
51
|
-
tableRef.datasetId,
|
|
52
|
-
version.schema,
|
|
53
|
-
input.columns
|
|
54
|
-
)
|
|
55
|
-
const snapshotId = parseVersionId(version.versionId)
|
|
56
|
-
const columnsSql = selectedColumns.map((column) => quoteIdentifier(column.name)).join(", ")
|
|
57
|
-
const tableSql = qualifiedTableName(this.options, tableRef.tableName)
|
|
58
|
-
// A user column named `rowid` shadows DuckDB's virtual row id. In that edge case the
|
|
59
|
-
// connection-level invariant still preserves insertion order; otherwise make it explicit.
|
|
60
|
-
const schemaNames = new Set(version.schema.columns.map((column) => column.name.toLowerCase()))
|
|
61
|
-
const canUsePhysicalRowId =
|
|
62
|
-
!schemaNames.has("rowid") && !schemaNames.has(PHYSICAL_ROW_ID_ALIAS.toLowerCase())
|
|
63
|
-
const orderSql = canUsePhysicalRowId ? " ORDER BY rowid" : ""
|
|
64
|
-
const baseSql = `SELECT ${columnsSql} FROM ${tableSql} AT (VERSION => ${snapshotId})`
|
|
65
|
-
const requestedOffset = Math.max(0, Math.trunc(input.offset ?? 0))
|
|
66
|
-
let remaining = input.limit === undefined ? undefined : Math.max(0, Math.trunc(input.limit))
|
|
67
|
-
if (remaining === 0) return
|
|
68
75
|
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
let
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
76
|
+
// Each bounded query releases both the runtime queue and attachment lease before yielding.
|
|
77
|
+
// SQLite metadata locks and PostgreSQL pool slots are released before consumer operations.
|
|
78
|
+
let offset = prepared.offset
|
|
79
|
+
let remaining = prepared.limit
|
|
80
|
+
let cursor: bigint | null = null
|
|
81
|
+
let page: readonly Record<string, unknown>[] | undefined = prepared.firstPage
|
|
82
|
+
while (remaining !== 0) {
|
|
83
|
+
read.signal.throwIfAborted()
|
|
84
|
+
const pageSize = Math.min(remaining ?? READ_PAGE_ROWS, READ_PAGE_ROWS)
|
|
85
|
+
const rows: readonly Record<string, unknown>[] =
|
|
86
|
+
page ??
|
|
87
|
+
(await this.connections.withAttachedRuntime((runtime) =>
|
|
88
|
+
runtime.query(pageSql(prepared, pageSize, offset, cursor))
|
|
89
|
+
))
|
|
90
|
+
page = undefined
|
|
91
|
+
read.signal.throwIfAborted()
|
|
92
|
+
yield* this.normalizeRows(rows, prepared.columns, read.signal)
|
|
93
|
+
if (remaining !== undefined) remaining -= rows.length
|
|
94
|
+
if (rows.length < pageSize) return
|
|
95
|
+
if (prepared.physicalOrder)
|
|
96
|
+
cursor = getBigIntLike(rows[rows.length - 1]!, PHYSICAL_ROW_ID_ALIAS)
|
|
88
97
|
offset += rows.length
|
|
89
|
-
if (remaining !== undefined) {
|
|
90
|
-
remaining -= rows.length
|
|
91
|
-
if (remaining === 0) return
|
|
92
|
-
}
|
|
93
|
-
if (canUsePhysicalRowId) {
|
|
94
|
-
physicalCursor = getBigIntLike(rows[rows.length - 1]!, PHYSICAL_ROW_ID_ALIAS)
|
|
95
|
-
}
|
|
96
|
-
if (rows.length < pageRows) return
|
|
97
98
|
}
|
|
98
99
|
} finally {
|
|
99
|
-
|
|
100
|
+
read.signal.removeEventListener("abort", read.release)
|
|
101
|
+
try {
|
|
102
|
+
await reader?.close()
|
|
103
|
+
} finally {
|
|
104
|
+
read.release()
|
|
105
|
+
}
|
|
100
106
|
}
|
|
101
107
|
}
|
|
102
108
|
|
|
103
109
|
private async *normalizeRows(
|
|
104
110
|
rows: Iterable<Record<string, unknown>> | AsyncIterable<Record<string, unknown>>,
|
|
105
|
-
selectedColumns: readonly DatasetColumnDefinition[]
|
|
111
|
+
selectedColumns: readonly DatasetColumnDefinition[],
|
|
112
|
+
signal: AbortSignal
|
|
106
113
|
): AsyncIterable<DatasetRow> {
|
|
107
114
|
for await (const row of rows) {
|
|
115
|
+
signal.throwIfAborted()
|
|
108
116
|
const output: Record<string, unknown> = {}
|
|
109
117
|
for (const column of selectedColumns) {
|
|
110
118
|
output[column.name] = normalizeReadValue(row[column.name], column)
|
|
@@ -165,3 +173,16 @@ export class DuckLakeRowReader {
|
|
|
165
173
|
)
|
|
166
174
|
}
|
|
167
175
|
}
|
|
176
|
+
|
|
177
|
+
function pageSql(
|
|
178
|
+
read: { readonly columnsSql: string; readonly relation: string; readonly physicalOrder: boolean },
|
|
179
|
+
limit: number,
|
|
180
|
+
offset: number,
|
|
181
|
+
cursor: bigint | null
|
|
182
|
+
): string {
|
|
183
|
+
return read.physicalOrder
|
|
184
|
+
? `SELECT rowid AS ${quoteIdentifier(PHYSICAL_ROW_ID_ALIAS)}, ${read.columnsSql}
|
|
185
|
+
FROM ${read.relation} ${cursor === null ? "" : `WHERE rowid > ${cursor}`}
|
|
186
|
+
ORDER BY rowid LIMIT ${limit} ${cursor === null && offset > 0 ? `OFFSET ${offset}` : ""}`
|
|
187
|
+
: `SELECT ${read.columnsSql} FROM ${read.relation} LIMIT ${limit} OFFSET ${offset}`
|
|
188
|
+
}
|
|
@@ -642,7 +642,12 @@ export class DuckLakeSnapshotReader {
|
|
|
642
642
|
return []
|
|
643
643
|
}
|
|
644
644
|
|
|
645
|
-
|
|
645
|
+
// A dataset cannot have a version before its table exists. Use the earliest table record:
|
|
646
|
+
// a rename may create a newer record with the same table id and must not hide older versions.
|
|
647
|
+
const ducklakeTable = duckLakeMetadataTableName(this.options, "ducklake_table")
|
|
648
|
+
const where = [
|
|
649
|
+
`snapshot.snapshot_id >= (SELECT min(begin_snapshot) FROM ${ducklakeTable} WHERE table_id = ${input.tableId})`,
|
|
650
|
+
]
|
|
646
651
|
if (input.exactSnapshotId !== undefined) {
|
|
647
652
|
assertDuckLakeSnapshotId(input.exactSnapshotId)
|
|
648
653
|
where.push(`snapshot.snapshot_id = ${input.exactSnapshotId}`)
|
|
@@ -652,7 +657,7 @@ export class DuckLakeSnapshotReader {
|
|
|
652
657
|
where.push(`snapshot.snapshot_id < ${input.beforeSnapshotId}`)
|
|
653
658
|
}
|
|
654
659
|
|
|
655
|
-
const whereSql =
|
|
660
|
+
const whereSql = `WHERE ${where.join(" AND ")}`
|
|
656
661
|
const limitSql =
|
|
657
662
|
input.limit === undefined ? "" : `LIMIT ${Math.max(0, Math.trunc(input.limit))}`
|
|
658
663
|
const ducklakeSnapshot = duckLakeMetadataTableName(this.options, "ducklake_snapshot")
|