@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
@@ -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
 
@@ -60,21 +60,50 @@ export function buildSetPostgresPoolSql(
60
60
  * Render a fully-qualified DuckLake metadata table path.
61
61
  *
62
62
  * PostgreSQL catalogs expose DuckLake metadata tables under the configured
63
- * metadata schema inside the attached metadata catalog. Local catalogs use
64
- * DuckDB's default `main` schema.
63
+ * metadata schema (PostgreSQL's `public` by default) inside the attached
64
+ * metadata catalog. Local catalogs use DuckDB's default `main` schema.
65
65
  */
66
66
  export function duckLakeMetadataTableName(
67
67
  options: Pick<DuckLakeStorageOptions, "alias" | "catalog">,
68
68
  tableName: string
69
69
  ): string {
70
- const schemaName =
71
- options.catalog.type === "postgres" ? (options.catalog.metadataSchema ?? "main") : "main"
72
-
73
70
  return `${quoteIdentifier(duckLakeMetadataCatalog(options))}.${quoteIdentifier(
74
- schemaName
71
+ duckLakeMetadataSchema(options)
75
72
  )}.${quoteIdentifier(tableName)}`
76
73
  }
77
74
 
75
+ /**
76
+ * Render a metadata query that runs where the catalog lives.
77
+ *
78
+ * DuckDB's PostgreSQL scanner pushes comparisons and constant IN lists down to PostgreSQL, but not
79
+ * string matching, so a query that filters on text would copy the whole column into DuckDB. On a
80
+ * PostgreSQL catalog the query therefore runs server-side through `postgres_query`; elsewhere the
81
+ * catalog is local and DuckDB runs it. `build` must produce SQL that both dialects accept.
82
+ */
83
+ export function buildDuckLakeMetadataQuery(
84
+ options: Pick<DuckLakeStorageOptions, "alias" | "catalog">,
85
+ build: (table: (tableName: string) => string) => string
86
+ ): string {
87
+ if (options.catalog.type !== "postgres") {
88
+ return build((tableName) => duckLakeMetadataTableName(options, tableName))
89
+ }
90
+
91
+ // Unqualified names follow the connection's search path, as DuckLake's own writes do.
92
+ const schema = options.catalog.metadataSchema
93
+ const sql = build((tableName) =>
94
+ schema === undefined
95
+ ? quoteIdentifier(tableName)
96
+ : `${quoteIdentifier(schema)}.${quoteIdentifier(tableName)}`
97
+ )
98
+ return `SELECT * FROM postgres_query(${quoteSqlString(duckLakeMetadataCatalog(options))}, ${quoteSqlString(sql)})`
99
+ }
100
+
101
+ function duckLakeMetadataSchema(options: Pick<DuckLakeStorageOptions, "catalog">): string {
102
+ if (options.catalog.type !== "postgres") return "main"
103
+ // Without a metadata schema DuckLake writes to PostgreSQL's default schema.
104
+ return options.catalog.metadataSchema ?? "public"
105
+ }
106
+
78
107
  /**
79
108
  * Quote a SQL identifier after validating it is already identifier-shaped.
80
109
  *
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[]