Skip to content

Add DuckLake target support - #75

Merged
viggy28 merged 2 commits into
mainfrom
agent/ducklake-support
Aug 30, 2026
Merged

Add DuckLake target support#75
viggy28 merged 2 commits into
mainfrom
agent/ducklake-support

Conversation

@viggy28

@viggy28 viggy28 commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Adds DuckLake as an additive target format via --target-format=ducklake, using DuckDB's ducklake extension with a separate SQLite catalog DB. Iceberg remains the default and its implementation path stays separate.

This PR also includes a DuckLake writer performance pass after initial benchmarking showed most latency came from row-by-row staging through database/sql.

What Changed

  • Added DuckLake config and CLI flags:
    • --target-format=iceberg|ducklake
    • --ducklake-catalog
    • --ducklake-data-path
  • Added a new internal/ducklake writer/resync implementation backed by DuckDB extensions: ducklake, sqlite, httpfs.
  • Uses DuckLake SQL DML for mutation handling: staged keys + DELETE ... USING, then staged rows + INSERT ... SELECT.
  • Optimized DuckLake writer staging with DuckDB QueryAppender instead of one SQL insert per staged row.
  • Batches FlushAll across multiple DuckLake tables into one transaction while preserving per-table resume LSN metadata.
  • Adds table/schema reconciliation caching for DuckLake flushes.
  • Adds focused DuckLake unit tests, query-server test, integration tests, and writer benchmark matrices.
  • Adds a matching Iceberg writer benchmark matrix for local comparison.

Benchmark Notes

These are writer microbenchmarks, not full pipeline benchmarks. Iceberg uses Streambed's handwritten Iceberg writer with in-memory object storage. DuckLake uses DuckDB's DuckLake extension with a local SQLite catalog and local data path. The goal is to compare writer-path overhead and verify real performance improvements without changing benchmark shape.

DuckLake before vs after optimization:

Operation Shape Before After Speedup
insert 100 rows, 1 table 49.4 ms 29.0 ms 1.7x
insert 1,000 rows, 1 table 144.0 ms 34.4 ms 4.2x
insert 5,000 rows, 1 table 605.9 ms 37.9 ms 16.0x
insert 1,000 rows x 4 tables 634.8 ms 39.9 ms 15.9x
update 100 rows, 1 table 63.8 ms 28.8 ms 2.2x
update 1,000 rows, 1 table 258.5 ms 30.5 ms 8.5x
update 5,000 rows, 1 table 1.05 s 38.2 ms 27.5x
update 1,000 rows x 4 tables 881.0 ms 42.1 ms 20.9x
delete 100 rows, 1 table 34.3 ms 16.8 ms 2.0x
delete 1,000 rows, 1 table 113.2 ms 16.9 ms 6.7x
delete 5,000 rows, 1 table 474.5 ms 19.3 ms 24.6x
delete 1,000 rows x 4 tables 439.7 ms 31.5 ms 13.9x

Interpretation: after switching to DuckDB appenders, row-count sensitivity mostly disappears. Remaining latency is dominated by fixed DuckLake transaction/catalog/snapshot work and per-table DML.

Validation

  • go test ./config ./internal/... passed.
  • go test ./internal/ducklake -count=1 -v passed.
  • go test -run '^$' -bench 'BenchmarkWriterOperationMatrix' -benchtime=1x -count=5 ./internal/ducklake passed.
  • go test -run '^$' -bench 'BenchmarkWriterOperationMatrix' -benchtime=1x -count=3 ./internal/iceberg passed.
  • go build ./cmd/streambed passed, with a non-fatal sandbox stat-cache warning writing under the normal Go module cache.

Earlier in the session, focused Docker-backed DuckLake integration tests passed before the performance pass. After the performance pass, Docker daemon access was blocked by the sandbox, so I could not rerun those integration tests locally.

Known baseline note: the broader existing integration suite has two Iceberg failures on origin/main as well: TestFlushThresholdCreatesExpectedParquetAndMetadata and TestPostgresTypeRoundTripToIceberg.

@viggy28

viggy28 commented Aug 15, 2026

Copy link
Copy Markdown
Owner Author

Current performance is not good enough. We could try DuckDB as the catalog store (instead of sqlite)

@viggy28

viggy28 commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Benchmark update from this session: I added/ran a more realistic lakehouse benchmark matrix comparing Iceberg COW vs DuckLake with SQLite catalog vs DuckLake with DuckDB catalog.

Important benchmark shape:

  • Baseline table is loaded first in one bulk flush and reported separately.
  • write_duration_ms measures only the post-baseline workload.
  • Mutation size is 10% of baseline rows.
  • Flush sizes tested: 100, 500, 1,000, 10,000 buffered units.
  • Targets: iceberg-cow, ducklake-sqlite, ducklake-duckdb.
  • Read queries measured after write: full aggregate, selective range, point lookup, TopN.
  • Caveat: benchmark bypasses PostgreSQL logical replication and psql-wire overhead; it measures writer/catalog commit + DuckDB reads over MinIO.

100k baseline rows — write ms, objects in parens:

