@sixb/ducklake 0.1.5 → 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.
Files changed (34) hide show
  1. package/README.md +24 -26
  2. package/dist/ducklake-storage.d.ts +3 -1
  3. package/dist/ducklake-storage.d.ts.map +1 -1
  4. package/dist/index.js +1100 -661
  5. package/dist/index.js.map +22 -19
  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-change-reader.d.ts +13 -0
  11. package/dist/internal/ducklake-change-reader.d.ts.map +1 -0
  12. package/dist/internal/ducklake-connection-manager.d.ts +19 -3
  13. package/dist/internal/ducklake-connection-manager.d.ts.map +1 -1
  14. package/dist/internal/ducklake-read-gate.d.ts +11 -0
  15. package/dist/internal/ducklake-read-gate.d.ts.map +1 -0
  16. package/dist/internal/ducklake-row-reader.d.ts.map +1 -1
  17. package/dist/internal/ducklake-snapshot-reader.d.ts.map +1 -1
  18. package/dist/internal/ducklake-write-coordinator.d.ts +2 -1
  19. package/dist/internal/ducklake-write-coordinator.d.ts.map +1 -1
  20. package/dist/types.d.ts +2 -0
  21. package/dist/types.d.ts.map +1 -1
  22. package/package.json +4 -4
  23. package/src/ducklake-storage.ts +13 -0
  24. package/src/internal/duckdb-reader.ts +129 -0
  25. package/src/internal/duckdb-runtime.ts +75 -51
  26. package/src/internal/ducklake-change-reader.ts +157 -0
  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 +7 -2
  32. package/src/internal/ducklake-write-coordinator.ts +56 -15
  33. package/src/internal/ducklake-write-session.ts +1 -0
  34. package/src/types.ts +2 -0
@@ -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")
@@ -18,7 +18,7 @@ import {
18
18
  LakeStorageError,
19
19
  retryDatasetMergeCommit,
20
20
  } from "@sixb/core/lake-storage"
21
- import type { DuckLakeStorageOptions } from "../types"
21
+ import type { DuckLakeCatalogOptions, DuckLakeStorageOptions } from "../types"
22
22
  import { localCatalogCoordinationKey } from "./catalog-key"
