Skip to content

feat: migrate to ADBC - #2

Merged
cigrainger merged 19 commits into
mainfrom
cg/adbc-migration
Mar 22, 2026
Merged

cigrainger merged 19 commits into
mainfrom
cg/adbc-migration

Conversation

@cigrainger

@cigrainger cigrainger commented Mar 22, 2026 •

Copy link
Copy Markdown
Contributor

What

Replace the Rust NIF layer (Rustler + duckdb-rs, ~830 lines) with ADBC — a precompiled C++ bridge to DuckDB maintained by the Livebook team. Dux becomes a pure Elixir project. No Rust toolchain needed for development or CI.

Why

The Rust NIF created significant burden:

  • Build: Rust toolchain required for all contributors. Fresh compiles took 2-5 minutes.
  • Release: 6-target precompiled NIF matrix. Tag → 20-40 min CI → download artifacts → checksums → publish.
  • Version coupling: DuckDB pinned to whatever duckdb-rs shipped (often months behind).
  • Contribution barrier: Rust knowledge required to touch the bridge layer.

Benchmarks: NIF vs ADBC

Operation NIF (v0.1.1) ADBC Change
from_query(10K) → compute 0.10ms 1.18ms 12x slower
from_list(100) → compute 3.41ms 6.04ms 1.8x slower
to_rows (1K) 3.69ms 6.78ms 1.8x slower
join (small) → compute 4.24ms 6.39ms 1.5x slower
from_list(10K) → compute 3675ms 5.81ms 633x faster
full pipeline → to_rows 625ms 4.88ms 128x faster
group_by + summarise 655ms 5.81ms 113x faster
filter + mutate → compute 3143ms 7.29ms 431x faster
to_columns (10K) 3150ms 8.08ms 390x faster

ADBC is slower for pure SQL queries (12x for `from_query`) due to temp table creation overhead — the NIF kept data in Arrow RecordBatches in Rust memory. ADBC is massively faster for `from_list` operations (100-633x) because v0.1.1 generated SQL `UNION ALL` for each row; ADBC uses `ingest` which bypasses SQL entirely.

How

New modules

  • `Dux.Backend` — pure Elixir ADBC wrapper. Query execution, temp table management via `ingest`, IPC serialization, Decimal normalization, SQL reserved word workarounds.
  • `Dux.TableRef` — replaces opaque NIF `ResourceArc`: temp table name + `Adbc.IngestResult` for GC + origin node.

How cross-node distribution works

All cross-node data transfer uses IPC serialization — plain BEAM binaries:

  1. Coordinator serializes → IPC binary (`Adbc.Result.to_ipc_stream/1`)
  2. IPC sent to worker via BEAM distribution
  3. Worker ingests IPC → local temp table with its own `IngestResult` (local GC)
  4. Worker returns results as IPC → Coordinator merges

The old `gc_sentinel.rs` and `Dux.Remote.place/1` were dead code — never called from production paths. Removed entirely.

Technical decisions

  1. `query_pointer` can't ingest on same connection — DuckDB connection is busy. Materialize first, then ingest.
  2. ADBC strips schema from zero-row results — `DESCRIBE` used for schema discovery.
  3. Empty IPC sentinel — `"DUX_EMPTY"` prefixed IPC with dummy NULL row for zero-row tables.
  4. Connection-scoped temp tables — Partitioner/Shuffle convert `{:table, %TableRef{}}` to `{:list, rows}` before sending to workers.
  5. SQL reserved word workaround — DuckDB ADBC driver doesn't quote column names. `ingest_safe` renames → ingests → renames back.
  6. Decimal normalization — SUM/COUNT return `Decimal`; `normalize_value/1` converts to integer/float.
  7. `from_list` >500 rows — uses `Adbc.Connection.ingest` instead of SQL VALUES.

Architecture cleanup

  • `Dux.Remote.HolderSupervisor` → `Dux.DynamicSupervisor` (general-purpose runtime supervisor)
  • Removed dead code: `Dux.Remote`, `Dux.Remote.Holder`, `Dux.Remote.LocalGC`, GC sentinel
  • `mix check` alias: format + compile(warnings-as-errors) + test + credo --strict

What we gained

  • Zero Rust code — pure Elixir project
  • No Rust toolchain for development or CI
  • No 6-target precompiled NIF build matrix
  • 633x faster `from_list` for large datasets
  • DuckDB driver downloaded at compile time via ADBC

What we lost

  • 12x slower `from_query → compute` (temp table overhead vs in-memory Arrow)
  • ArrowVTab zero-copy table registration (replaced by columnar copy via ingest)
  • Empty string preservation (ADBC converts `""` to `nil`)

Deleted

  • `native/dux/` — 830 lines of Rust (3,737 lines deleted total)
  • `lib/dux/native.ex`, `lib/dux/remote.ex`, `lib/dux/remote/holder.ex`, `lib/dux/remote/local_gc.ex`
  • `.github/workflows/release.yml`, `RELEASING.md`

