Skip to content

Commit 01b3834

Browse files
timsaucerclaude
andcommitted
feat: ADBC-backed Spark DataSource for DataFusion table providers
Add a Spark DataSourceV2 connector that reads from a DataFusion TableProvider through a standard ADBC driver. Spark talks to the upstream arrow-adbc Java driver manager (adbc-core + adbc-driver-jni), which loads a native DataFusion ADBC cdylib and returns arrow-java ArrowReaders consumed zero-copy as ArrowColumnVectors on the cluster-provided Arrow. Spark connector (spark/): - AdbcDatafusionTableProvider: registers the `adbc-datafusion` format; schema probed once on the driver via AdbcConnection.getTableSchema. - Scan with projection/filter/limit pushed into the scan as a Substrait plan, with a SQL fallback path. - Multi-partition reads via executePartitioned/readPartition; the target_partitions option tunes scan parallelism. - Per-executor AdbcConnectionPool caches the AdbcDatabase per driver + options key; each task opens its own connection off it (connections are not shared across tasks: the arrow-adbc FFI exporter aliases &mut to one connection, which would be undefined behavior). Example driver (examples/adbc-datafusion-driver/): a DataFusion ADBC cdylib exercising the full stack, with a PySpark end-to-end script and partitioning tests. Docs: user-guide page "DataFusion as a Spark data source (ADBC)" in the documentation site, covering what the connector provides, how to read from Spark, and how DataFusion's parallelism-bound partitioning maps onto Spark's byte-bound task model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ab57cb9 commit 01b3834

29 files changed