23
23
  import {
24
24
  type ApplyDatasetRowsResult,
@@ -330,13 +330,12 @@ export class DuckLakeWriteCoordinator {
330
330
  // which snapshot belongs to this transaction.
331
331
  const previousWriteSnapshotId = await this.lastCommittedSnapshotId(input.runtime)
332
332
 
333
- await input.runtime.run("BEGIN TRANSACTION")
334
333
  let committed = false
335
334
  try {
336
335
  // Guarded Sixb commits need compare-and-swap semantics. DuckLake can
337
336
  // automatically retry non-conflicting commits, so guarded transactions
338
- // disable retries and re-check the dataset head after BEGIN.
339
- const latestVersion = await this.latestVersionForCommit(input)
337
+ // disable retries and validate a head belonging to the native write snapshot.
338
+ const latestVersion = await this.beginVersionTransaction(input)
340
339
  if (input.expectedLatestVersionId !== undefined) {
341
340
  this.assertExpectedLatestVersion({
342
341
  datasetId: input.dataset.id,
@@ -527,15 +526,16 @@ export class DuckLakeWriteCoordinator {
527
526
  private async ensureNoOpHasSnapshot(
528
527
  input: DuckLakeCommitVersionOutcomeRuntimeInput,
529
528
  changeResult: ApplyDatasetRowsResult,
530
- knownLatestVersion: DuckLakeVersionSummary | null
529
+ knownLatestVersion: DuckLakeVersionSummary | null | undefined
531
530
  ): Promise<void> {
532
531
  if (changeResult.dataChangeExpected) {
533
532
  return
534
533
  }
535
534
 
536
535
  const latestVersion =
537
- knownLatestVersion ??
538
- (await this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset))
536
+ knownLatestVersion === undefined
537
+ ? await this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset)
538
+ : knownLatestVersion
539
539
  if (
540
540
  latestVersion
541
541
  ? input.mode === "merge" || !hasDatasetInputChanges(latestVersion.inputs, input.inputs)
@@ -582,14 +582,49 @@ export class DuckLakeWriteCoordinator {
582
582
  }
583
583
  }
584
584
 
585
- private async latestVersionForCommit(
585
+ private async beginVersionTransaction(
586
586
  input: DuckLakeCommitVersionOutcomeRuntimeInput
587
- ): Promise<DuckLakeVersionSummary | null> {
588
- if (input.expectedLatestVersionId === undefined && input.mode !== "append") {
589
- return null
587
+ ): Promise<DuckLakeVersionSummary | null | undefined> {
588
+ if (!isSqliteCatalog(this.options.catalog)) {
589
+ await input.runtime.run("BEGIN TRANSACTION")
590
+ if (input.expectedLatestVersionId === undefined && input.mode !== "append") {
591
+ return undefined
592
+ }
593
+ return this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset)
590
594
  }
591
595
 
592
- return this.snapshots.getLatestVersionSummaryForDefinition(input.runtime, input.dataset)
596
+ // Direct metadata reads enlist a second SQLite transaction on the outer DuckDB connection.
597
+ // Its read lock blocks DuckLake's own metadata connection at COMMIT. Read in autocommit,
598
+ // then pin the native DuckLake snapshot and verify nothing changed in between. The summary
599
+ // is now valid for this write transaction, including null (no previous version).
600
+ // Only preparation can repeat: staged mutations and COMMIT are never replayed here.
601
+ for (let attempt = 0; attempt < 3; attempt++) {
602
+ input.signal?.throwIfAborted()
603
+ const snapshotId = await this.currentSnapshotId(input.runtime)
604
+ const latestVersion = await this.snapshots.getLatestVersionSummaryForDefinition(
605
+ input.runtime,
606
+ input.dataset
607
+ )
608
+ await input.runtime.run("BEGIN TRANSACTION")
609
+ if ((await this.currentSnapshotId(input.runtime)) === snapshotId) {
610
+ return latestVersion
611
+ }
612
+ await input.runtime.run("ROLLBACK")
613
+ }
614
+
615
+ throw new LakeConcurrencyError(
616
+ `[SixbDuckLake] Catalog changed during three write preparations for dataset '${input.dataset.id}'. Retry the write.`
617
+ )
618
+ }
619
+
620
+ private async currentSnapshotId(runtime: DuckDbQueryRuntime): Promise<string> {
621
+ const [row] = await runtime.query(
622
+ `SELECT id FROM ${quoteIdentifier(duckLakeAlias(this.options))}.current_snapshot()`
623
+ )
624
+ if (row === undefined) {
625
+ throw new LakeStorageError("[SixbDuckLake] DuckLake did not return a current snapshot.")
626
+ }
627
+ return String(getBigIntLike(row, "id"))
593
628
  }
594
629
 
595
630
  private async lastCommittedSnapshotId(runtime: DuckDbQueryRuntime): Promise<string | null> {
@@ -681,15 +716,21 @@ export class DuckLakeWriteCoordinator {
681
716
  }
682
717
  }
683
718
 
719
+ function isSqliteCatalog(catalog: DuckLakeCatalogOptions): boolean {
720
+ return (
721
+ catalog.type === "sqlite" ||
722
+ (catalog.type === "custom" && catalog.uri.toLowerCase().startsWith("sqlite:"))
723
+ )
724
+ }
725
+
684
726
  function commitRowCount(
685
727
  input: Pick<DuckLakeCommitVersionOutcomeInput, "expectedLatestVersionId" | "mode">,
686
728
  changeResult: ApplyDatasetRowsResult,
687
- latestVersion: DuckLakeVersionSummary | null
729
+ latestVersion: DuckLakeVersionSummary | null | undefined
688
730
  ): CommitRowCount {
689
731
  // Unguarded append commits can be retried by DuckLake after this transaction
690
732
  // read the previous latest version. In that case previousRowCount +
691
- // sourceRowCount can underreport the committed snapshot, so omit metadata
692
- // and let hydration count the exact committed snapshot instead.
733
+ // sourceRowCount can underreport the committed snapshot, so leave the count unknown.
693
734
  if (input.mode === "append" && input.expectedLatestVersionId === undefined) {
694
735
  return { kind: "unknown" }
695
736
  }
@@ -183,6 +183,7 @@ class DuckLakeWriteSession implements LakeWriteSession {
183
183
  }
184
184
 
185
185
  this.cleanedUp = true
186
+ this.stagedPrimaryKeys.clear()
186
187
  await this.dropStagingTable()
187
188
  }
188
189
 
package/src/types.ts CHANGED
@@ -151,6 +151,8 @@ export interface DuckLakeStorageOptions {
151
151
  readonly alias?: string
152
152
  readonly duckdb?: DuckDbRuntimeOptions
153
153
  readonly postgresPool?: DuckLakePostgresPoolOptions
154
+ /** Maximum native streaming readers per provider; extra reads use pages. Default: 4. */
155
+ readonly maxStreamingReads?: number
154
156
  readonly secrets?: readonly DuckDbSecretOptions[]
155
157
  /** Runs after DuckLake/catalog extensions load and before ATTACH. */
156
158
  readonly setupSql?: readonly string[]