Skip to content

DataFusion Integration

RockLake provides a native integration with Apache DataFusion through the rocklake-datafusion crate. This allows Rust applications using DataFusion as their query engine to access DuckLake catalogs directly — without DuckDB, without the PG-wire protocol, and without any network overhead. The catalog is accessed in-process through DataFusion's trait system, making it a natural fit for building custom analytics applications, data platforms, and streaming systems in Rust.

DataFusion is a fast, extensible query engine written in Rust. It is used as the foundation for numerous data systems including Apache Arrow DataFusion itself, InfluxDB IOx, Ballista, and many custom analytics platforms. By implementing DataFusion's catalog traits, RockLake becomes a first-class citizen in this ecosystem.

Architecture

The integration implements three core DataFusion traits that form the catalog hierarchy:

graph TD
    DF[DataFusion SessionContext] --> CP[CatalogProvider<br/>RockLakeCatalog]
    CP --> SP1[SchemaProvider<br/>RockLakeSchema: analytics]
    CP --> SP2[SchemaProvider<br/>RockLakeSchema: staging]
    SP1 --> TP1[TableProvider<br/>RockLakeTable: events]
    SP1 --> TP2[TableProvider<br/>RockLakeTable: users]
    SP2 --> TP3[TableProvider<br/>RockLakeTable: raw_events]

    TP1 --> Files[Data Files in Object Storage]
    TP2 --> Files
    TP3 --> Files

Trait Implementations

DataFusion Trait RockLake Implementation Responsibility
CatalogProvider RockLakeCatalog Lists schemas, provides schema access
SchemaProvider RockLakeSchema Lists tables within a schema, provides table access
TableProvider RockLakeTable Provides schema (columns), statistics, creates scan plans

When DataFusion plans a query against a RockLake-backed table, it calls the TableProvider to get the Arrow schema, table statistics, and the list of Parquet data files. Then DataFusion's built-in Parquet reader handles the actual data reading — RockLake is only responsible for telling DataFusion where to look.

Usage

Basic Example

use rocklake_datafusion::RockLakeCatalog;
use datafusion::prelude::*;
use std::sync::Arc;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create a DataFusion session
    let ctx = SessionContext::new();

    // Open a RockLake catalog from object storage
    let catalog = RockLakeCatalog::open("s3://my-bucket/lakehouse/catalog/").await?;

    // Register it with DataFusion under the name "lake"
    ctx.register_catalog("lake", Arc::new(catalog));

    // Query tables through standard SQL
    let df = ctx.sql("SELECT event_type, count(*) as cnt 
                      FROM lake.analytics.events 
                      WHERE timestamp > '2024-03-01' 
                      GROUP BY event_type 
                      ORDER BY cnt DESC")
        .await?;

    df.show().await?;
    Ok(())
}

With Custom Object Storage Configuration

use rocklake_datafusion::{RockLakeCatalog, CatalogConfig};
use object_store::aws::AmazonS3Builder;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Configure object storage explicitly
    let config = CatalogConfig::builder()
        .storage_url("s3://my-bucket/catalog/")
        .aws_region("us-east-1")
        .aws_endpoint("https://s3.us-east-1.amazonaws.com")
        .build();

    let catalog = RockLakeCatalog::open_with_config(config).await?;

    let ctx = SessionContext::new();
    ctx.register_catalog("lake", Arc::new(catalog));

    // Register the same S3 object store for DataFusion to read Parquet files
    let s3 = AmazonS3Builder::from_env()
        .with_bucket_name("my-bucket")
        .build()?;
    ctx.register_object_store("s3://my-bucket", Arc::new(s3));

    let df = ctx.sql("SELECT * FROM lake.analytics.events LIMIT 10").await?;
    df.show().await?;
    Ok(())
}

Reading at a Specific Snapshot

use rocklake_datafusion::RockLakeCatalog;

// Open catalog at a specific point in time
let catalog = RockLakeCatalog::open_at_snapshot("s3://bucket/catalog/", 1000).await?;
ctx.register_catalog("lake_snapshot", Arc::new(catalog));

// All queries through this catalog see the state at snapshot 1000
let df = ctx.sql("SELECT count(*) FROM lake_snapshot.analytics.events").await?;

Multiple Catalogs

// Register multiple catalogs for cross-catalog queries
let prod = RockLakeCatalog::open("s3://prod-bucket/catalog/").await?;
let staging = RockLakeCatalog::open("s3://staging-bucket/catalog/").await?;

ctx.register_catalog("prod", Arc::new(prod));
ctx.register_catalog("staging", Arc::new(staging));