scenario    flush   iceberg-cow       ducklake-sqlite   ducklake-duckdb
append      100     1,731 (406)       770 (101)         542 (101)
append      500     236 (86)          189 (21)          151 (21)
append      1,000   120 (46)          120 (11)          88 (11)
append      10,000  26 (10)           40 (2)            35 (2)
update 10%  100     34,334 (806)      4,074 (401)       3,798 (401)
update 10%  500     6,753 (166)       851 (81)          800 (81)
update 10%  1,000   3,322 (86)        450 (41)          421 (41)
update 10%  10,000  346 (14)          82 (5)            73 (5)
delete 10%  100     16,420 (406)      1,502 (101)       1,153 (101)
delete 10%  500     3,214 (86)        362 (21)          376 (21)
delete 10%  1,000   1,582 (46)        190 (11)          131 (11)
delete 10%  10,000  163 (10)          44 (2)            38 (2)
mixed       100     53,381 (1,606)    6,991 (601)       6,255 (601)
mixed       500     10,668 (326)      1,473 (121)       1,230 (121)
mixed       1,000   5,118 (166)       735 (61)          607 (61)
mixed       10,000  527 (22)          122 (7)           123 (7)

1M baseline rows — write ms, objects in parens:

scenario    flush   iceberg-cow        ducklake-sqlite    ducklake-duckdb
append      100     —                  6,630 (1,005)      5,597 (1,005)
update 10%  100     —                  103,336 (4,005)    97,904 (4,005)
delete 10%  100     —                  29,966 (1,005)     26,780 (1,005)
mixed       100     —                  205,288 (6,005)    196,791 (6,005)
append      500     4,468 (806)        1,558 (205)        1,206 (205)
update 10%  500     539,958 (1,606)    14,277 (805)       13,308 (805)
delete 10%  500     256,689 (806)      5,639 (205)        5,325 (205)
mixed       500     804,298 (3,206)    29,479 (1,205)     28,312 (1,205)
append      1,000   2,227 (406)        955 (105)          732 (105)
update 10%  1,000   269,344 (806)      7,075 (405)        6,576 (405)
delete 10%  1,000   128,079 (406)      2,877 (105)        2,724 (105)
mixed       1,000   399,228 (1,606)    13,989 (605)       13,576 (605)
append      10,000  242 (46)           246 (15)           208 (15)
update 10%  10,000  26,999 (86)        891 (45)           834 (45)
delete 10%  10,000  12,836 (46)        416 (15)           405 (15)
mixed       10,000  40,040 (166)       1,792 (65)         1,697 (65)

For 1M + flush=100, Iceberg COW did not complete the full matrix within the 90m test timeout, so I only have DuckLake numbers for that flush size.

Example read medians for 1M baseline, flush=1,000 — all values ms:

scenario    target            full agg  range  point  topN
append      iceberg-cow       60        37     42     31
append      ducklake-sqlite   10        4      3      8
append      ducklake-duckdb   8         2      2      6
update 10%  iceberg-cow       42        14     15     52
update 10%  ducklake-sqlite   17        4      4      22
update 10%  ducklake-duckdb   16        2      2      21
delete 10%  iceberg-cow       36        12     14     48
delete 10%  ducklake-sqlite   14        3      3      22
delete 10%  ducklake-duckdb   12        3      2      20
mixed       iceberg-cow       56        35     38     32
mixed       ducklake-sqlite   24        4      4      27
mixed       ducklake-duckdb   25        2      2      24

Takeaway: with realistic small CDC flush sizes, DuckLake is dramatically faster for mutation-heavy workloads and generally has better read latencies through DuckDB. DuckDB catalog is usually a bit faster than SQLite catalog, especially on point/range reads and commit-heavy workloads.

Benchmark artifacts from this run were written locally to:

  • /tmp/streambed-bench/lakehouse-matrix-100k.json
  • /tmp/streambed-bench/lakehouse-matrix-1m-500plus.json
  • /tmp/streambed-bench/lakehouse-matrix-1m-flush100-ducklake.json

The benchmark harness itself is in test/integration/lakehouse_feature_benchmark_test.go and is opt-in via STREAMBED_RUN_LAKEHOUSE_FEATURE_BENCH=1.

@viggy28
viggy28 marked this pull request as ready for review August 30, 2026 05:19
@viggy28
viggy28 merged commit cf3be44 into main Aug 30, 2026
4 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb3f4b84bc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +384 to +388
return err
}
}
if rows > 0 {
if err := w.stageRowsAndInsert(ctx, conn, buf); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve delete ordering within each buffered batch

When a key is inserted or updated and then deleted before the buffer flushes, the replacement remains in buf.Rows while its key is also placed in buf.Deletes. This flush applies every delete first and then inserts every buffered row, so the deleted replacement is reinserted and remains visible; primary-key chains such as A→B→C similarly retain intermediate rows. Track net-deleted keys and suppress superseded buffered rows before inserting.

Useful? React with 👍 / 👎.

Comment on lines +534 to +536
if !exists {
w.logger.Info("ducklake truncate on not-yet-created table, no-op", "table", event.Schema+"."+event.Table)
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear buffered rows before returning from a new-table truncate

If a newly discovered table receives inserts and then a TRUNCATE before its first threshold or interval flush, the DuckLake table does not exist yet and this branch returns without clearing the in-memory buffer. The next flush therefore creates the table and writes all pre-TRUNCATE rows back into it. Discard the table's pending rows and deletes before the existence check, as is done for an already-created table.

Useful? React with 👍 / 👎.

Comment on lines +609 to +613
for _, col := range columns {
if existing[col.Name] {
continue
}
stmt := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", tableName, quoteIdent(col.Name), pgOIDToDuckDBType(col.OID))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reconcile dropped and changed columns after restart

When a PostgreSQL column is dropped or type-changed while the daemon is stopped, the decoder's first relation message has no prior schema and therefore does not invoke HandleSchemaChange; this restart reconciliation path then treats every same-named column as valid and only adds missing columns. Dropped columns consequently remain query-visible, while changed columns retain the old DuckLake type and can coerce incorrectly or make later appends fail (for example, INTEGER→BIGINT once values exceed the old range). Compare existing types and remove columns absent from the incoming schema here as well.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant