@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.
Files changed (35) hide show
  1. package/README.md +25 -27
  2. package/dist/ducklake-storage.d.ts +2 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +736 -244
  5. package/dist/index.js.map +14 -12
  6. package/dist/internal/duckdb-reader.d.ts +28 -0
  7. package/dist/internal/duckdb-reader.d.ts.map +1 -0
  8. package/dist/internal/duckdb-runtime.d.ts +5 -3
  9. package/dist/internal/duckdb-runtime.d.ts.map +1 -1
  10. package/dist/internal/ducklake-connection-manager.d.ts +19 -3
  11. package/dist/internal/ducklake-connection-manager.d.ts.map +1 -1
  12. package/dist/internal/ducklake-read-gate.d.ts +11 -0
  13. package/dist/internal/ducklake-read-gate.d.ts.map +1 -0
  14. package/dist/internal/ducklake-row-reader.d.ts.map +1 -1
  15. package/dist/internal/ducklake-snapshot-reader.d.ts +26 -1
  16. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-write-coordinator.d.ts +2 -1
  18. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  19. package/dist/internal/sql.d.ts +11 -2
  20. package/dist/internal/sql.d.ts.map +1 -1
  21. package/dist/types.d.ts +2 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +4 -4
  24. package/src/ducklake-storage.ts +8 -0
  25. package/src/internal/duckdb-reader.ts +129 -0
  26. package/src/internal/duckdb-runtime.ts +75 -51
  27. package/src/internal/ducklake-connection-manager.ts +117 -33
  28. package/src/internal/ducklake-maintenance.ts +1 -1
  29. package/src/internal/ducklake-read-gate.ts +58 -0
  30. package/src/internal/ducklake-row-reader.ts +84 -63
  31. package/src/internal/ducklake-snapshot-reader.ts +331 -79
  32. package/src/internal/ducklake-write-coordinator.ts +56 -15
  33. package/src/internal/ducklake-write-session.ts +1 -0
  34. package/src/internal/sql.ts +35 -6
  35. 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`. Calls on a runtime
24
- * are serialized through one queue because a runtime owns one DuckDB connection.
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
- streamRows(sql: string, values?: readonly DuckDBValue[]): AsyncIterable<Record<string, unknown>>
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
- // One DuckDBConnection is shared by the runtime. Serialize calls so request
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
- async close(): Promise<void> {
172
- if (this.closed) {
173
- return
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 instance = await DuckDBInstance.create(options.path ?? ":memory:", options.config ?? {})
290
- return new NodeDuckDbRuntime(instance, await instance.connect())
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 localAttachmentLock: Promise<void> = Promise.resolve()
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
- // The lease is intentionally wider than one DuckDB queue operation. It
91
- // covers the full Sixb read/commit boundary for local catalogs while
92
- // remaining a no-op for PostgreSQL catalogs.
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.acquireLocalAttachmentLock()
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.withLocalAttachmentLock(() =>
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
- await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
257
- this.attached = false
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
- async close(): Promise<void> {
282
- if (this.closed) {
283
- return
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.withLocalAttachmentLock(() => this.detachRuntimeUnlocked())
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 withLocalAttachmentLock<T>(run: () => Promise<T>): Promise<T> {
420
- const releaseLock = await this.acquireLocalAttachmentLock()
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 acquireLocalAttachmentLock(): Promise<() => void> {
429
- if (this.options.catalog.type === "postgres") {
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.localAttachmentLock = previous.then(() => current)
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
- if (options.detach) {
479
- try {
480
- await runtime.run(`DETACH ${quoteIdentifier(duckLakeAlias(this.options))}`)
481
- } catch {
482
- // The runtime may already be detached, partially initialized, or in an
483
- // error state. Closing the native connection is still required.
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.withExclusiveAttached(async (runtime) => {
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
+ }