// Compare data across environments
let df = ctx.sql("
    SELECT 'prod' as env, count(*) as rows FROM prod.analytics.events
    UNION ALL
    SELECT 'staging' as env, count(*) as rows FROM staging.analytics.events
").await?;

Creating a Provider from an Existing CatalogStore (v0.27.2)

When your application already holds a CatalogStore (for example, in an embedded server that uses both the PG-Wire interface and DataFusion), use RockLakeCatalogProvider::from_catalog_store() to avoid opening a second connection to the same catalog:

use rocklake_catalog::store::CatalogStore;
use rocklake_datafusion::RockLakeCatalogProvider;
use datafusion::prelude::*;
use std::sync::Arc;

// Assume `store` is an Arc<Mutex<CatalogStore>> already open.
let provider = RockLakeCatalogProvider::from_catalog_store(store.clone()).await?;

let ctx = SessionContext::new();
ctx.register_catalog("lake", Arc::new(provider));

Key difference from RockLakeCatalog::open(): from_catalog_store() reads the data_path key from the catalog metadata automatically, so you do not need to supply data_root explicitly. The bridge uses a single persistent background thread (started at construction time) to run async catalog calls from DataFusion's synchronous TableProvider trait, avoiding per-query thread spawns.

What This Enables

Rust-Native Analytics Applications

Build analytics backends in Rust that access DuckLake catalogs without depending on DuckDB or Python:

// Example: REST API that queries a DuckLake catalog
async fn query_handler(req: QueryRequest) -> Result<QueryResponse, Error> {
    let ctx = get_session_context(); // Pre-configured with RockLake catalog
    let df = ctx.sql(&req.sql).await?;
    let batches = df.collect().await?;
    Ok(QueryResponse::from_arrow(batches))
}

Custom Query Engines

If you are building a specialized query engine on top of DataFusion (streaming analytics, time-series processing, ML feature stores), RockLake provides the table metadata layer:

// Streaming analytics that reads catalog metadata from RockLake
// and processes new Parquet files as they arrive
async fn process_new_files(catalog: &RockLakeCatalog) {
    let table = catalog.get_table("analytics", "events").await?;
    let new_files = table.files_since_snapshot(last_processed_snapshot).await?;
    for file in new_files {
        process_parquet_file(&file.path).await?;
    }
}

Testing and Validation

Use DataFusion to validate catalog consistency by querying the same catalog from both DuckDB and DataFusion and comparing results:

// Integration test: verify RockLake returns same results through both paths
#[tokio::test]
async fn test_catalog_consistency() {
    let catalog = RockLakeCatalog::open("./test-catalog/").await.unwrap();
    let ctx = SessionContext::new();
    ctx.register_catalog("lake", Arc::new(catalog));

    let df = ctx.sql("SELECT count(*) FROM lake.public.events").await.unwrap();
    let batches = df.collect().await.unwrap();
    let count: i64 = batches[0].column(0).as_primitive::<Int64Type>().value(0);

    assert_eq!(count, expected_count_from_duckdb);
}

Embedded Analytics

For applications that need analytics capabilities without external dependencies:

// Self-contained analytics binary
fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        let catalog = RockLakeCatalog::open("./data/catalog/").await.unwrap();
        let ctx = SessionContext::new();
        ctx.register_catalog("local", Arc::new(catalog));

        // Run analytics queries
        let report = ctx.sql("SELECT ...").await.unwrap();
        export_to_csv(report, "output.csv").await;
    });
}

Supported Operations

The DataFusion integration is currently read-only:

Operation Supported Notes
List schemas Via CatalogProvider::schema_names()
List tables Via SchemaProvider::table_names()
Get table schema (columns) Via TableProvider::schema()
Get data file locations Via custom scan plan
Table statistics (row count, byte size) Via TableProvider::statistics()
Column statistics (min/max/null_count) Via TableProvider::statistics()
Partition pruning Using column statistics
Projection pushdown Only reads needed columns
Filter pushdown Pushes predicates to Parquet reader
CREATE TABLE Use PG-wire sidecar or CLI
INSERT Use PG-wire sidecar or CLI
DROP TABLE Use PG-wire sidecar or CLI
ALTER TABLE Use PG-wire sidecar or CLI
Time travel (per-query) Open catalog at specific snapshot

Write operations are not supported through the DataFusion interface. To mutate the catalog, use the PG-wire sidecar (Strategy B), the CLI, or the rocklake-catalog crate directly.

Dependencies

Add to your Cargo.toml:

[dependencies]
rocklake-datafusion = "0.8"
datafusion = "45"
tokio = { version = "1", features = ["full"] }
object-store = "0.12"       # For configuring data file access

Version Compatibility

The rocklake-datafusion crate tracks DataFusion's major versions:

rocklake-datafusion DataFusion Rust Edition
0.8.x 45.x 2021
0.7.x 44.x 2021
0.6.x 43.x 2021

DataFusion's trait interfaces change between major versions (the project is pre-1.0 and evolving rapidly). Each RockLake release is pinned to a specific DataFusion major version.

Transitive Dependencies