Test plan

  • 435 tests pass (38 doctests, 11 properties, 386 tests, 0 failures)
  • All 40 peer/distributed tests pass (cross-node IPC, PageRank, CC, shuffle, broadcast)
  • 24 new ADBC edge case tests (Decimal, empty IPC sentinel, concurrent compute, graph, wicked)
  • Benchmark comparison: ADBC vs v0.1.1 NIF baseline
  • Flaky test fix (race in worker stop during `on_exit`)
  • `mix format --check-formatted` clean
  • `mix compile --warnings-as-errors` clean
  • `mix credo --strict` zero issues

🤖 Generated with Claude Code

cigrainger and others added 19 commits March 23, 2026 02:24
Dux.Backend replaces Dux.Native with pure Elixir wrapping ADBC:
- query/2: executes SQL, ingests result into temp table → %TableRef{}
- table_names/dtypes via DESCRIBE (ADBC LIMIT 0 returns no schema)
- table_to_columns/rows with Decimal→integer/float normalization
- table_to_ipc/from_ipc via Adbc.Result.to_ipc_stream/from_ipc_stream
- Error wrapping: Adbc.Error → ArgumentError for API compat
- Empty result handling: creates empty temp table via SQL

Dux.TableRef replaces opaque NIF ResourceArc with a struct:
- name: DuckDB temp table name
- gc_ref: %Adbc.IngestResult{} — prevents GC cleanup
- node: origin node for remote detection

DuckDB type mapping from both SQL strings (DESCRIBE) and ADBC atoms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes:
- Error wrapping: Adbc.Error → ArgumentError with "DuckDB query failed"
- Decimal normalization: SUM/COUNT Decimal results → integer/float
- Empty result handling: DESCRIBE for schema when ADBC returns no columns
- from_list ingest: large lists (>500 rows) go through ADBC ingest to
  avoid SQL expression depth limits; small lists use SQL VALUES
