DataFusion research for Measure QL Catalog B

Research date: 2026-10-14. DataFusion source is pinned to 07077ff83ffe1d44f61ff7557a3b2d8b927220b3, resolved from upstream main. This is an architectural reference, not a new runtime dependency. Recommendations below are BanyanDB design judgments; upstream facts have their owning source linked next to them.

Recommendation and boundaries

Adopt DataFusion’s separation of expressions, relational plans, physical operators and columnar execution for B1–B9. Implement that subset in Go on PR #1412, reusing BanyanDB’s existing vectorized batches and liaison execution. Keep existing protobufs, RPCs, request/response encoding and data-node execution unchanged. DataFusion itself is a Rust engine whose in-memory representation is Arrow. Its scope includes substantially more SQL and execution machinery than Catalog B needs. DataFusion README

The useful result is a small Go relational engine over existing measure queries, not an attempted port of DataFusion. Defer joins, subqueries, windows, exchanges, distributed scheduling, a cost-based optimizer, general function plugins and spill infrastructure. B3 retains the prescribed split-native-request and keyed merge path; residual field filters and computed grouping require a separate local aggregation path.

What to transfer

DataFusion mechanism Catalog B adaptation Upstream source
Relational LogicalPlan nodes separate from Expr trees Small Scan, Filter, Aggregate, Project, Distinct, Sort, Limit, Empty vocabulary; aliases are output metadata, not storage names LogicalPlan, Expr
Analyzer distinct from semantics-preserving optimizer Resolve scope, aliases, ordinals and aggregate legality; determine types/nullability and insert explicit coercions before physical lowering Analyzer, type coercion
PhysicalExpr::evaluate(batch) returns ColumnarValue Compile typed column-index access and batch kernels once; preserve scalar literals, bound parameters and captured now without expanding them into every row PhysicalExpr, ColumnarValue
Schema-bearing record-batch streams A fixed output schema exists even for empty input; compose existing Go pull/fusible/breaker contracts RecordBatchStream
Per-expression filter capability Make pushdown decisions inspectable and preserve residual conditions TableProvider
Dense group IDs and columnar accumulator state Share group lookup across all local aggregates and use typed state arrays GroupsAccumulator
DISTINCT lowered to grouping Reuse group-key encoding/equality for visible output tuples ReplaceDistinctWithAggregate
Memory reservations and bounded top-k Reserve retained state before growth; choose top-k when a finite final limit is available MemoryReservation, SortExec

Expressions and planning

DataFusion’s analyzer establishes executable meaning, while its optimizer must preserve that meaning. For BanyanDB, keep PR #1412’s prepare/bind and schema-source boundaries, then use one typed expression tree for WHERE, HAVING, SELECT, grouping and sorting. Parameter values and one execution-time now remain per execution; schema-bound expressions must not mutate cached parse templates. Analyzer contract

Implement only necessary normalization: expand stars, resolve aliases/ordinals, collect unique aggregate calls, insert casts, prune unused columns, classify predicates, choose native versus local aggregation, and fuse final sort/limit. Use stable internal slot IDs rather than display-name lookup. Arithmetic and comparisons require explicit BanyanDB numeric/null rules; borrowing the architecture does not adopt every DataFusion coercion or function.

The scalar/array split directly helps B1 and B7: latency > CAST(? AS DOUBLE) needs one scalar conversion and an array comparison. CASE and COALESCE must evaluate only selected or still-unresolved rows. DataFusion’s CASE implementation uses filtered remainder batches and branch results; this provides a concrete model for preserving short-circuit errors while processing columns. ColumnarValue, CASE evaluation

Pushdown is a correctness contract

DataFusion classifies each offered filter as Exact, Inexact or Unsupported. An inexact filter may reduce scan work but requires evaluation above the scan; its provider documentation explicitly forbids pushing a limit below that residual work. Projection must also preserve columns needed for residual evaluation. Provider filter/limit contract

For B, exact native tag/time predicates can be removed from liaison WHERE only after equivalence is established. A safe time envelope is inexact when it covers an original disjunction or leaves boundary checks. Field predicates remain local. Do not push one arm of an OR as a mandatory native condition. Neither aggregate pushdown nor final LIMIT is legal below a residual WHERE. Keep complete native inputs for grouping, DISTINCT, merge and ordering, including more than 100 rows.