Lines changed: 3486 additions & 1 deletion

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ rust-target/
66
*.iml
77
.DS_Store
88
tpch-data/
9+
# Lockfile for the example ADBC driver crate (a demo cdylib, not a published lib).
10+
examples/adbc-datafusion-driver/Cargo.lock
911
.claude
1012
docs/superpowers
1113
docs/build/
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# DataFusion as a Spark data source (ADBC)
21+
22+
This repository ships a Spark connector that lets a Spark job read from a
23+
DataFusion `TableProvider` as a native Spark `DataSourceV2`. Spark sees an
24+
ordinary external table; the data is produced by DataFusion in native code and
25+
handed back as Apache Arrow batches.
26+
27+
## What this repository provides
28+
29+
Two pieces work together:
30+
31+
- **The `adbc-datafusion` Spark connector** (`spark/`) — a Spark
32+
`DataSourceV2` registered under the format name `adbc-datafusion`. It builds
33+
the scan, pushes projection / filters / limit down, maps DataFusion's scan
34+
partitions onto Spark input partitions, and imports each partition's Arrow
35+
data into Spark **zero-copy** as an `ArrowColumnVector`.
36+
- **An example DataFusion ADBC driver** (`examples/adbc-datafusion-driver/`) —
37+
a small native library (cdylib) that exposes a DataFusion `TableProvider`
38+
through the [ADBC](https://arrow.apache.org/adbc/) C API. It is a worked
39+
example you can copy: your own driver registers whatever provider you want and
40+
is loaded the same way.
41+
42+
The connector is built on the standard Apache Arrow ADBC Java driver manager
43+
(`adbc-core` + `adbc-driver-jni`), so it talks to the native driver through a
44+
stable, public interface. Because that boundary is plain ADBC, the same
45+
connector can front **any** ADBC-speaking source; the DataFusion driver shown
46+
here is just one example, and you can point it at a different ADBC driver
47+
without changing the connector.
48+
49+
```text
50+
Spark DataSourceV2 (adbc-datafusion)
51+
→ arrow-adbc Java driver manager (adbc-core + adbc-driver-jni)
52+
→ native ADBC driver (the DataFusion cdylib)
53+
→ your DataFusion TableProvider
54+
```
55+
56+
## Reading from Spark
57+
58+
Point the `driver` option at the built driver library and the `table` option at
59+
a table the provider exposes:
60+
61+
```scala
62+
val df = spark.read
63+
.format("adbc-datafusion")
64+
.option("driver", "/abs/path/to/libadbc_datafusion_example_driver.so")
65+
.option("entrypoint", "AdbcDatafusionExampleInit")
66+
.option("table", "example")
67+
.load()
68+
69+
df.filter("id > 1").select("name").show()
70+
```
71+
72+
The connector probes the schema once (on the driver) when the `DataFrame` is
73+
created, then plans the scan. `df.filter(...)` and `df.select(...)` are pushed
74+
into the DataFusion scan where possible (see below); the rest Spark evaluates
75+
itself.
76+
77+
## Two partitioning models that must be reconciled
78+
79+
The heart of the connector is mapping DataFusion's idea of a partitioned scan
80+
onto Spark's idea of tasks. The two engines size scans on **different
81+
principles**, and a provider author who ignores the difference will get a job
82+
that runs but performs badly.
83+
84+
### DataFusion: parallelism-bound
85+
86+
DataFusion partitions a scan to **saturate the cores of one machine**. Its
87+
built-in `ListingTable`, for example, packs input files into roughly
88+
`target_partitions` groups — where `target_partitions` defaults to the local
89+
core count — using a minimum-size floor and optional single-file splitting only
90+
to *reach* that count. The partition count is therefore approximately
91+
92+
```text
93+
N ≈ target_partitions ≈ cores
94+
```
95+
96+
independent of how much data there is. More data means **bigger** partitions,
97+
not more of them.
98+
99+
### Spark: one task per partition, byte-bound
100+
101+
Spark turns **each input partition into one task**, and a native Spark file
102+
source sizes partitions by **bytes**: it targets roughly one partition per
103+
`spark.sql.files.maxPartitionBytes` (default 128 MB), cutting files into splits
104+
and bin-packing them to that size. (Spark shrinks the target below 128 MB when
105+
the data is small, so that there are at least enough partitions to keep every
106+
core busy.)
107+
108+
So large data means **many** partitions — typically far more than the cluster
109+
has cores — which run in waves. Each task processes a bounded slice (≈128 MB),
110+
which is what keeps memory bounded and lets Spark reschedule stragglers.
111+
112+
### How the connector joins them
113+
114+
For a scan that only pushes projection / filter / limit (no DataFusion-side
115+
join or aggregation), the physical plan's output partitioning **is** the
116+
provider's `scan()` partitioning, and the mapping to Spark is **1:1**:
117+
118+
```text
119+
provider.scan() output partitions
120+
→ DataFusion physical plan partition count N
121+
→ N ADBC partition descriptors
122+
→ N Spark input partitions = N Spark tasks (scan stage)
123+
```
124+
125+
Spark never splits or merges a partition afterward — `df.rdd().getNumPartitions()
126+
== N`, and the cluster core count only bounds how many of the `N` run at once.
127+
128+
**The consequence you must plan for:** suppose a provider keeps DataFusion's
129+
default of `N = target_partitions ≈ cores` partitions. On a large dataset that
130+
produces only a handful of partitions, each holding about `total_bytes / cores`
131+
of data — so Spark runs a few very large tasks. Because a task is the unit of
132+
work, those oversized tasks bring memory pressure, stragglers that hold up the
133+
whole stage, and no way for Spark to rebalance. To feed Spark well, a provider
134+
should size partitions by **bytes** — the way a Spark file source does — rather
135+
than by the `target_partitions` count.
136+
137+
## Sizing the scan
138+
139+
Aim for the Spark sweet spot:
140+
141+
- **Floor:** `N` ≥ total executor cores, or cores sit idle. `~2–4× cores` gives
142+
slack for skew and stragglers.
143+
- **Per-partition size:** big enough to amortize per-task overhead — the
144+
canonical target is **128–256 MB** of data per partition (matching Spark's
145+
`spark.sql.files.maxPartitionBytes` default of 128 MB).
146+
- **Ceiling — and ADBC lowers it.** Beyond Spark's generic per-task cost, this
147+
path adds two per-partition costs: each of the `N` ADBC descriptors carries a
148+
copy of the **whole serialized physical plan**, and each task **deserializes**
149+
that plan when it reads its partition. (The driver caches a deserialized plan
150+
per connection, but that does not help here: each Spark task uses its own
151+
connection and reads a single partition, so the plan is deserialized once per
152+
task and never reused.) Tens of thousands of tiny partitions is far costlier
153+
here than for a native file source. Keep `N` in the hundreds; prefer bigger
154+
partitions over a huge count.
155+
156+
Rule of thumb, balanced by **bytes** rather than split count:
157+
158+
```text
159+
N ≈ clamp(total_bytes / target_bytes, floor=cores, ceiling≈hundreds)
160+
```
161+
162+
## Writing a provider that feeds Spark well
163+
164+
Your provider's `scan()` receives the session, so it can read the connector's
165+
parallelism hint and then decide based on what it knows about the data:
166+
167+
```text
168+
T = state.config().target_partitions() // connector hint ≈ desired parallelism
169+
target_bytes = ~128–256 MB // bias larger for ADBC to keep N down
170+
min_bytes = floor so partitions never get tiny // ~ tens of MB
171+
172+
if total_bytes known AND splittable (files / row groups / key ranges):
173+
N = clamp(ceil(total_bytes / target_bytes), 1, CEILING) // byte-bound
174+
bin-pack splits into N groups BALANCED BY BYTES // not by count
175+
do not split below min_bytes; merge small splits
176+
177+
elif split_count known but not bytes (shards / external partitions):
178+
N = min(split_count, CEILING) // natural partitioning
179+
if split_count >> T: coalesce shards into ~T balanced groups
180+
181+
else (size and splits unknown; opaque / streaming):
182+
N = T // fall back to the hint
183+
lean slightly high — stragglers hurt more than overhead
184+
```
185+
186+
- **Balance by bytes, not by split count.** A task is atomic; Spark cannot
187+
rebalance a fat partition. One 10 GB partition among 100 small ones stalls the
188+
whole stage. Bin-pack so partition bytes are even.
189+
- **`target_partitions` is a hint, cap, and fallback** — not the data-bound
190+
count. Use it when bytes are unknown or as a parallelism ceiling; when you know
191+
the data size, let **bytes** drive `N`.
192+
193+
### Planning happens once, on the driver
194+
195+
Your provider's `scan()` runs **once**, on the driver, while the multi-partition
196+
descriptors are built. The driver plans the query, fixes the partitioning, and
197+
**serializes the whole physical plan** into each descriptor. Each executor task
198+
then deserializes that plan and executes its partition index — it does **not**
199+
call `scan()` again or re-plan. So partition `index i` always means the same
200+
slice: there is no second planning pass that could disagree with the first, and
201+
no need to make `N` stable across a driver/executor boundary.
202+
203+
The one requirement this places on a provider is that its plan be
204+
**serializable**: built-in DataFusion nodes round-trip through the default codec,
205+
but a custom `ExecutionPlan` node needs a `PhysicalExtensionCodec` registered
206+
with the driver so it can be encoded on the driver and decoded on the executor.
207+
208+
## Connector options
209+
210+
| Option | Required | Meaning |
211+
| --- | --- | --- |
212+
| `driver` | yes | Path to (or manifest name of) the native ADBC driver library. |
213+
| `entrypoint` | depends on driver | Name of the driver's C init symbol (e.g. `AdbcDatafusionExampleInit`). |
214+
| `table` | yes | Table the provider exposes; its schema is probed on the driver. |
215+
| `target_partitions` | no | Parallelism hint. Defaults to `SparkContext.defaultParallelism` (total executor cores). The connector issues `SET datafusion.execution.target_partitions = N` on the planning session, so it influences the partition count when the driver builds the physical plan; that plan (with its partitioning fixed) is what gets serialized into the descriptors. Pass `k × cores` to raise parallelism. |
216+
| `manifest.path` | no | Extra search path for ADBC driver manifests when `driver` is a name rather than an absolute path. |
217+
| *(anything else)* | no | Forwarded verbatim as native ADBC database options, so provider-specific knobs pass straight through. |
218+
219+
Two things are intentionally **not** connector options:
220+
221+
- **`maxPartitionBytes`** — byte-sizing lives in the provider's `scan()`; the
222+
connector does not know the data size and cannot derive it. Pass a sizing knob
223+
as a provider-specific option if your provider supports one.
224+
- **Reported partitioning / storage-partitioned joins
225+
(`SupportsReportPartitioning`)** — not available over ADBC today: the
226+
descriptor carries no distribution metadata, and DataFusion's hash
227+
partitioning does not match Spark's key-grouped / bucketed model.
228+
229+
## Summary
230+
231+
- The connector maps the provider's `scan()` partition count **1:1** onto Spark
232+
tasks.
233+
- DataFusion sizes scans to **cores** (parallelism-bound); Spark sizes them to
234+
**bytes** (one task per ~128 MB). To feed Spark well, size your provider's
235+
partitions by bytes — typically 128–256 MB each, floored at executor cores and
236+
capped in the hundreds because every descriptor carries the full serialized
237+
plan and each task deserializes it.
238+
- Planning happens once on the driver; the physical plan is serialized into each
239+
descriptor and executors run it as-is, so a custom `ExecutionPlan` node just
240+
needs a registered `PhysicalExtensionCodec` to be serializable.

‎docs/source/user-guide/index.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ DataFrame queries execute in native Rust; results return to the JVM as
2626
Data Interface.
2727

2828
This guide covers installation, the `SessionContext` and `DataFrame` APIs,
29-
and Parquet ingestion.
29+
Parquet ingestion, table providers, and using DataFusion as a Spark data
30+
source over ADBC.
3031

3132
```{toctree}
3233
:maxdepth: 1
@@ -39,6 +40,7 @@ parquet
3940
proto-plans
4041
scalar-udf
4142
table-provider
43+
adbc-spark-connector
4244
api-reference
4345
```
4446

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
[package]
19+
name = "adbc-datafusion-example-driver"
20+
version = "0.1.0"
21+
edition = "2021"
22+
publish = false
23+
description = "End-to-end example: an ADBC driver that exposes a custom DataFusion TableProvider"
24+
25+
# Standalone crate that stands in for a real, separate driver repo. The empty
26+
# [workspace] table makes it its own workspace root so it is NOT pulled into the
27+
# parent datafusion-java workspace (whose members are listed explicitly).
28+
[workspace]
29+
30+
[dependencies]
31+
# The ready-made ADBC-over-DataFusion driver. We depend on it as a LIBRARY and
32+
# only customize the SessionContext via its new_with_context_init hook -- no
33+
# fork, no datafusion-ffi module loading. Pulled from its own repo (git) since
34+
# that is where it lives.
35+
adbc-driver-datafusion = { git = "https://github.com/adbc-drivers/datafusion" }
36+
adbc_core = "0.23"
37+
adbc_ffi = "0.23"
38+
datafusion = "53"
39+
async-trait = "0.1"
40+
41+
# Build both an rlib (so the provider/driver can be unit-tested or embedded) and
42+
# a cdylib (the artifact the arrow-adbc Java driver manager loads at runtime).
43+
[lib]
44+
crate-type = ["cdylib", "lib"]
45+
46+
# Temporary: redirect adbc-driver-datafusion to the PR branch that implements
47+
# execute_partitions / read_partition (adbc-drivers/datafusion#32), so we can run
48+
# a full multi-partition system test before it merges. The crate is not on
49+
# crates.io, so this patches the git source (not [patch.crates-io]). Remove once
50+
# the PR is merged and the canonical dependency carries the change.
51+
[patch."https://github.com/adbc-drivers/datafusion"]
52+
adbc-driver-datafusion = { git = "https://github.com/timsaucer/adbc-driver-datafusion", branch = "feat/execute-partitions" }

0 commit comments

Comments
 (0)