@sixb/ducklake 0.1.6 → 0.1.8
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 +25 -27
- package/dist/ducklake-storage.d.ts +2 -1
- package/dist/ducklake-storage.d.ts.map +1 -1
- package/dist/index.js +736 -244
- package/dist/index.js.map +14 -12
- 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 +26 -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/internal/sql.d.ts +11 -2
- package/dist/internal/sql.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/ducklake-storage.ts +8 -0
- 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 +331 -79
- package/src/internal/ducklake-write-coordinator.ts +56 -15
- package/src/internal/ducklake-write-session.ts +1 -0
- package/src/internal/sql.ts +35 -6
- package/src/types.ts +2 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
1
3
|
import {
|
|
2
4
|
type DuckDBConnection,
|
|
3
5
|
DuckDBInstance,
|
|
@@ -6,6 +8,7 @@ import {
|
|
|
6
8
|
} from "@duckdb/node-api"
|
|
7
9
|
import { LakeStorageError } from "@sixb/core/lake-storage"
|
|
8
10
|
import type { DuckDbRuntimeOptions, DuckLakeStorageOptions } from "../types"
|
|
11
|
+
import { type DuckDbReader, type DuckDbReaderOptions, NodeDuckDbReader } from "./duckdb-reader"
|
|
9
12
|
import { sixbDuckDbValueConverter } from "./duckdb-value-converter"
|
|
10
13
|
import {
|
|
11
14
|
buildAttachSql,
|
|
@@ -20,8 +23,8 @@ import {
|
|
|
20
23
|
* Narrow adapter around `@duckdb/node-api`.
|
|
21
24
|
*
|
|
22
25
|
* Keeping the public provider behind this interface confines the driver types to
|
|
23
|
-
* this module instead of leaking them into `DuckLakeStorage`.
|
|
24
|
-
*
|
|
26
|
+
* this module instead of leaking them into `DuckLakeStorage`. Main-connection calls
|
|
27
|
+
* share one queue; readers use separate connections to the same DuckDB instance.
|
|
25
28
|
*/
|
|
26
29
|
export interface DuckDbQueryRuntime {
|
|
27
30
|
run(sql: string, values?: readonly DuckDBValue[]): Promise<void>
|
|
@@ -34,7 +37,8 @@ export interface DuckDbQueryRuntime {
|
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export interface DuckDbRuntime extends DuckDbQueryRuntime {
|
|
37
|
-
|
|
40
|
+
openReader(sql: string, options?: DuckDbReaderOptions): Promise<DuckDbReader>
|
|
41
|
+
stopReaders(): Promise<void>
|
|
38
42
|
/**
|
|
39
43
|
* Reserve the runtime queue for a multi-statement critical section.
|
|
40
44
|
*
|
|
@@ -71,15 +75,43 @@ interface SetupDuckLakeOptions {
|
|
|
71
75
|
class NodeDuckDbRuntime implements DuckDbRuntime {
|
|
72
76
|
private closing = false
|
|
73
77
|
private closed = false
|
|
74
|
-
//
|
|
75
|
-
// handlers cannot interleave statements or close/reset an active query.
|
|
78
|
+
// Serialize main-connection calls so transactions and staged writes cannot interleave.
|
|
76
79
|
private operations: Promise<void> = Promise.resolve()
|
|
80
|
+
private readonly readers = new Set<DuckDbReader>()
|
|
81
|
+
private closePromise: Promise<void> | undefined
|
|
77
82
|
|
|
78
83
|
constructor(
|
|
79
84
|
private readonly instance: DuckDBInstance,
|
|
80
|
-
private readonly connection: DuckDBConnection
|
|
85
|
+
private readonly connection: DuckDBConnection,
|
|
86
|
+
private readonly temporaryDirectory?: string
|
|
81
87
|
) {}
|
|
82
88
|
|
|
89
|
+
async openReader(sql: string, options: DuckDbReaderOptions = {}): Promise<DuckDbReader> {
|
|
90
|
+
return this.enqueue(async () => {
|
|
91
|
+
this.assertAcceptingOperations()
|
|
92
|
+
const connection = await this.instance.connect()
|
|
93
|
+
if (this.closing) {
|
|
94
|
+
connection.closeSync()
|
|
95
|
+
this.assertAcceptingOperations()
|
|
96
|
+
}
|
|
97
|
+
const reader = new NodeDuckDbReader(connection, {
|
|
98
|
+
...options,
|
|
99
|
+
onClose: async () => {
|
|
100
|
+
this.readers.delete(reader)
|
|
101
|
+
await options.onClose?.()
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
this.readers.add(reader)
|
|
105
|
+
try {
|
|
106
|
+
await reader.start(sql)
|
|
107
|
+
return reader
|
|
108
|
+
} catch (error) {
|
|
109
|
+
await reader.close()
|
|
110
|
+
throw error
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
async run(sql: string, values?: readonly DuckDBValue[]): Promise<void> {
|
|
84
116
|
await this.enqueue(async () => {
|
|
85
117
|
await this.connection.run(sql, values === undefined ? undefined : [...values])
|
|
@@ -107,45 +139,6 @@ class NodeDuckDbRuntime implements DuckDbRuntime {
|
|
|
107
139
|
})
|
|
108
140
|
}
|
|
109
141
|
|
|
110
|
-
async *streamRows(
|
|
111
|
-
sql: string,
|
|
112
|
-
values?: readonly DuckDBValue[]
|
|
113
|
-
): AsyncIterable<Record<string, unknown>> {
|
|
114
|
-
this.assertAcceptingOperations()
|
|
115
|
-
|
|
116
|
-
// A stream holds its queue slot until the consumer finishes or abandons the
|
|
117
|
-
// iterator. This keeps later writes from running while rows are still being
|
|
118
|
-
// read from the same DuckDB connection.
|
|
119
|
-
let releaseOperation: (() => void) | undefined
|
|
120
|
-
const operationFinished = new Promise<void>((resolve) => {
|
|
121
|
-
releaseOperation = resolve
|
|
122
|
-
})
|
|
123
|
-
const waitForTurn = this.operations.then(() => {
|
|
124
|
-
this.assertNotClosed()
|
|
125
|
-
})
|
|
126
|
-
this.operations = waitForTurn
|
|
127
|
-
.then(() => operationFinished)
|
|
128
|
-
.then(
|
|
129
|
-
() => {},
|
|
130
|
-
() => {}
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
await waitForTurn
|
|
135
|
-
const result = await this.connection.stream(
|
|
136
|
-
sql,
|
|
137
|
-
values === undefined ? undefined : [...values]
|
|
138
|
-
)
|
|
139
|
-
for await (const batch of result.yieldConvertedRowObjects(sixbDuckDbValueConverter)) {
|
|
140
|
-
for (const row of batch) {
|
|
141
|
-
yield row as Record<string, unknown>
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
} finally {
|
|
145
|
-
releaseOperation?.()
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
142
|
async withAppender<T>(
|
|
150
143
|
tableName: string,
|
|
151
144
|
useAppender: (appender: DuckDbAppender) => T | Promise<T>
|
|
@@ -168,18 +161,29 @@ class NodeDuckDbRuntime implements DuckDbRuntime {
|
|
|
168
161
|
})
|
|
169
162
|
}
|
|
170
163
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
164
|
+
close(): Promise<void> {
|
|
165
|
+
this.closePromise ??= this.finish()
|
|
166
|
+
return this.closePromise
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async stopReaders(): Promise<void> {
|
|
170
|
+
const results = await Promise.allSettled([...this.readers].map((reader) => reader.close()))
|
|
171
|
+
const failed = results.find((result) => result.status === "rejected")
|
|
172
|
+
if (failed?.status === "rejected") throw failed.reason
|
|
173
|
+
}
|
|
175
174
|
|
|
175
|
+
private async finish(): Promise<void> {
|
|
176
176
|
this.closing = true
|
|
177
|
+
const results = await Promise.allSettled([...this.readers].map((reader) => reader.close()))
|
|
177
178
|
// resetRuntime()/close() can run while an API read is still using this
|
|
178
179
|
// runtime. Wait for accepted work before closing the native connection.
|
|
179
180
|
await this.operations.catch(() => {})
|
|
180
181
|
this.closed = true
|
|
181
182
|
this.connection.closeSync()
|
|
182
183
|
this.instance.closeSync()
|
|
184
|
+
if (this.temporaryDirectory) await rm(this.temporaryDirectory, { recursive: true, force: true })
|
|
185
|
+
const failed = results.find((result) => result.status === "rejected")
|
|
186
|
+
if (failed?.status === "rejected") throw failed.reason
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
private enqueue<T>(operation: () => T | Promise<T>): Promise<T> {
|
|
@@ -286,8 +290,28 @@ class NodeDuckDbAppenderAdapter implements DuckDbAppender {
|
|
|
286
290
|
export async function createDuckDbRuntime(
|
|
287
291
|
options: DuckDbRuntimeOptions = {}
|
|
288
292
|
): Promise<DuckDbRuntime> {
|
|
289
|
-
const
|
|
290
|
-
|
|
293
|
+
const path = options.path ?? ":memory:"
|
|
294
|
+
const base = options.config?.temp_directory ?? (path === ":memory:" ? ".tmp" : `${path}.tmp`)
|
|
295
|
+
// DuckDB's default .tmp filenames collide between independent instances/processes. Keep the
|
|
296
|
+
// configured disk location, but give each runtime its own directory and cleanup ownership.
|
|
297
|
+
let temporaryDirectory: string | undefined
|
|
298
|
+
if (base !== "") {
|
|
299
|
+
const directory = resolve(base)
|
|
300
|
+
await mkdir(directory, { recursive: true })
|
|
301
|
+
temporaryDirectory = await mkdtemp(join(directory, "sixb-"))
|
|
302
|
+
}
|
|
303
|
+
let instance: DuckDBInstance | undefined
|
|
304
|
+
try {
|
|
305
|
+
instance = await DuckDBInstance.create(path, {
|
|
306
|
+
...options.config,
|
|
307
|
+
...(temporaryDirectory ? { temp_directory: temporaryDirectory } : {}),
|
|
308
|
+
})
|
|
309
|
+
return new NodeDuckDbRuntime(instance, await instance.connect(), temporaryDirectory)
|
|
310
|
+
} catch (error) {
|
|
311
|
+
instance?.closeSync()
|
|
312
|
+
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true })
|
|
313
|
+
throw error
|
|
314
|
+
}
|
|
291
315
|
}
|
|
292
316
|
|
|
293
317
|
/**
|
|
@@ -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
|
+
}
|