rocklake-datafusion brings in:

  • rocklake-catalog — Core catalog implementation
  • rocklake-core — Key encoding, protobuf, types
  • slatedb — LSM-tree storage engine
  • object_store — Object storage abstraction (S3, GCS, Azure, local)
  • prost — Protobuf encoding/decoding
  • arrow — Apache Arrow memory format

Total dependency footprint is moderate (~200 crates in the dependency tree).

Performance

Because the integration is in-process, catalog lookups are dominated by SlateDB read latency (which depends on object storage):

Operation Local Storage S3 Standard S3 Express
List schemas 50μs 5–20ms 1–5ms
Describe table 50μs 5–20ms 1–5ms
List files (10) 100μs 10–30ms 2–10ms
Full query plan 200μs 20–60ms 5–15ms

For development (local storage), the integration is extremely fast. For production (cloud object storage), latency depends on the storage tier.

Error Handling

The integration propagates errors through DataFusion's error types:

use datafusion::error::DataFusionError;

match ctx.sql("SELECT * FROM lake.nonexistent.table").await {
    Ok(df) => { /* success */ },
    Err(DataFusionError::Plan(msg)) => {
        // Schema or table not found
        eprintln!("Planning error: {}", msg);
    },
    Err(DataFusionError::External(e)) => {
        // Storage or catalog error
        eprintln!("External error: {}", e);
    },
    Err(e) => {
        eprintln!("Other error: {}", e);
    }
}

Use Cases

Embedded Analytics Application

The most common use case for the DataFusion integration is building applications that need SQL query capabilities without running a separate database server. The application links against rocklake-datafusion and gains the ability to query lakehouse tables directly:

// Example: REST API that queries the lakehouse
async fn handle_query(query: String) -> Result<Vec<RecordBatch>> {
    let catalog = RockLakeCatalog::open("s3://bucket/catalog/").await?;
    let ctx = SessionContext::new();
    ctx.register_catalog("lake", Arc::new(catalog));

    let df = ctx.sql(&query).await?;
    let batches = df.collect().await?;
    Ok(batches)
}

This pattern eliminates the network hop between application and catalog — the query planner reads catalog metadata directly from SlateDB without any intermediate wire protocol overhead.

Data Pipeline Orchestration

Orchestration frameworks can use DataFusion to inspect and validate catalog state before and after pipeline steps:

// Validate that a table has expected columns before loading data
let catalog = RockLakeCatalog::open(&storage_url).await?;
let ctx = SessionContext::new();
ctx.register_catalog("lake", Arc::new(catalog));

let schema = ctx.sql("DESCRIBE lake.analytics.events").await?.collect().await?;
let columns: Vec<String> = schema.iter()
    .flat_map(|b| b.column(0).as_any().downcast_ref::<StringArray>())
    .flat_map(|a| (0..a.len()).map(|i| a.value(i).to_string()))
    .collect();

assert!(columns.contains(&"event_id".to_string()));
assert!(columns.contains(&"created_at".to_string()));

Custom Query Engines

Teams building specialized query engines (streaming processors, ML feature stores, real-time aggregation) can use rocklake-datafusion as the metadata layer while implementing their own execution:

// Use the catalog for metadata but execute differently
let catalog = RockLakeCatalog::open(&storage_url).await?;
let ctx = SessionContext::new();
ctx.register_catalog("lake", Arc::new(catalog));

// Get the logical plan (not execution)
let plan = ctx.sql("SELECT * FROM lake.analytics.events WHERE date > '2024-01-01'")
    .await?
    .into_optimized_plan()?;

// Extract file list from the plan for custom processing
let files = extract_data_files_from_plan(&plan);
for file in files {
    custom_streaming_processor.ingest(file).await?;
}

Comparison with PG-Wire Integration

Aspect DataFusion (in-process) PG-Wire (networked)
Latency Lowest (no network) Network round-trip per query
Language Rust only Any language with PG driver
Concurrency Single process Multiple clients
Write support Full (direct SlateDB access) Full (via RockLake server)
Deployment Library dependency Separate server process
Memory Shared address space Isolated processes
Fault isolation Crash affects host application Server crash independent

Choose DataFusion integration when you are building a Rust application and want the lowest possible latency. Choose PG-Wire when you need language flexibility, client isolation, or already have a RockLake server running.

Limitations

  • Rust-only: The DataFusion integration is available only from Rust code. Other languages must use the PG-wire protocol (via the RockLake server) or the C FFI layer.
  • Single-writer: Like all RockLake access paths, the DataFusion integration is subject to the single-writer constraint. Only one process can write to a catalog at a time.
  • No connection pooling: Since the integration is in-process, there is no concept of connection pooling. Each RockLakeCatalog instance maintains its own SlateDB reader/writer.
  • DataFusion version coupling: The integration is compiled against a specific DataFusion version. Upgrading DataFusion may require a matching rocklake-datafusion release.

Further Reading