For example, WHERE service = 'api' AND latency > 2.5 permits the service tag predicate on the native request, but requires raw latency values and a liaison filter before aggregation. A native SUM over all service rows cannot be repaired by filtering its result.

Aggregation, DISTINCT and ordering

DataFusion assigns each group a dense integer and passes batch values plus these indices to grouped accumulators. State is stored per group; AVG can expose SUM and COUNT state separately. Grouped accumulator contract

Use this for the B8/B9 local path: evaluate computed keys once, map tuples to group IDs once, and update all required aggregates against the same filtered batch. Retain explicit null/empty state. B3’s native path instead aligns finalized branch results by complete grouping tuple and bucket timestamp; it must not reinterpret arbitrary native results as mergeable partial states. QL AVG uses verified SUM/COUNT inputs and float finalization. Independent native requests still lack a shared storage snapshot.

DataFusion rewrites SELECT DISTINCT a, b to an aggregate grouped on a, b with no aggregate expressions. Reuse that idea for B6 without including hidden sort columns in equality. Share key encoding and null equality with B9, while retaining different visible schemas. DISTINCT rewrite

DataFusion distinguishes general sorting and top-k when a fetch bound is known. B5 can use one typed lexicographic comparator for both paths, including per-key direction/null placement. Retain OFFSET + LIMIT candidates after checked addition, then apply offset. Evaluate keys once per batch; ensure top-k does not pin entire source batches just to retain a few values. Full sorting remains budgeted; DataFusion’s spilling and dynamic-filter machinery are separate scope. SortExec, TopK

Arrow style versus Arrow interchange

Arrow specifies homogeneous arrays, schema, length, null state and physical buffers. Its validity bit is one for a non-null value; variable-width binary and UTF-8 normally use offsets and a data buffer. Arrow format

BanyanDB already has a useful columnar carrier: vectorized.RecordBatch, typed columns, selection vectors, constant columns and explicit owners. Its bitmap uses one for null, strings use Go slices, and selection/constant representations are project-specific. It is Arrow style, not an Arrow SDK record batch or proof of IPC/C Data Interface compatibility. Batch, column/null representation, typed slices, constant columns

Reuse that carrier for B with a QL schema/slot layer. Adapt existing native results once at the liaison boundary; do not create protobuf cells between every operator. Honor ActiveLen, distinguish nil from empty selections, and retain owners or copy values before an input batch is released. Internal boolean/timestamp semantics need explicit representation even where existing physical column types are reused. The final adapter still emits the existing measure_result.

Arrow Go is an alternative if future interoperability is required: it supplies schema-bearing RecordBatch, typed arrays, reference counting and compute APIs. Adopting it would require conversion, allocator integration and kernel-semantic validation. For B, a second batch representation brings little benefit over the existing carrier. Arrow Go RecordBatch, compute

Embedding DataFusion is a larger alternative. Its FFI explicitly targets Rust libraries and recommends a Rust wrapper for other languages; it is not an off-the-shelf Go engine API. A Go integration would need cgo/build distribution, cross-runtime cancellation, ownership and error translation, plus a bridge for BanyanDB’s dialect and native requests. Arrow interoperability alone does not solve those requirements. DataFusion FFI design

Lifecycle, verification and implementation priority

DataFusion requires execution streams to stop work after errors, release resources when dropped and remain responsive to cancellation. Map this onto existing Go operator Close, context checks and query ownership; do not introduce a Rust-like scheduler. ExecutionPlan lifecycle, existing Go operators

Its MemoryReservation supports fallible growth and releases reserved bytes on drop. Reuse the principle through existing BanyanDB admission/tracking: charge retained native frames/batches, computed columns, groups, distinct keys, retained sort values and final output. Fail explicitly when reservations cannot grow; never substitute silent truncation. Memory reservations

DataFusion prefers SQL result tests for expressible SQL behavior. Borrow that test organization and selected independently authored edge cases; retain native query equivalence as B3’s oracle rather than treating all DataFusion semantics as authoritative for BanyanDB. Add plan assertions for residual filter placement, hidden columns and limit legality. Vary batch sizes/selections, reorder aggregate branches, and verify nulls, short-circuit errors, cancellation and owner release. DataFusion testing guide

Build the typed plan and batch adapter alongside B1–B2, then B3 keyed merge. Add the shared batch expression evaluator for B7, use it for B4/B5/B6, and finish the raw-input local aggregate path needed by B8/B9. This makes every adopted mechanism serve a Catalog B acceptance case instead of building a general SQL framework.