@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.
@@ -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
+ }
@@ -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
- // Large reads release the provider's single DuckDB queue slot between bounded pages. Besides
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.assertOpen()
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 runtime = lease.runtime
43
- // Resolve latest exactly once so a paged read stays pinned even if another writer commits
44
- // between pages. DuckLake snapshots are immutable; every page names this snapshot.
45
- const tableRef = await resolveDatasetTableRef(this.options, runtime, input.datasetId)
46
- if (!tableRef) {
47
- throw new LakeStorageError(`[SixbDuckLake] Unknown dataset '${input.datasetId}'.`)
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
- // Materialize one bounded page and release its queue operation before yielding it. A
70
- // consumer may enqueue another DuckLake operation while processing the yielded rows.
71
- // Holding a native stream open here would make that operation wait behind itself.
72
- let offset = requestedOffset
73
- let physicalCursor: bigint | null = null
74
- while (true) {
75
- const pageRows =
76
- remaining === undefined ? READ_PAGE_ROWS : Math.min(READ_PAGE_ROWS, remaining)
77
- const pageSql = canUsePhysicalRowId
78
- ? `SELECT rowid AS ${quoteIdentifier(PHYSICAL_ROW_ID_ALIAS)}, ${columnsSql}
79
- FROM ${tableSql} AT (VERSION => ${snapshotId})
80
- ${physicalCursor === null ? "" : `WHERE rowid > ${physicalCursor}`}
81
- ORDER BY rowid LIMIT ${pageRows}
82
- ${physicalCursor === null && offset > 0 ? `OFFSET ${offset}` : ""}`
83
- : `${baseSql}${orderSql} LIMIT ${pageRows} OFFSET ${offset}`
84
- const rows = await runtime.query(pageSql)
85
- if (rows.length === 0) return
86
-
87
- yield* this.normalizeRows(rows, selectedColumns)
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
- await lease.release()
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
- const where: string[] = []
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 = where.length === 0 ? "" : `WHERE ${where.join(" AND ")}`
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")