- Special column names: detected and routed to SQL path (ADBC ingest
  doesn't quote identifiers — DuckDB driver limitation)
- Write error messages: "DuckDB write failed" for COPY TO errors
- Test: is_reference(ref) → match?(%TableRef{}) for compute test

137 core tests pass (verb, query, IO, security, distribute API).
Graph and distributed tests still pending (Phases 2-3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migrated 18 call sites in graph.ex + 1 in graph/inspect.ex:
- table_ensure(db, ref) → ref.name (TableRef has the name)
- table_to_ipc/from_ipc → Backend equivalents with conn
- df_query → Backend.query (raises on error, no case needed)
- get_db() → get_conn()

26 graph tests + 5 karate club dataset tests + 1 graph E2E pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Worker, Merger, Broadcast, Coordinator all migrated from Dux.Native:
- Worker owns its own Adbc.Database + Connection (not shared)
- table_to_ipc materializes before to_ipc_stream (ADBC requirement)
- table_from_ipc materializes before ingest (ADBC requirement)
- Remote node detection via TableRef.node instead of node(nif_ref)
- GC sentinel stubbed (Phase 4) — sentinel tests skipped

Test files migrated: worker_test, shuffle_test, broadcast_test,
coordinator_test, distributed_test, connection_test, types_property_test,
flame_test, remote_test. native_test.exs replaced by backend_test.exs.

Most tests pass individually; some fail in sequence (test isolation
with shared ADBC connection — investigating).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ADBC temp tables are connection-local, so {:table, %TableRef{}} sources
can't be sent directly to workers (they don't exist on the worker's
connection). Fixed in three places:

- Partitioner.replicate: converts table sources to {:list, rows}
- Shuffle.slice_for_workers: same ensure_worker_safe conversion
- Backend.table_to_ipc: materialize before to_ipc_stream
- Backend.table_from_ipc: materialize before ingest

Also fixed Remote.setup_tracking to use TableRef.node instead of
node(nif_ref), and qi() quoting in worker register_table/append_chunk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ADBC can't serialize zero-row results to IPC (requires >= 1 row).
Fixed with sentinel-based approach:
- table_to_ipc: empty tables get a "DUX_EMPTY" prefixed IPC with a
  dummy NULL row that preserves schema
- table_from_ipc: detects prefix, ingests dummy row, then deletes it

Empty right side in broadcast join: Coordinator detects zero-row right
side and creates a schema-preserving empty query instead of trying
to serialize/broadcast.

Special column names in IPC: table_from_ipc detects columns with
spaces/special chars and uses safe rename → ingest → rename-back
to work around DuckDB ADBC driver's unquoted DDL.

423 tests, 2 failures (file-backed DB + empty string property).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Database path option: top-level kwarg to Adbc.Database, not process_options
- File-backed DB tests: DuckDB creates file lazily, write data first
- String property test: ADBC converts empty string to nil (min_length: 1)
- All 423 tests pass (38 doctests + 11 properties + 374 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed:
- native/dux/ (830 lines of Rust — database.rs, dataframe.rs,
  types.rs, gc_sentinel.rs, error.rs, lib.rs)
- lib/dux/native.ex (Rustler stub)
- .github/workflows/release.yml (6-target precompiled NIF matrix)
- RELEASING.md (NIF release process)

Updated:
- mix.exs: removed rustler/rustler_precompiled deps, checksum_files,
  Rust aliases, native/ from package files
- ci.yml: removed Rust toolchain, cargo cache, rust-lint job

Dux is now a pure Elixir project. DuckDB access via ADBC precompiled
driver. No Rust toolchain needed for development or CI.

423 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Coverage gaps identified by audit and addressed:
- Decimal normalization: SUM→integer, COUNT→integer, AVG→float,
  DECIMAL with/without fraction, negative Decimal
- Empty results: filter→empty preserves columns, empty through
  group_by+summarise, empty compute preserves names, empty distributed
- IPC: round-trip with multiple types, NULL values, DUX_EMPTY sentinel
- Concurrent: 10 parallel compute() on same pipeline, idempotency
- 3+ workers: 3-worker group_by+summarise, 3-worker broadcast join
- Graph: single-node degree, single-node pagerank, 25 disconnected
  components, self-loops
- Wicked: group_by with cardinality=row count, 30-deep pipeline chain,
  filter idempotency

447 tests, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- mix check: format --check-formatted + compile --warnings-as-errors +
  test --exclude distributed + credo --strict — run before every commit
- Fixed all credo --strict issues (nesting, arity, cond→if, TODO tag)
- Formatted all files
- Updated AGENTS.md: removed Rust workflow, added mix check instruction

453 tests, 0 failures, 0 credo issues, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Run against ADBC branch: mix run bench/compare_backend.exs
Run against v0.1.1 NIF: git checkout v0.1.1 && DUX_BUILD=true mix run bench/compare_backend.exs

Benchmarks: from_list, from_query, from_parquet, filter+mutate,
group_by+summarise, full pipeline, join, IPC round-trip, to_columns,
to_rows, distributed vs local.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixed ADBC ingest failing on SQL reserved word column names (e.g.,
"group", "select", "order"). Added @sql_reserved word list checked
in both Backend.query and Backend.table_from_ipc paths.

Also fixed:
- Module attribute ordering (sql_reserved_words before @sql_reserved)
- Unified has_special_column_names? check in query function
- preferred_cli_env deprecation warning
- Test assertion for broadcast join (uniq regions, not exact count)

477 total tests (38 doctests + 11 properties + 428 tests), 0 failures.
All 40 peer/distributed tests pass including cross-node IPC transfer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dux.Remote.place/1 was never called from production code — all
cross-node transfer uses IPC serialization. The GC sentinel (which
tracked remote NIF references) is not needed with ADBC's IPC approach.

Removed:
- lib/dux/remote.ex (place/1, setup_tracking, GC sentinel stub)
- test/dux/remote_test.exs (sentinel + place tests)

Kept: HolderSupervisor (used by FLAME), LocalGC, Holder (infrastructure)

Also fixed SQL reserved word check in Backend.query (unified with
has_special_column_names? instead of inline regex-only check).

435 tests, 0 failures, 0 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed dead code:
- lib/dux/remote.ex (place/1, GC sentinel stub — never called)
- lib/dux/remote/holder.ex (NIF ref holder — dead with ADBC)
- lib/dux/remote/local_gc.ex (sentinel message relay — dead)
- test/dux/remote_test.exs

Renamed Dux.Remote.HolderSupervisor → Dux.DynamicSupervisor
(general-purpose runtime child supervisor for FLAME pools + workers)

Benchmark script uses compiled modules per Benchee docs.

Benchmark comparison (v0.1.1 NIF vs ADBC):
- from_query(10K): NIF 0.10ms, ADBC 1.18ms (12x slower — temp table overhead)
- from_list(100): NIF 3.41ms, ADBC 6.04ms (1.8x slower)
- from_list(10K): NIF 3675ms, ADBC 5.81ms (633x FASTER — ingest vs SQL VALUES)
- full pipeline: NIF 625ms, ADBC 4.88ms (128x FASTER)

Net: ADBC is slower for pure SQL queries (temp table overhead) but
massively faster for from_list operations (ingest vs SQL generation).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Worker stop helpers had a race: Process.alive?(w) could return true
but the worker dies before GenServer.stop executes. Fixed all 9 test
files to use implicit try/catch :exit instead.

Added bench/results/adbc-migration.md with NIF vs ADBC comparison.

435 tests, 0 failures, 0 credo issues, 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New peer tests exercising ADBC-specific cross-node behavior:
- IPC type fidelity (int, float, string, bool, NULL, Decimal)
- Chained distributed: compute → filter → distribute again
- Chained: distributed group_by → collect → local join
- Distributed graph: connected components, shortest paths, triangle count
- Real dataset: nycflights star schema join, penguins group_by
- 3-worker peer test with AVG rewrite

bench/results/history.csv tracks benchmarks per version/SHA.

476 tests (38 doctests + 11 properties + 427 tests), 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ADBC's C++ NIF has GCC warnings on Ubuntu that fail with
--warnings-as-errors. Compile deps first (without the flag),
then compile our Elixir code with warnings-as-errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Alias Dux.Test.Datasets, replace length > 0 with != [].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cigrainger
cigrainger merged commit 0534abb into main Mar 22, 2026
3 checks passed
@cigrainger
cigrainger deleted the cg/adbc-migration branch March 23, 2026 01:49
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