Conversation
📝 WalkthroughWalkthroughIntroduces Phase 2 Arrow-native columnar execution with aligned shared buffers, typed arrays, vectorized compute kernels, logical and physical planning, batch execution operators including joins and aggregates, external sort infrastructure, Parquet I/O with pushdown, and comprehensive benchmarking comparing Phase 1 vs Phase 2 performance. ChangesPhase 2 columnar execution foundation
**Vectorized compute kernels**
**Logical and physical planning**
**I/O and integration**
**Benchmarking, testing, and documentation**
Estimated code review effort: 5 (Critical) | ~120 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
fe533d4 to
c88cbde
Compare
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
src/buffer/buffer.rs (1)
96-103: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win
slice()bounds checks aredebug_assert!-only in bothBufferandBitmap. Both zero-copy slice constructors validateoffset + len <= self.lenonly viadebug_assert!, so release builds silently build an invalid view whose failure only surfaces later as a less-diagnostic panic from indexing insideas_slice()/get().
src/buffer/buffer.rs#L96-L103: promote theBuffer::slicebounds check to an unconditional check (e.g.assert!or return aResult) so misuse fails at the call site instead of downstream.src/buffer/bitmap.rs#L74-L87: apply the same unconditional bounds check toBitmap::slice.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/buffer/buffer.rs` around lines 96 - 103, Replace the debug-only bounds validation in Buffer::slice with an unconditional check so invalid offset and length values fail at the call site; preserve the existing zero-copy view behavior for valid ranges. Apply the same unconditional bounds check in Bitmap::slice; update src/buffer/buffer.rs:96-103 and src/buffer/bitmap.rs:74-87.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/array/primitive.rs`:
- Around line 160-163: Replace the debug-only bounds checks with hard assertions
in the safe slice methods: change the assertion guarding PrimitiveArray::slice
in src/array/primitive.rs lines 160-163 and the corresponding BooleanArray slice
check in src/array/boolean.rs lines 74-77 from debug_assert! to assert!,
preserving their existing conditions and messages.
- Around line 22-26: Remove the offset field from PrimitiveArray and update all
constructors, slicing logic, and value accessors to rely on Buffer and Bitmap’s
internal offsets. In particular, adjust slice and values so they no longer read
or add self.offset, while preserving aligned validity/value slicing and existing
length behavior; update any struct initializers accordingly.
In `@src/array/string.rs`:
- Around line 49-50: Update the public StringArray::try_new constructor to
validate every provided offset before storing or using it: require offsets to be
within values' byte length, ordered as expected by value(), and positioned on
UTF-8 character boundaries. Return the existing error type for invalid offsets
so value() can safely retain its unchecked UTF-8 conversion without
out-of-bounds access or undefined behavior.
- Around line 147-151: Update append_value to calculate the prospective values
length and validate it converts to i32 before mutating self.values. Only after
this check succeeds should it extend self.values and push the corresponding
offset, preserving builder state unchanged when overflow occurs.
In `@src/buffer/buffer.rs`:
- Around line 21-26: Replace the derived Debug implementations for AlignedBytes
and Buffer with bounded custom implementations that expose only safe metadata,
never raw or padded byte contents. Ensure formatting Buffer directly, through
Arc<AlignedBytes>, or via Bitmap cannot dump payload data, while retaining
useful structural fields such as lengths and offsets where appropriate.
---
Nitpick comments:
In `@src/buffer/buffer.rs`:
- Around line 96-103: Replace the debug-only bounds validation in Buffer::slice
with an unconditional check so invalid offset and length values fail at the call
site; preserve the existing zero-copy view behavior for valid ranges. Apply the
same unconditional bounds check in Bitmap::slice; update
src/buffer/buffer.rs:96-103 and src/buffer/bitmap.rs:74-87.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 985b6dec-8193-495c-a4e9-e9ed43d5b806
📒 Files selected for processing (12)
src/array/array.rssrc/array/boolean.rssrc/array/mod.rssrc/array/primitive.rssrc/array/string.rssrc/array/types.rssrc/buffer/bitmap.rssrc/buffer/buffer.rssrc/buffer/mod.rssrc/buffer/mutable.rssrc/buffer/native.rssrc/lib.rs
| pub struct PrimitiveArray<T: ArrowPrimitiveType> { | ||
| values: Buffer, | ||
| validity: Option<Bitmap>, | ||
| offset: usize, | ||
| len: usize, |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Remove redundant offset field to prevent double-offset bugs and simplify the struct.
Unlike upstream Arrow where ArrayData tracks the offset because its buffers are absolute, this codebase's Buffer and Bitmap natively track their own internal offsets (as seen in Buffer::slice and Bitmap::slice).
Currently, PrimitiveArray always sets offset: 0 during construction and slicing. If it were ever non-zero, it would introduce two critical bugs:
validityandvalueswould become misaligned during slicing, becausevalidityis sliced using justoffsetwhilevaluesincorrectly attempts to use(self.offset + offset).values()would panic with an out-of-bounds error, because the slicedBufferis already sized down exactly toself.len, making&full[self.offset..]out of bounds.
Removing the offset field simplifies the type (making it consistent with BooleanArray) and removes this footgun.
♻️ Proposed refactor to remove the offset field
pub struct PrimitiveArray<T: ArrowPrimitiveType> {
values: Buffer,
validity: Option<Bitmap>,
- offset: usize,
len: usize,Apply these changes to the rest of the file to remove all usages of offset:
@@ -39,7 +38,6 @@ impl<T: ArrowPrimitiveType> Clone for PrimitiveArray<T> {
fn clone(&self) -> Self {
PrimitiveArray {
values: self.values.clone(),
validity: self.validity.clone(),
- offset: self.offset,
len: self.len,
_phantom: PhantomData,
}
}
@@ -88,7 +86,6 @@ pub fn try_new(values: Buffer, validity: Option<Bitmap>) -> Result<Self> {
Ok(PrimitiveArray {
values,
validity,
- offset: 0,
len,
_phantom: PhantomData,
})
}
@@ -110,7 +107,6 @@ pub(crate) fn from_parts_unchecked(
PrimitiveArray {
values,
validity,
- offset: 0,
len,
_phantom: PhantomData,
}
}
@@ -122,8 +118,7 @@ pub fn values(&self) -> &[T::Native] {
// SAFETY: validated once in `try_new` (or guaranteed by the
// `from_parts_unchecked` caller contract); `Buffer` is immutable, so
// the invariant holds for the array's whole lifetime.
- let full = unsafe { self.values.typed_data_unchecked::<T::Native>() };
- &full[self.offset..self.offset + self.len]
+ unsafe { self.values.typed_data_unchecked::<T::Native>() }
}
@@ -165,10 +160,9 @@ fn slice(&self, offset: usize, len: usize) -> ArrayRef {
Arc::new(PrimitiveArray::<T> {
values: self
.values
- .slice((self.offset + offset) * elem_size, len * elem_size),
+ .slice(offset * elem_size, len * elem_size),
validity: self.validity.as_ref().map(|v| v.slice(offset, len)),
- offset: 0,
len,
_phantom: PhantomData,
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| pub struct PrimitiveArray<T: ArrowPrimitiveType> { | |
| values: Buffer, | |
| validity: Option<Bitmap>, | |
| offset: usize, | |
| len: usize, | |
| pub struct PrimitiveArray<T: ArrowPrimitiveType> { | |
| values: Buffer, | |
| validity: Option<Bitmap>, | |
| len: usize, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/array/primitive.rs` around lines 22 - 26, Remove the offset field from
PrimitiveArray and update all constructors, slicing logic, and value accessors
to rely on Buffer and Bitmap’s internal offsets. In particular, adjust slice and
values so they no longer read or add self.offset, while preserving aligned
validity/value slicing and existing length behavior; update any struct
initializers accordingly.
| debug_assert!( | ||
| offset + len <= self.len, | ||
| "PrimitiveArray::slice out of bounds" | ||
| ); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Enforce safe slicing bounds in release mode to prevent Undefined Behavior.
The safe slice methods use debug_assert! to validate bounds, which strips the check in release builds. This allows callers to pass out-of-bounds offset or len values. Because the underlying Buffer::slice and Bitmap::slice operations likely also use debug assertions (per provided Context snippets), this creates a returned struct holding an out-of-bounds length. When values() is later called on that array, it blindly trusts this length to construct a Rust slice (&[T]), leading to immediate Undefined Behavior (UB) when the bounds exceed the underlying allocation.
Replace debug_assert! with a hard assert! to guarantee memory safety.
src/array/primitive.rs#L160-L163: Changedebug_assert!toassert!.src/array/boolean.rs#L74-L77: Changedebug_assert!toassert!.
📍 Affects 2 files
src/array/primitive.rs#L160-L163(this comment)src/array/boolean.rs#L74-L77
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/array/primitive.rs` around lines 160 - 163, Replace the debug-only bounds
checks with hard assertions in the safe slice methods: change the assertion
guarding PrimitiveArray::slice in src/array/primitive.rs lines 160-163 and the
corresponding BooleanArray slice check in src/array/boolean.rs lines 74-77 from
debug_assert! to assert!, preserving their existing conditions and messages.
| std::str::from_utf8(values.as_slice()) | ||
| .map_err(|e| BasaltError::Internal(format!("values buffer is not valid UTF-8: {e}")))?; |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Validate offsets against bounds and UTF-8 boundaries to ensure memory safety.
try_new is a public constructor, but it currently fails to validate that the provided offsets are within the bounds of values and land on valid UTF-8 character boundaries. Because value(i) relies on these invariants and uses unsafe { std::str::from_utf8_unchecked(...) }, invalid offsets passed to try_new can cause out-of-bounds panics or undefined behavior (UB) in safe code.
🛡️ Proposed fix to ensure memory safety
- std::str::from_utf8(values.as_slice())
- .map_err(|e| BasaltError::Internal(format!("values buffer is not valid UTF-8: {e}")))?;
+ let valid_str = std::str::from_utf8(values.as_slice())
+ .map_err(|e| BasaltError::Internal(format!("values buffer is not valid UTF-8: {e}")))?;
+
+ for &offset in offsets_typed {
+ if offset < 0 || !valid_str.is_char_boundary(offset as usize) {
+ return Err(BasaltError::Internal(format!(
+ "offset {} is out of bounds or not a char boundary",
+ offset
+ )));
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| std::str::from_utf8(values.as_slice()) | |
| .map_err(|e| BasaltError::Internal(format!("values buffer is not valid UTF-8: {e}")))?; | |
| let valid_str = std::str::from_utf8(values.as_slice()) | |
| .map_err(|e| BasaltError::Internal(format!("values buffer is not valid UTF-8: {e}")))?; | |
| for &offset in offsets_typed { | |
| if offset < 0 || !valid_str.is_char_boundary(offset as usize) { | |
| return Err(BasaltError::Internal(format!( | |
| "offset {} is out of bounds or not a char boundary", | |
| offset | |
| ))); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/array/string.rs` around lines 49 - 50, Update the public
StringArray::try_new constructor to validate every provided offset before
storing or using it: require offsets to be within values' byte length, ordered
as expected by value(), and positioned on UTF-8 character boundaries. Return the
existing error type for invalid offsets so value() can safely retain its
unchecked UTF-8 conversion without out-of-bounds access or undefined behavior.
| pub fn append_value(&mut self, s: &str) -> Result<()> { | ||
| self.values.extend_from_slice(s.as_bytes()); | ||
| let end = i32::try_from(self.values.len()) | ||
| .map_err(|_| BasaltError::Internal("StringArray offsets overflowed i32".to_string()))?; | ||
| self.offsets.push(end); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Prevent corrupted builder state on overflow.
If append_value fails because the new total length exceeds i32::MAX, self.values has already been mutated. This leaves the builder in an unrecoverable, inconsistent state where the internal values buffer is out of sync with offsets and len, corrupting any subsequent appends or the final array.
Calculate and check the new length before mutating self.values.
🔒️ Proposed fix to ensure atomic updates
pub fn append_value(&mut self, s: &str) -> Result<()> {
- self.values.extend_from_slice(s.as_bytes());
- let end = i32::try_from(self.values.len())
+ let new_len = self.values.len() + s.len();
+ let end = i32::try_from(new_len)
.map_err(|_| BasaltError::Internal("StringArray offsets overflowed i32".to_string()))?;
+ self.values.extend_from_slice(s.as_bytes());
self.offsets.push(end);
self.validity.push(true);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| pub fn append_value(&mut self, s: &str) -> Result<()> { | |
| self.values.extend_from_slice(s.as_bytes()); | |
| let end = i32::try_from(self.values.len()) | |
| .map_err(|_| BasaltError::Internal("StringArray offsets overflowed i32".to_string()))?; | |
| self.offsets.push(end); | |
| pub fn append_value(&mut self, s: &str) -> Result<()> { | |
| let new_len = self.values.len() + s.len(); | |
| let end = i32::try_from(new_len) | |
| .map_err(|_| BasaltError::Internal("StringArray offsets overflowed i32".to_string()))?; | |
| self.values.extend_from_slice(s.as_bytes()); | |
| self.offsets.push(end); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/array/string.rs` around lines 147 - 151, Update append_value to calculate
the prospective values length and validate it converts to i32 before mutating
self.values. Only after this check succeeds should it extend self.values and
push the corresponding offset, preserving builder state unchanged when overflow
occurs.
| #[derive(Debug)] | ||
| struct AlignedBytes { | ||
| raw: Vec<u8>, | ||
| align_offset: usize, | ||
| len: usize, | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Derived Debug on AlignedBytes/Buffer dumps raw bytes — PII/logging risk.
#[derive(Debug)] on AlignedBytes prints the entire raw: Vec<u8> (including the over-allocated padding), and Buffer's derived Debug delegates through Arc<AlignedBytes> to the same. Since Buffer is the backing store for arbitrary column bytes — including string/text data that may contain real customer content — any future {:?}/panic message/log statement touching a Buffer (directly, or via a Bitmap, which also holds one) will dump raw payload data. Notably, PrimitiveArray's hand-written Debug impl (src/array/primitive.rs) deliberately avoids printing values for exactly this reason — the convention this file should also follow.
🔒 Proposed fix: bounded custom Debug impl
-#[derive(Debug)]
struct AlignedBytes {
raw: Vec<u8>,
align_offset: usize,
len: usize,
}
+
+impl std::fmt::Debug for AlignedBytes {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("AlignedBytes")
+ .field("len", &self.len)
+ .field("align_offset", &self.align_offset)
+ .finish()
+ }
+}-#[derive(Clone, Debug)]
+#[derive(Clone)]
pub struct Buffer {
data: Arc<AlignedBytes>,
offset: usize,
len: usize,
}
+
+impl std::fmt::Debug for Buffer {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Buffer")
+ .field("offset", &self.offset)
+ .field("len", &self.len)
+ .finish()
+ }
+}Also applies to: 57-62
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/buffer/buffer.rs` around lines 21 - 26, Replace the derived Debug
implementations for AlignedBytes and Buffer with bounded custom implementations
that expose only safe metadata, never raw or padded byte contents. Ensure
formatting Buffer directly, through Arc<AlignedBytes>, or via Bitmap cannot dump
payload data, while retaining useful structural fields such as lengths and
offsets where appropriate.
Implements Module 2.1 of design-docs/basalt-phase2-lld.md: the foundation
everything else in Phase 2 depends on. Added alongside Phase 1's row-oriented
Column/Value (untouched, still what exec/expr/io run on) rather than replacing
it in place — the LLD's migration map is a big cut-over across execution,
CSV, and the binder, and landing that same-PR as the memory model would risk
a half-working state in both halves. This PR is deliberately just the
foundation, built completely and tested hard, per the LLD's own emphasis that
these three types are what everything above them depends on getting right.
New: `buffer/` (Buffer, MutableBuffer, Bitmap, BitmapBuilder, NativeType) and
`array/{array,types,primitive,boolean,string}.rs` (Array trait, ArrayRef,
PrimitiveArray<T>, BooleanArray, StringArray + their builders).
Notable design calls, deviating from the LLD where noted, with reasoning:
- 64-byte alignment via over-allocated Vec<u8> + a computed align_offset,
not a hand-rolled unsafe allocator. The LLD's own non-goal is "no unsafe
unless measured" — a custom aligned allocator is exactly the unsafe that
isn't justified without a benchmark showing this approach's overhead
matters. A few dozen wasted bytes per buffer buys real alignment with only
safe Vec<u8> allocation/deallocation.
- Bitmap and/or/not are bit-at-a-time, not word-at-a-time. Correctness first
at the LLD's own flagged highest-bug-density area; the SIMD-friendly fast
path is a benchmarked follow-up, not a shortcut taken here.
- Invariants (buffer alignment, offset validity, UTF-8) are validated once
at construction (try_new) rather than on every access, so hot-path
accessors (values(), value()) never need to return Result or panic.
Builders bypass try_new via a private from_parts_unchecked, since they
just built the data themselves — no untrusted input at that boundary.
- StringBuilder only ever appends whole &str values, so every offset lands
on a UTF-8 char boundary by construction; value() uses
str::from_utf8_unchecked justified by that plus the one-time whole-buffer
UTF-8 check in try_new, instead of re-validating per access.
168 tests passing (131 -> 168), including the LLD-mandated bit-offset suite
(1, 7, 8, 9, 63, 64, 65) and a zero-copy refcount proof for Buffer::slice.
Clippy clean under -D warnings.
…, filter, take, cast
Implements Module 2.2 of design-docs/basalt-phase2-lld.md on top of the
Module 2.1 memory model: ColumnarValue, and the arith/comparison/boolean
(Kleene)/filter/take/cast kernels. Built on the Phase 2 array types, still
not wired into execution (Phase 1's row-at-a-time exec/expr path is
untouched and is what actually runs queries right now).
New: scalar.rs (ScalarValue), compute/{mod,arith,comparison,boolean,filter,
take,cast,index}.rs.
Notable design decisions, deviating from the LLD in places, with reasoning:
- ScalarValue is its own type, not a reuse of types::value::Value despite
the LLD's "Value becomes ScalarValue" phrasing. Phase 1's Value::Null is
deliberately untyped (the bound expression tree always supplies the type
at eval time). ColumnarValue kernels dispatch with no expression tree in
hand, so a null scalar must carry its own type tag to know which loop to
run — every ScalarValue variant is Option<T>, matching arrow-rs's own
ScalarValue for the same reason.
- Arithmetic kernels skip null rows entirely rather than the LLD's
"compute over garbage, then mask via validity AND" pattern. That pattern
is only sound for trap-free ops; this project's arithmetic uses checked
operations that error on overflow (see Phase 1's expr::eval), and running
checked_add over a null slot's uninitialized garbage bytes can trip a
false overflow on a row nobody asked about. Costs some vectorization,
documented as a deliberate trade rather than an oversight.
- Kleene and/or/not and Bitmap and/or/not (module 2.1) are both per-row,
not the LLD's six-bitwise-word-ops formula. Correctness first at the
LLD's own flagged bug-dense area; the branch-free fast path is a
benchmarked follow-up in both places.
- Every kernel here always materializes scalar operands into full arrays
via ColumnarValue::into_array, rather than the LLD's three-entry-point
shape (array-array, array-scalar, scalar-array) that keeps scalars
unmaterialized. Simpler first pass; the type still distinguishes
Array/Scalar so the zero-materialization path can land later without an
API change.
- Index arrays (UInt32Array, for take/filter/sort/join) are a standalone
type, not routed through the Array trait or DataType. Those model SQL's
logical type lattice (Phase 1 kept it to four members on purpose); row
positions are a physical-execution concept that never appears in a
schema or a CAST, so giving them a DataType variant would leak an
internal concept into SQL-facing surface for no benefit.
Caught and fixed one real bug during testing: the initial `div`/`rem`
kernels used Option<i64> for the int path, collapsing "divide by zero" and
"integer overflow" into the same NumericOverflow error — 10 / 0 was
reported as an overflow. Fixed by threading a proper Result through the
per-element op closures instead of Option; added a regression test
(div_by_zero_is_distinct_from_overflow) asserting the two stay distinct
error variants.
218 tests passing (168 -> 218). Clippy clean under -D warnings.
Implements Module 2.3's expression half (design-docs/basalt-phase2-lld.md §5.2): the PhysicalExpr trait and its implementations (Column, Literal, Binary, Cast, IsNull, Not, Neg), evaluating over a batch instead of a row. Adds ColumnarBatch (schema.rs's new SchemaRef + batch.rs) as the columnar counterpart to Phase 1's RecordBatch, kept a distinctly-named additive type for the same reason as everything else in Phase 2 so far: Phase 1's RecordBatch is still what actually executes queries. PhysicalExpr is dyn (open set: UDFs, user expressions), unlike the coming LogicalPlan enum (closed set Phase 3's optimizer pattern-matches on) — same rule applied to two different inputs, not an inconsistency. BinaryExpr's AND/OR bridge into compute::boolean's Kleene kernels rather than the generic arith/comparison path, since three-valued logic needs BooleanArray specifically. cast_scalar reuses Phase 1's Value::cast_to verbatim rather than re-deriving the cast matrix a third time. 218 -> 241 tests. Clippy clean under -D warnings.
Implements the logical-plan half of Module 2.3 (design-docs/basalt-phase2-lld.md §5.1): the LogicalPlan enum, TableSource trait, LogicalPlanBuilder, and Display-based plan tree printing (an EXPLAIN precursor). LogicalPlan is an enum (closed set this crate owns, exhaustively matched by Phase 3's optimizer rules) — the opposite choice from PhysicalExpr/ ExecutionPlan (dyn, open sets). Same rule from the physical_expr commit, applied to a different input. Reuses Phase 1's bound expr::expr::Expr directly as the plan's expression type rather than inventing a parallel one. Caught and fixed a real bug via the join builder test: joining two LogicalPlans whose schemas share a column name (t1.id / t2.id — an entirely ordinary self-join shape) unwrap-panicked, because the concatenated output fields were routed through Schema::new, which rejects duplicate names by design for a single table's schema. Added Schema::new_allow_duplicate_names for exactly this case (a joined schema's uniqueness rules are genuinely different from a single table's) and a regression test asserting both same-named columns survive rather than erroring. 241 -> 253 tests. Clippy clean under -D warnings.
…imit, planner Completes Module 2.3 (design-docs/basalt-phase2-lld.md §5.3-5.4): the ExecutionPlan trait, BatchStream (a plain synchronous iterator — Phase 4 swaps this alias for a Stream without touching call sites), MemoryScanExec, FilterExec, ProjectionExec, LimitExec, and PhysicalPlanner tying LogicalPlan to this operator tree. This is build-order step 11 from the LLD: the first full query running through the vectorized path (scan -> filter -> project -> limit), exercised end to end in physical_plan::planner's test. FilterExec never emits zero-row batches (loops internally until it has rows or the child is exhausted) — the LLD calls this out explicitly, since a selective predicate over many batches would otherwise flood downstream operators with empty work. LimitExec stops pulling from its child as soon as `fetch` rows are produced rather than draining the whole input first. PhysicalPlanner only supports MemoryTableSource for now (CSV/Parquet-backed scans are a separate io-module concern); Sort/Aggregate/Join plan nodes return a clear "not yet implemented, see module X" error rather than panicking, since those modules land next. 253 -> 269 tests. Clippy clean under -D warnings.
…ateExec Implements Module 2.4 (design-docs/basalt-phase2-lld.md §6): the Accumulator trait (Sum, Count, MinMax, Avg), GroupKeyEncoder (packed, order-preserving byte keys for numeric/boolean types, explicitly documented as NOT order-preserving for Utf8 across different lengths — a real gap if reused for sorting, called out rather than silently assumed away), GroupedHashAggregator, and AggregateExec wired into the physical planner. AvgAccumulator is the concrete proof the two-phase (state()/merge_batch()) contract is necessary, not decorative: state is (sum, count), output is sum/count, and a test asserts merging two partial averages naively (just averaging 15 and 100) would give the wrong answer (57.5) versus the correct merge through raw sums and counts (130/3). No-group aggregation (SELECT SUM(x) FROM t) is a separate, simpler path in AggregateExec with no hashing at all, per the LLD. AggregateExec is a pipeline breaker: execute() drains its child stream eagerly and returns exactly one output batch. Caught and fixed a real bug in the *previous* commit's LogicalPlanBuilder:: aggregate while wiring output schemas here: it used the aggregate argument's own type as the output type, which is wrong for AVG (always Float64, even over an Int64 column) and COUNT (always Int64 regardless of what's counted), and marked every aggregate output non-nullable even though SUM/AVG/MIN/MAX of an all-null group is NULL. Fixed with per-kind output type/nullability logic and a regression test covering all four kinds at once. 269 -> 295 tests. Clippy clean under -D warnings.
Implements the in-memory portion of Module 2.6 (design-docs/basalt-phase2-lld.md §8.1-8.2): compute::sort::lexsort_to_indices (sorts indices, then take()s — the same pattern filter/joins/aggregation all reuse), a compute::concat kernel (needed but not separately named in the LLD: a pipeline breaker like SortExec collects several batches and needs one contiguous array per column to sort over), SortExec, and TopKExec. Wired into the physical planner. Two explicit, documented scope-narrowings rather than silent shortfalls: - The comparator is column-wise, not the LLD's normalized order-preserving row-key encoding (2-5x faster per the LLD) — correctness first at a second bug-dense area of this phase, consistent with the same call made for Bitmap/Kleene logic in earlier commits. The row-key path can reuse aggregate::group_keys::GroupKeyEncoder almost as-is, which is why that encoder was built order-preserving in the first place. - TopKExec is a full sort truncated to k, not yet the LLD's bounded-heap O(n log k) / O(k)-memory algorithm. Both gaps are called out in physical_plan/sort.rs's module doc rather than left to be discovered. External sort / spilling (LLD §8.3) is not implemented — everything here buffers its full input in memory, same as AggregateExec's pipeline-breaker behavior already does. 295 -> 311 tests. Clippy clean under -D warnings.
Implements Module 2.5 (design-docs/basalt-phase2-lld.md §7): HashJoinExec covering Inner/Left/Right/Full/LeftSemi/LeftAnti/RightSemi/RightAnti, and NestedLoopJoinExec (Inner-only fallback for non-equi predicates, batched per build row rather than per pair — broadcast one build row across the whole probe batch and evaluate the predicate vectorized, not per-(b,p)-pair). Both build index arrays then take() — the same primitive filter/sort/ aggregation already share. Wired into the physical planner (equi-join keys route to HashJoinExec, a bare filter predicate with no keys routes to NestedLoopJoinExec, Inner only). Reuses aggregate::group_keys::GroupKeyEncoder for the build-side hash table — exactly the reuse that encoder's order-preserving design was built for. Two explicit scope decisions, documented in hash_join.rs's module doc: - Both sides are fully materialized rather than the LLD's streaming probe side, via the same compute::concat pattern SortExec/AggregateExec already use for their own pipeline-breaking. - No residual-filter support (ON a.x = b.y AND a.z < b.w) — equi-only for HashJoinExec; a mixed predicate errors clearly from the planner rather than silently dropping half the condition. Also spells out a join-type convention explicitly since "left"/"right" is ambiguous between SQL-table-position and build/probe-role: this operator names its children build/probe, and Left*/Right* JoinType variants are defined purely in terms of those roles (Left* preserves/tests probe rows, Right* preserves/tests build rows), read literally off the LLD's own emit-rule table rather than assumed to match textbook SQL LEFT/RIGHT naming under a fixed build-side default. 311 -> 324 tests, including one for every join type plus a null-key regression (a row with a null join key must never match anything but must still surface as unmatched in outer/anti output). Clippy clean under -D warnings.
…down Implements Module 2.7 (design-docs/basalt-phase2-lld.md §9): adds the parquet and arrow crates as dependencies (a real, discussed decision, not a silent addition) and a conversion layer between Basalt's own array types and arrow-rs's, per the LLD's explicit exception to this project's hand-roll-everything rule — Parquet's Thrift-encoded format teaches serialization, not query engineering. Two of the three pushdown levels the LLD describes are implemented: - Projection pushdown via ProjectionMask (only requested columns decoded). - Row-group skipping via statistics (RowGroupPredicate: a closed column-OP-literal shape evaluated against each row group's min/max, deliberately not a general PhysicalExpr — that needs real interval arithmetic belonging to Phase 3's cost model). Page-level skipping is not implemented, matching the LLD's own ranking of it as the lowest-payoff tier. Caught and fixed two real bugs via the test suite: - Projected reads used the *builder's* schema (always the full file schema) instead of the *reader's* (reflecting the actual projection), so every projected read failed a field-count check in ColumnarBatch::try_new. Fixed by reading the schema after building the reader, from the reader itself. - When every row group was skipped (or a file has none), the empty path returned zero columns regardless of the schema's field count, rather than the right number of correctly-typed zero-row columns — the same class of bug, same fix shape (a small empty_array_for helper mirroring the one already in physical_plan::join::hash_join). 324 -> 330 tests. Clippy clean under -D warnings.
…e I/O Implements the standalone pieces of Module 2.6's spilling (§8.3): MemoryReservation (a minimal per-operator budget — try_grow errs as the caller's cue to spill; a proper cross-operator MemoryPool is explicitly a Phase 5 concern) and write_batch/read_batch, a length-prefixed spill file format. Spill encoding reuses aggregate::group_keys::GroupKeyEncoder wholesale rather than inventing a second byte format: a batch's rows, treated as one "group key" per row across all columns, is exactly the null-flagged byte encoding a spill file needs. Not wired into SortExec's execute() in this pass — accumulate-until-budget- trips, sort-and-spill-each-run, k-way-merge is real pipeline integration work, deliberately left as a documented follow-up the same way TopKExec's bounded-heap already is in this codebase: get the primitive right and tested in isolation first. 330 -> 336 tests. Clippy clean under -D warnings.
Replaces TopKExec's placeholder (full sort + truncate) with the real
O(n log k) / O(k)-memory algorithm the LLD describes: a bounded max-heap of
size k, pushing every row and evicting the current worst-of-the-best-k-so-far
once the heap exceeds k. Streams its child directly rather than going
through SortExec, so it never buffers more than k rows plus the current
input batch — the actual point of this operator.
Wired into the physical planner: a Limit-over-Sort logical plan shape with
no OFFSET now builds TopKExec instead of SortExec composed with LimitExec,
matching the LLD's own framing ("in Phase 2, special-case it in the
planner; in Phase 3 this becomes a proper optimizer rule").
Tests cover the multi-batch case specifically (the one a naive per-batch
top-k would get wrong), descending order, null-ordering policy, k=0, and
k larger than the input.
336 -> 342 tests. Clippy clean under -D warnings.
Closes the last correctness gap noted in hash_join.rs's module doc: a non-equi residual predicate alongside equi-join keys. Evaluated once per probe row against only that row's equi-matched build-side candidates (gathered via take, broadcast against the single probe row) — never against the full build side, since the hash lookup already narrowed the candidate set before the residual runs. A candidate pair only counts as matched — for build_matched/probe_matched, and therefore for every join type's outer/semi/anti emit rule — once it passes both the equi-key lookup and the residual filter. Added HashJoinExec::with_filter (new() now delegates to it with filter: None) and a combined_schema field (always build-fields-then-probe-fields) so the filter has something stable to evaluate against regardless of what the actual output schema keeps for semi/anti joins. Wired into the planner: a Join's equi keys and residual filter are no longer mutually exclusive: both convert and pass through together. Tests cover the residual narrowing an equi-match down (only some equi- matched pairs survive) and the outer-join bookkeeping case specifically — a pair that passes the equi-key lookup but fails the residual must surface as *unmatched* for Left/Right/Full's null-padding, not as a real match. 342 -> 344 tests. Clippy clean under -D warnings.
Adds criterion benchmark comparing row-at-a-time (Phase 1 expr::eval) against batch-at-a-time (Phase 2 compute::arith::add) for col + 1 at 1K/100K/1M rows. BENCHMARKS.md records the actual measured results: 2.5-2.7x speedup at 1K-100K rows, narrowing to 1.34x at 1M as both paths become bandwidth-bound.
array+scalar and scalar+array now read the scalar once and apply it directly against the array, instead of building a full N-element array of the scalar via into_array first. That extra allocation+fill pass was hiding inside what should have been a single-pass kernel and dominated the col+1 benchmark, especially at 1M rows. Speedups after the fix: 4.47x/4.17x/2.39x at 1K/100K/1M, up from 2.53x/2.68x/1.34x. Also updates README with the current Phase 1 + Phase 2 layout.
…nels value(i) called values() on every element, which re-derives its slice from the Arc-backed Buffer from scratch each time (Arc deref + AlignedBytes slicing + offset slicing). Hoisting values() out of the loop and indexing directly cuts the full kernel from 16.89 to 11.44 ns/element at N=1M. Combined with the prior scalar-materialization fix, col+1 speedup is now 6.44x/5.88x/2.95x at 1K/100K/1M, up from 4.47x/4.17x/2.39x. Adds examples/profile_add.rs, the isolation harness used to find this: splits out bare checked_add, wrapping_add, autovectorized iterator, and builder-only costs to locate where the per-element floor actually went. BENCHMARKS.md documents both rounds and the still-open bitmap fast-path gap.
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/types/schema.rs (1)
83-94: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winAllow duplicate field names in projected schemas.
Schema::projectusesSchema::new(fields)to construct the resulting schema, which strictly enforces unique field names. If a projection is applied over a joined schema that legitimately contains duplicate names (explicitly permitted vianew_allow_duplicate_names), or if a query deliberately duplicates a column in its output (e.g.,SELECT a, a FROM t), this will return an error and crash the query planning or execution.Use
Schema::new_allow_duplicate_namesinstead to preserve the source schema's tolerance for duplicates.🐛 Proposed fix
/// Build a new schema from a subset of columns, in the given order. For projection. pub fn project(&self, indices: &[usize]) -> Result<Schema> { let fields = indices .iter() .map(|&i| { self.field(i).cloned().ok_or_else(|| BasaltError::Schema { message: format!("field index {i} out of bounds"), }) }) .collect::<Result<Vec<_>>>()?; - Schema::new(fields) + Ok(Schema::new_allow_duplicate_names(fields)) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/types/schema.rs` around lines 83 - 94, Update Schema::project to construct projected schemas with Schema::new_allow_duplicate_names instead of Schema::new, preserving duplicate field names from joined schemas or repeated projections while keeping the existing field-index validation unchanged.
🧹 Nitpick comments (7)
src/physical_plan/aggregate/mod.rs (1)
126-189: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicate strict
scalars_to_arrayhelper.src/physical_plan/aggregate/mod.rsandsrc/physical_plan/spill.rscarry byte-for-byte identical strictscalars_to_arrayimplementations (same match arms, same error messages); consider hoisting one copy into a shared module (e.g.crate::arrayor a smallscalar_arraysutil) so the two stay in lock-step as newDataTypes are added. Note thesrc/physical_plan/sort.rscopy is intentionally lenient (_ => append_null) and should stay separate.
src/physical_plan/aggregate/mod.rs#L126-L189: replace with a call to the shared helper.src/physical_plan/spill.rs#L167-L231: replace with a call to the shared helper.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/physical_plan/aggregate/mod.rs` around lines 126 - 189, The strict scalars_to_array implementation is duplicated across both aggregate and spill paths. Move one strict implementation into a shared array/scalar utility, then replace the helpers in src/physical_plan/aggregate/mod.rs:126-189 and src/physical_plan/spill.rs:167-231 with calls to it; leave the lenient scalars_to_array in src/physical_plan/sort.rs unchanged.src/physical_expr/unary.rs (1)
112-148: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDuplicated Int64/Float64 negation loops.
The
Arraybranch ofNegExpr::evaluaterepeats the same is_null/append_null/append_value loop forInt64andFloat64, differing only in the value type and negation op (checked vs. plain). Could be factored into a small generic helper once more numeric types are added, but not urgent with only two variants today.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/physical_expr/unary.rs` around lines 112 - 148, Keep the current Int64 and Float64 negation implementations unchanged; the duplicated loops are an optional future refactor, not a required fix. If refactoring now, extract the shared null-preserving array-negation logic from NegExpr::evaluate while retaining checked negation for Int64 and plain negation for Float64.README.md (3)
39-44: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd language specifier to fenced code block.
As indicated by static analysis hints, specifying a language enhances rendering and syntax highlighting. Use
shfor these shell commands.♻️ Proposed fix
-``` +```sh cargo build cargo test cargo clippy --all-targets -- -D warnings cargo fmt --all</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.In
@README.mdaround lines 39 - 44, Update the fenced shell-command block in the
README to specify the sh language identifier, while preserving all existing
commands and formatting.</details> <!-- cr-comment:v1:e8e63246c6e412da19b97e46 --> _Source: Linters/SAST tools_ --- `48-50`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_ **Add language specifier to fenced code block.** As indicated by static analysis hints, specifying a language enhances rendering and syntax highlighting. Use `sh` for these shell commands. <details> <summary>♻️ Proposed fix</summary> ```diff -``` +```sh cargo bench --bench row_vs_batch</details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.In
@README.mdaround lines 48 - 50, Update the fenced code block containing the
cargo bench command to specify the sh language while preserving the command and
its formatting.</details> <!-- cr-comment:v1:b1fd8fcd7551f76c828ad892 --> _Source: Linters/SAST tools_ --- `14-31`: _📐 Maintainability & Code Quality_ | _🔵 Trivial_ | _💤 Low value_ **Add language specifier to fenced code block.** As indicated by static analysis hints, specifying a language enhances rendering and syntax highlighting. Use `text` for this directory tree block. <details> <summary>♻️ Proposed fix</summary> ```diff -``` +```text src/ ├── types/ # DataType, Value, Schema, coercion ├── array/ # Buffer, Bitmap, Array trait, PrimitiveArray/BooleanArray/StringArray🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@README.md` around lines 14 - 31, Update the fenced directory-tree block in the README to specify the text language, changing the opening fence to use text while preserving the tree contents and formatting.Source: Linters/SAST tools
src/physical_plan/aggregate/hash_table.rs (1)
90-106: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winHoist
rows_per_groupout of the per-aggregate loop.
rows_per_groupdepends only ongroup_of_rowand the group count, both invariant acrossagg_arrays. Rebuilding it (a freshVec<Vec<u32>>allocation plus a full re-scan ofgroup_of_row) once per aggregate multiplies the grouping cost by the number of aggregates on the aggregation hot path.♻️ Compute grouping once, reuse across aggregates
- for (agg_idx, agg_array) in agg_arrays.iter().enumerate() { - let mut rows_per_group: Vec<Vec<u32>> = vec![Vec::new(); self.group_keys.len()]; - for (row, &g) in group_of_row.iter().enumerate() { - rows_per_group[g].push(row as u32); - } - for (g, rows) in rows_per_group.into_iter().enumerate() { - if rows.is_empty() { - continue; // group existed before this batch but got no rows in it - } - let mut idx_builder = UInt32Builder::with_capacity(rows.len()); - for r in rows { - idx_builder.append_value(r); - } - let gathered = take(agg_array.as_ref(), &idx_builder.finish())?; - self.accumulators[agg_idx][g].update_batch(&[gathered])?; - } - } + let mut rows_per_group: Vec<Vec<u32>> = vec![Vec::new(); self.group_keys.len()]; + for (row, &g) in group_of_row.iter().enumerate() { + rows_per_group[g].push(row as u32); + } + for (agg_idx, agg_array) in agg_arrays.iter().enumerate() { + for (g, rows) in rows_per_group.iter().enumerate() { + if rows.is_empty() { + continue; // group existed before this batch but got no rows in it + } + let mut idx_builder = UInt32Builder::with_capacity(rows.len()); + for &r in rows { + idx_builder.append_value(r); + } + let gathered = take(agg_array.as_ref(), &idx_builder.finish())?; + self.accumulators[agg_idx][g].update_batch(&[gathered])?; + } + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/physical_plan/aggregate/hash_table.rs` around lines 90 - 106, Hoist the construction of rows_per_group out of the per-aggregate loop in the aggregate update logic. Build it once from group_of_row and self.group_keys.len(), then reuse it while iterating over agg_arrays; preserve the existing empty-group handling, take gathering, and accumulator updates.src/io/parquet.rs (1)
108-211: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winPre-size the UTF-8 builder.
StringBuilder::with_capacity(a.len(), 0)under-reserves the string payload and can force extra reallocations; usea.value_data().len()(the Arrow string buffer size) instead. The primitive branches don’t expose a direct bulk-copy path in the current Basalt array APIs, so the string branch is the clear low-cost win.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/io/parquet.rs` around lines 108 - 211, Update the UTF-8 branch in arrow_array_to_basalt to initialize StringBuilder with the Arrow string buffer size from a.value_data().len() as its payload capacity, while preserving the existing element-count capacity and null/value handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@benches/tpch.rs`:
- Around line 113-118: Update the vector construction containing quantity,
extendedprice, discount, and shipdate so its elements are cast with as _ to the
expected ArrayRef trait-object type. Ensure the first element,
quantity.finish(), does not force a concrete Int64 array type, allowing all
heterogeneous arrays to compile as Vec<ArrayRef>.
In `@src/batch.rs`:
- Around line 357-365: Update ColumnarBatch::slice to validate that offset + len
does not exceed self.num_rows before slicing columns or constructing the result.
Fail fast on invalid ranges, including zero-column batches, while preserving the
existing zero-copy behavior for valid slices.
In `@src/compute/cast.rs`:
- Around line 41-52: Update the Float64-to-Int64 conversion in the (Float64,
Int64) branch to require v to be strictly less than i64::MAX as f64, while
retaining the existing finite and lower-bound checks and error behavior.
In `@src/io/parquet.rs`:
- Around line 229-259: Update row_group_may_match so Int64 statistics and
predicate values remain in exact i64 arithmetic, rather than converting them to
f64. Split the Int64 and Double/Float64 handling into type-specific comparison
paths, preserving the existing min/max fallback and unsupported-type behavior;
only the Double path should use f64 comparisons.
In `@src/logical_plan/builder.rs`:
- Around line 60-107: Update expr_display_name and its aggregate() call site to
accept the group-expression position and disambiguate non-Column names,
preserving column names while generating distinct names for repeated expression
outputs. Change the group_expr iteration in aggregate() to pass the enumerated
index, and update any other callers of expr_display_name consistently.
---
Outside diff comments:
In `@src/types/schema.rs`:
- Around line 83-94: Update Schema::project to construct projected schemas with
Schema::new_allow_duplicate_names instead of Schema::new, preserving duplicate
field names from joined schemas or repeated projections while keeping the
existing field-index validation unchanged.
---
Nitpick comments:
In `@README.md`:
- Around line 39-44: Update the fenced shell-command block in the README to
specify the sh language identifier, while preserving all existing commands and
formatting.
- Around line 48-50: Update the fenced code block containing the cargo bench
command to specify the sh language while preserving the command and its
formatting.
- Around line 14-31: Update the fenced directory-tree block in the README to
specify the text language, changing the opening fence to use text while
preserving the tree contents and formatting.
In `@src/io/parquet.rs`:
- Around line 108-211: Update the UTF-8 branch in arrow_array_to_basalt to
initialize StringBuilder with the Arrow string buffer size from
a.value_data().len() as its payload capacity, while preserving the existing
element-count capacity and null/value handling.
In `@src/physical_expr/unary.rs`:
- Around line 112-148: Keep the current Int64 and Float64 negation
implementations unchanged; the duplicated loops are an optional future refactor,
not a required fix. If refactoring now, extract the shared null-preserving
array-negation logic from NegExpr::evaluate while retaining checked negation for
Int64 and plain negation for Float64.
In `@src/physical_plan/aggregate/hash_table.rs`:
- Around line 90-106: Hoist the construction of rows_per_group out of the
per-aggregate loop in the aggregate update logic. Build it once from
group_of_row and self.group_keys.len(), then reuse it while iterating over
agg_arrays; preserve the existing empty-group handling, take gathering, and
accumulator updates.
In `@src/physical_plan/aggregate/mod.rs`:
- Around line 126-189: The strict scalars_to_array implementation is duplicated
across both aggregate and spill paths. Move one strict implementation into a
shared array/scalar utility, then replace the helpers in
src/physical_plan/aggregate/mod.rs:126-189 and
src/physical_plan/spill.rs:167-231 with calls to it; leave the lenient
scalars_to_array in src/physical_plan/sort.rs unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d10aa126-88ec-477d-bd46-cd99fb5ecd14
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (62)
BENCHMARKS.mdCargo.tomlREADME.mdbenches/kernel_only.rsbenches/row_vs_batch.rsbenches/tpch.rsexamples/profile_add.rssrc/array/array.rssrc/array/boolean.rssrc/array/mod.rssrc/array/primitive.rssrc/array/string.rssrc/array/types.rssrc/batch.rssrc/buffer/bitmap.rssrc/buffer/buffer.rssrc/buffer/mod.rssrc/buffer/mutable.rssrc/buffer/native.rssrc/compute/arith.rssrc/compute/boolean.rssrc/compute/cast.rssrc/compute/comparison.rssrc/compute/concat.rssrc/compute/filter.rssrc/compute/index.rssrc/compute/mod.rssrc/compute/sort.rssrc/compute/take.rssrc/io/mod.rssrc/io/parquet.rssrc/lib.rssrc/logical_plan/builder.rssrc/logical_plan/display.rssrc/logical_plan/mod.rssrc/logical_plan/plan.rssrc/physical_expr/binary.rssrc/physical_expr/cast.rssrc/physical_expr/column.rssrc/physical_expr/expr.rssrc/physical_expr/is_null.rssrc/physical_expr/literal.rssrc/physical_expr/mod.rssrc/physical_expr/unary.rssrc/physical_plan/aggregate/accumulator.rssrc/physical_plan/aggregate/group_keys.rssrc/physical_plan/aggregate/hash_table.rssrc/physical_plan/aggregate/mod.rssrc/physical_plan/filter.rssrc/physical_plan/join/hash_join.rssrc/physical_plan/join/mod.rssrc/physical_plan/join/nested_loop.rssrc/physical_plan/limit.rssrc/physical_plan/mod.rssrc/physical_plan/plan.rssrc/physical_plan/planner.rssrc/physical_plan/projection.rssrc/physical_plan/scan.rssrc/physical_plan/sort.rssrc/physical_plan/spill.rssrc/scalar.rssrc/types/schema.rs
🚧 Files skipped from review as they are similar to previous changes (11)
- src/buffer/mod.rs
- src/buffer/native.rs
- src/array/types.rs
- src/array/mod.rs
- src/buffer/mutable.rs
- src/buffer/bitmap.rs
- src/buffer/buffer.rs
- src/array/primitive.rs
- src/array/string.rs
- src/array/boolean.rs
- src/array/array.rs
| vec![ | ||
| Arc::new(quantity.finish()), | ||
| Arc::new(extendedprice.finish()), | ||
| Arc::new(discount.finish()), | ||
| Arc::new(shipdate.finish()), | ||
| ], |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Fix type inference mismatch in vec!.
The vec! macro determines the element type from the first element. Because quantity.finish() returns a PrimitiveArray<Int64Type> and extendedprice.finish() returns a PrimitiveArray<Float64Type>, they are different concrete types, which will cause a compilation error (expected struct PrimitiveArray<Int64Type>, found struct PrimitiveArray<Float64Type>).
Cast the elements (or at least the first one) to the expected trait object type using as _ to allow the compiler to successfully infer Vec<ArrayRef> (or Vec<Arc<dyn Array>>).
🐛 Proposed fix for explicit type casting
vec![
- Arc::new(quantity.finish()),
- Arc::new(extendedprice.finish()),
- Arc::new(discount.finish()),
- Arc::new(shipdate.finish()),
+ Arc::new(quantity.finish()) as _,
+ Arc::new(extendedprice.finish()) as _,
+ Arc::new(discount.finish()) as _,
+ Arc::new(shipdate.finish()) as _,
],📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| vec![ | |
| Arc::new(quantity.finish()), | |
| Arc::new(extendedprice.finish()), | |
| Arc::new(discount.finish()), | |
| Arc::new(shipdate.finish()), | |
| ], | |
| vec![ | |
| Arc::new(quantity.finish()) as _, | |
| Arc::new(extendedprice.finish()) as _, | |
| Arc::new(discount.finish()) as _, | |
| Arc::new(shipdate.finish()) as _, | |
| ], |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@benches/tpch.rs` around lines 113 - 118, Update the vector construction
containing quantity, extendedprice, discount, and shipdate so its elements are
cast with as _ to the expected ArrayRef trait-object type. Ensure the first
element, quantity.finish(), does not force a concrete Int64 array type, allowing
all heterogeneous arrays to compile as Vec<ArrayRef>.
| /// O(1): every column's `slice` is a zero-copy `Arc`-shared window. | ||
| pub fn slice(&self, offset: usize, len: usize) -> ColumnarBatch { | ||
| let columns = self.columns.iter().map(|c| c.slice(offset, len)).collect(); | ||
| ColumnarBatch { | ||
| schema: self.schema.clone(), | ||
| columns, | ||
| num_rows: len, | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add bounds checking to slice.
slice does not verify that offset + len <= self.num_rows. While the underlying non-empty arrays might panic on out-of-bounds slicing, a 0-column batch will silently succeed and illegally inflate its num_rows. Adding an explicit bounds check enforces the batch invariant and fails fast.
🛡️ Proposed fix
/// O(1): every column's `slice` is a zero-copy `Arc`-shared window.
pub fn slice(&self, offset: usize, len: usize) -> ColumnarBatch {
+ assert!(
+ offset + len <= self.num_rows,
+ "slice offset and length exceed batch row count"
+ );
let columns = self.columns.iter().map(|c| c.slice(offset, len)).collect();
ColumnarBatch {
schema: self.schema.clone(),
columns,
num_rows: len,
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// O(1): every column's `slice` is a zero-copy `Arc`-shared window. | |
| pub fn slice(&self, offset: usize, len: usize) -> ColumnarBatch { | |
| let columns = self.columns.iter().map(|c| c.slice(offset, len)).collect(); | |
| ColumnarBatch { | |
| schema: self.schema.clone(), | |
| columns, | |
| num_rows: len, | |
| } | |
| } | |
| /// O(1): every column's `slice` is a zero-copy `Arc`-shared window. | |
| pub fn slice(&self, offset: usize, len: usize) -> ColumnarBatch { | |
| assert!( | |
| offset + len <= self.num_rows, | |
| "slice offset and length exceed batch row count" | |
| ); | |
| let columns = self.columns.iter().map(|c| c.slice(offset, len)).collect(); | |
| ColumnarBatch { | |
| schema: self.schema.clone(), | |
| columns, | |
| num_rows: len, | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/batch.rs` around lines 357 - 365, Update ColumnarBatch::slice to validate
that offset + len does not exceed self.num_rows before slicing columns or
constructing the result. Fail fast on invalid ranges, including zero-column
batches, while preserving the existing zero-copy behavior for valid slices.
| (Float64, Int64) => { | ||
| let src = as_primitive::<Float64Type>(array)?; | ||
| map_primitive_to_primitive::<Float64Type, Int64Type>(src, |v| { | ||
| if v.is_finite() && v >= i64::MIN as f64 && v <= i64::MAX as f64 { | ||
| Ok(v as i64) | ||
| } else { | ||
| Err(BasaltError::Type { | ||
| message: format!("cannot cast {v} to Int64: out of range or not finite"), | ||
| }) | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
Rust f64 as i64 cast saturating behavior and i64::MAX rounding to f64
💡 Result:
In Rust, the as operator performs a saturating cast when converting a floating-point type (like f64) to an integer type (like i64) [1][2]. This behavior is well-defined [3]. The conversion rules for f64 to i64 using as are as follows: 1. Rounding: The fractional part of the f64 value is discarded, effectively rounding towards zero [1][2]. 2. Saturation: If the value exceeds the range of the destination integer type (i64), it is clamped to the type's bounds [3][2]. - Values greater than i64::MAX (including f64::INFINITY) saturate to i64::MAX [3][4]. - Values less than i64::MIN (including f64::NEG_INFINITY) saturate to i64::MIN [3][4]. 3. NaN handling: NaN (Not-a-Number) values are converted to 0 [3][4]. Regarding the conversion of i64::MAX to f64: - The type f64 uses an IEEE 754 double-precision floating-point format, which has a 53-bit significand. Consequently, it cannot exactly represent all 64-bit integers [1][2]. - The constant i64::MAX (2^63 - 1) is larger than the maximum integer exactly representable in f64 [2][5]. When you cast i64::MAX to f64, the value is rounded to the nearest representable f64 value [1][2]. Because of this loss of precision, casting i64::MAX to f64 and then back to i64 will not yield the original i64::MAX value [2]. If you require precise rounding or need to handle out-of-range values explicitly, it is recommended to use methods such as f64::round (to control the rounding strategy before casting) or to utilize the TryFrom trait for checked conversions, which can return an error if the value is out of range, rather than relying on the silent saturation of the as operator [1][6][7].
Citations:
- 1: https://learnrust.net/chapter-4/numeric-conversions/
- 2: https://rust-for-c-programmers.com/ch16/16_2_primitive_casting_with_as.html
- 3: Define UB in float-to-int casts to saturate rust-lang/rust#71269
- 4: https://rust.googlesource.com/rust/+/0ce5963923c8809036302d927ee6545cdd2071f9/tests/ui/numbers-arithmetic/saturating-float-casts-impl.rs
- 5: https://doc.rust-lang.org/std/primitive.f64.html
- 6: https://stackoverflow.com/questions/19607564/how-does-rust-handle-rounding-in-conversion-of-f64-to-i64
- 7: https://stackoverflow.com/questions/41138599/rounding-a-f64-to-nearest-i64-in-rust
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '--- src/compute/cast.rs (relevant excerpt) ---\n'
nl -ba src/compute/cast.rs | sed -n '1,90p'
printf '\n--- boundary probe (Rust semantics via Python float + reasoning) ---\n'
python3 - <<'PY'
import math
vals = [
float(2**63 - 1),
float(2**63),
float(2**63 + 1),
float(-(2**63)),
float(-(2**63) - 1),
]
for v in vals:
print(repr(v), 'finite=', math.isfinite(v), '>=min=', v >= float(-(2**63)), '<=max=', v <= float(2**63 - 1), '<2^63=', v < float(2**63 - 1))
PYRepository: unikdahal/basalt
Length of output: 245
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- src/compute/cast.rs (relevant excerpt) ---'
nl -ba src/compute/cast.rs | sed -n '35,60p'
echo
echo '--- float boundary probe ---'
python3 - <<'PY'
import math
max_i64 = 2**63 - 1
min_i64 = -(2**63)
vals = [
max_i64,
max_i64 + 1,
2**63,
min_i64,
min_i64 - 1,
]
for n in vals:
v = float(n)
print(
f"n={n} v={v!r} finite={math.isfinite(v)} "
f">=min={v >= float(min_i64)} <=max={v <= float(max_i64)} <2^63={v < float(2**63)}"
)
print("\nfloat(i64::MAX) == float(2**63):", float(max_i64) == float(2**63))
print("float(i64::MAX):", float(max_i64))
print("float(2**63):", float(2**63))
PYRepository: unikdahal/basalt
Length of output: 241
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- src/compute/cast.rs (relevant excerpt) ---'
cat -n src/compute/cast.rs | sed -n '35,60p'
echo
echo '--- float boundary probe ---'
python3 - <<'PY'
import math
min_i64 = -(2**63)
max_i64 = 2**63 - 1
for n in [max_i64, max_i64 + 1, 2**63, min_i64, min_i64 - 1]:
v = float(n)
print(
f"n={n} v={v!r} finite={math.isfinite(v)} "
f">=min={v >= float(min_i64)} <=max={v <= float(max_i64)} <2^63={v < float(2**63)}"
)
print("float(i64::MAX) == float(2**63):", float(max_i64) == float(2**63))
print("float(i64::MAX):", float(max_i64))
print("float(2**63):", float(2**63))
PYRepository: unikdahal/basalt
Length of output: 2029
Upper bound should be strict
i64::MAX as f64 rounds to 2^63, so v <= i64::MAX as f64 lets 2^63 through and v as i64 saturates to i64::MAX. Use < here.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/compute/cast.rs` around lines 41 - 52, Update the Float64-to-Int64
conversion in the (Float64, Int64) branch to require v to be strictly less than
i64::MAX as f64, while retaining the existing finite and lower-bound checks and
error behavior.
| fn row_group_may_match(stats: Option<&Statistics>, pred: &RowGroupPredicate) -> bool { | ||
| let Some(stats) = stats else { return true }; | ||
|
|
||
| let (min, max): (f64, f64) = match (stats, &pred.value) { | ||
| (Statistics::Int64(s), ScalarValue::Int64(Some(_))) => match (s.min_opt(), s.max_opt()) { | ||
| (Some(&min), Some(&max)) => (min as f64, max as f64), | ||
| _ => return true, | ||
| }, | ||
| (Statistics::Double(s), ScalarValue::Float64(Some(_))) => { | ||
| match (s.min_opt(), s.max_opt()) { | ||
| (Some(&min), Some(&max)) => (min, max), | ||
| _ => return true, | ||
| } | ||
| } | ||
| _ => return true, // Utf8/Boolean statistics, or a type mismatch: not supported here. | ||
| }; | ||
| let value = match pred.value { | ||
| ScalarValue::Int64(Some(v)) => v as f64, | ||
| ScalarValue::Float64(Some(v)) => v, | ||
| _ => return true, | ||
| }; | ||
|
|
||
| match pred.op { | ||
| BinaryOp::Gt => max > value, | ||
| BinaryOp::GtEq => max >= value, | ||
| BinaryOp::Lt => min < value, | ||
| BinaryOp::LtEq => min <= value, | ||
| BinaryOp::Eq => min <= value && value <= max, | ||
| _ => true, // NotEq and non-comparison ops: statistics can't prove exclusion. | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Float conversion loses precision for large Int64 statistics, defeating the function's own "never wrongly skip" guarantee.
(min as f64, max as f64) and ScalarValue::Int64(Some(v)) => v as f64 round every i64 beyond 2^53 (~9.007e15) to the nearest representable f64. Nanosecond-epoch timestamps and large surrogate/sequence IDs regularly exceed this threshold. Once rounded, a Gt/Lt/GtEq/LtEq comparison that is true on the real integers can become false (or vice versa) purely from rounding, causing a row group to be wrongly skipped — exactly the "silently wrong answer" failure mode the doc comment above (Line 227) says this function must never produce.
Keep Int64 statistics comparisons in exact i64 arithmetic; only convert to f64 for the Double/Float64 branch.
🐛 Proposed fix: compare Int64 statistics exactly, without float conversion
fn row_group_may_match(stats: Option<&Statistics>, pred: &RowGroupPredicate) -> bool {
let Some(stats) = stats else { return true };
- let (min, max): (f64, f64) = match (stats, &pred.value) {
- (Statistics::Int64(s), ScalarValue::Int64(Some(_))) => match (s.min_opt(), s.max_opt()) {
- (Some(&min), Some(&max)) => (min as f64, max as f64),
- _ => return true,
- },
- (Statistics::Double(s), ScalarValue::Float64(Some(_))) => {
- match (s.min_opt(), s.max_opt()) {
- (Some(&min), Some(&max)) => (min, max),
- _ => return true,
- }
- }
- _ => return true, // Utf8/Boolean statistics, or a type mismatch: not supported here.
- };
- let value = match pred.value {
- ScalarValue::Int64(Some(v)) => v as f64,
- ScalarValue::Float64(Some(v)) => v,
- _ => return true,
- };
-
- match pred.op {
- BinaryOp::Gt => max > value,
- BinaryOp::GtEq => max >= value,
- BinaryOp::Lt => min < value,
- BinaryOp::LtEq => min <= value,
- BinaryOp::Eq => min <= value && value <= max,
- _ => true, // NotEq and non-comparison ops: statistics can't prove exclusion.
- }
+ match (stats, &pred.value) {
+ (Statistics::Int64(s), ScalarValue::Int64(Some(value))) => {
+ let Some((&min, &max)) = s.min_opt().zip(s.max_opt()) else { return true };
+ match pred.op {
+ BinaryOp::Gt => max > *value,
+ BinaryOp::GtEq => max >= *value,
+ BinaryOp::Lt => min < *value,
+ BinaryOp::LtEq => min <= *value,
+ BinaryOp::Eq => min <= *value && *value <= max,
+ _ => true,
+ }
+ }
+ (Statistics::Double(s), ScalarValue::Float64(Some(value))) => {
+ let Some((&min, &max)) = s.min_opt().zip(s.max_opt()) else { return true };
+ match pred.op {
+ BinaryOp::Gt => max > *value,
+ BinaryOp::GtEq => max >= *value,
+ BinaryOp::Lt => min < *value,
+ BinaryOp::LtEq => min <= *value,
+ BinaryOp::Eq => min <= *value && *value <= max,
+ _ => true,
+ }
+ }
+ _ => true, // Utf8/Boolean statistics, or a type mismatch: not supported here.
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| fn row_group_may_match(stats: Option<&Statistics>, pred: &RowGroupPredicate) -> bool { | |
| let Some(stats) = stats else { return true }; | |
| let (min, max): (f64, f64) = match (stats, &pred.value) { | |
| (Statistics::Int64(s), ScalarValue::Int64(Some(_))) => match (s.min_opt(), s.max_opt()) { | |
| (Some(&min), Some(&max)) => (min as f64, max as f64), | |
| _ => return true, | |
| }, | |
| (Statistics::Double(s), ScalarValue::Float64(Some(_))) => { | |
| match (s.min_opt(), s.max_opt()) { | |
| (Some(&min), Some(&max)) => (min, max), | |
| _ => return true, | |
| } | |
| } | |
| _ => return true, // Utf8/Boolean statistics, or a type mismatch: not supported here. | |
| }; | |
| let value = match pred.value { | |
| ScalarValue::Int64(Some(v)) => v as f64, | |
| ScalarValue::Float64(Some(v)) => v, | |
| _ => return true, | |
| }; | |
| match pred.op { | |
| BinaryOp::Gt => max > value, | |
| BinaryOp::GtEq => max >= value, | |
| BinaryOp::Lt => min < value, | |
| BinaryOp::LtEq => min <= value, | |
| BinaryOp::Eq => min <= value && value <= max, | |
| _ => true, // NotEq and non-comparison ops: statistics can't prove exclusion. | |
| } | |
| } | |
| fn row_group_may_match(stats: Option<&Statistics>, pred: &RowGroupPredicate) -> bool { | |
| let Some(stats) = stats else { return true }; | |
| match (stats, &pred.value) { | |
| (Statistics::Int64(s), ScalarValue::Int64(Some(value))) => { | |
| let Some((&min, &max)) = s.min_opt().zip(s.max_opt()) else { return true }; | |
| match pred.op { | |
| BinaryOp::Gt => max > *value, | |
| BinaryOp::GtEq => max >= *value, | |
| BinaryOp::Lt => min < *value, | |
| BinaryOp::LtEq => min <= *value, | |
| BinaryOp::Eq => min <= *value && *value <= max, | |
| _ => true, | |
| } | |
| } | |
| (Statistics::Double(s), ScalarValue::Float64(Some(value))) => { | |
| let Some((&min, &max)) = s.min_opt().zip(s.max_opt()) else { return true }; | |
| match pred.op { | |
| BinaryOp::Gt => max > *value, | |
| BinaryOp::GtEq => max >= *value, | |
| BinaryOp::Lt => min < *value, | |
| BinaryOp::LtEq => min <= *value, | |
| BinaryOp::Eq => min <= *value && *value <= max, | |
| _ => true, | |
| } | |
| } | |
| _ => true, // Utf8/Boolean statistics, or a type mismatch: not supported here. | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/io/parquet.rs` around lines 229 - 259, Update row_group_may_match so
Int64 statistics and predicate values remain in exact i64 arithmetic, rather
than converting them to f64. Split the Int64 and Double/Float64 handling into
type-specific comparison paths, preserving the existing min/max fallback and
unsupported-type behavior; only the Double path should use f64 comparisons.
| pub fn aggregate( | ||
| self, | ||
| group_expr: Vec<Expr>, | ||
| aggr_expr: Vec<AggregateFunction>, | ||
| ) -> Result<Self> { | ||
| let mut fields = Vec::with_capacity(group_expr.len() + aggr_expr.len()); | ||
| for expr in &group_expr { | ||
| fields.push(Field::new( | ||
| expr_display_name(expr), | ||
| expr.data_type()?, | ||
| expr.nullable(), | ||
| )); | ||
| } | ||
| for agg in &aggr_expr { | ||
| // Per-kind output type, not the argument's own type: COUNT | ||
| // always outputs Int64 regardless of what it's counting, and | ||
| // AVG always outputs Float64 even over an Int64 column — using | ||
| // `agg.arg.data_type()` directly here would be wrong for both. | ||
| // Only SUM/MIN/MAX genuinely pass the argument's type through. | ||
| let data_type = match agg.kind { | ||
| AggregateKind::Count => crate::types::data_type::DataType::Int64, | ||
| AggregateKind::Avg => crate::types::data_type::DataType::Float64, | ||
| AggregateKind::Sum | AggregateKind::Min | AggregateKind::Max => match &agg.arg { | ||
| Some(e) => e.data_type()?, | ||
| None => { | ||
| return Err(crate::error::BasaltError::Internal(format!( | ||
| "{:?} requires an argument expression", | ||
| agg.kind | ||
| ))) | ||
| } | ||
| }, | ||
| }; | ||
| // COUNT never produces NULL (0 over empty/all-null input); every | ||
| // other aggregate can (SUM/AVG/MIN/MAX of an all-null group is | ||
| // NULL, not 0) — see accumulator.rs's null-semantics doc comment. | ||
| let nullable = agg.kind != AggregateKind::Count; | ||
| fields.push(Field::new(agg.output_name.clone(), data_type, nullable)); | ||
| } | ||
| let schema = Arc::new(Schema::new(fields)?); | ||
| Ok(LogicalPlanBuilder { | ||
| plan: Arc::new(LogicalPlan::Aggregate { | ||
| input: self.plan, | ||
| group_expr, | ||
| aggr_expr, | ||
| schema, | ||
| }), | ||
| }) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
expr_display_name collapses to "expr" for any non-Column expression, causing name collisions on multi-key aggregates.
aggregate() names each group_expr field via expr_display_name, which only special-cases Expr::Column; every other expression variant returns the literal "expr" (Line 169). A GROUP BY with two or more non-column expressions (e.g. GROUP BY a + 1, b + 1) produces two fields both named "expr", and Schema::new(fields) — which this same file's join() doc comment/tests confirm rejects duplicate names — will error out on construction.
🔧 Proposed fix: disambiguate by position
-fn expr_display_name(expr: &Expr) -> String {
- match expr {
- Expr::Column { index, .. } => format!("col_{index}"),
- _ => "expr".to_string(),
- }
-}
+fn expr_display_name(expr: &Expr, position: usize) -> String {
+ match expr {
+ Expr::Column { index, .. } => format!("col_{index}"),
+ _ => format!("expr_{position}"),
+ }
+}And update the call site to pass the loop index: for (i, expr) in group_expr.iter().enumerate() { fields.push(Field::new(expr_display_name(expr, i), ...)); }
Also applies to: 166-171
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/logical_plan/builder.rs` around lines 60 - 107, Update expr_display_name
and its aggregate() call site to accept the group-expression position and
disambiguate non-Column names, preserving column names while generating distinct
names for repeated expression outputs. Change the group_expr iteration in
aggregate() to pass the enumerated index, and update any other callers of
expr_display_name consistently.
…arison, filter, boolean, take, and sort MutableBuffer::freeze allocated a second full buffer and copied into it purely for alignment (on top of the one holding the computed result) - fixed by pre-padding MutableBuffer's own allocation so freeze can move instead of copy, with a tested fallback for the rare case a Vec reallocates mid-construction. compute::comparison had the same scalar-materialization and value(i)-re-derivation bugs arith.rs had before its own fast path, found via a new TPC-H Q6-shaped end-to-end benchmark (benches/tpch.rs) where Phase 2 initially ran slower than Phase 1's row-at-a-time interpreter. Chasing it down further surfaced the same Buffer::as_slice() re-derivation cost throughout the bit-packed Bitmap/BooleanArray path: filter.rs's predicate scan, boolean.rs's Kleene AND/OR/NOT, take.rs's gathers, and sort.rs's per-comparison-call downcast (called O(n log n) times) all paid it. Added Bitmap::as_bytes()/bit_offset() plus a bit_at() helper so each hot loop hoists the bitmap slice once instead of re-deriving it per element. Net effect on the TPC-H Q6 benchmark: Phase 2 went from 0.83x/0.87x/1.08x (slower than Phase 1) to 1.66x/1.81x/1.62x across N=1K/100K/1M. BENCHMARKS.md has the full writeup, including the harness bug (plan construction timed for Phase 2 but not Phase 1) found alongside these.
Summary
design-docs/basalt-phase2-lld.md:Buffer,MutableBuffer,Bitmap/BitmapBuilder, and theArraytrait withPrimitiveArray<T>/BooleanArray/StringArray+ builders.Column/Value(untouched — still whatexec/expr/iorun on), not a replacement yet. The LLD's full migration map (retargeting execution, CSV, and the binder onto these types) is a separate, much larger cut-over; this PR is deliberately just the foundation, built completely and tested hard, since the LLD itself says everything else depends on getting it right.-D warnings, fmt applied.Notable design decisions (deviating from the LLD in places, with reasoning)
Vec<u8>+ computedalign_offset, not a hand-rolled unsafe allocator — the LLD's own non-goal is "no unsafe unless measured," and a custom aligned allocator is exactly that.and/or/notare bit-at-a-time, not word-at-a-time yet — correctness first at the LLD's own flagged highest-bug-density area; the SIMD fast path is a benchmarked follow-up.try_new), not on every access — hot accessors (values(),value()) never need to returnResultor panic. Builders bypasstry_newvia a privatefrom_parts_uncheckedsince they produce trusted data themselves.StringBuilderonly appends whole&strs, so offsets always land on UTF-8 boundaries by construction —value()usesfrom_utf8_uncheckedjustified by that plus one whole-buffer UTF-8 check intry_new.Test plan
cargo buildcargo test— 168 passed, including the LLD-mandated bit-offset suite (1, 7, 8, 9, 63, 64, 65) and a zero-copy refcount proof forBuffer::slicecargo clippy --all-targets -- -D warningscargo fmt --all -- --checkSummary by CodeRabbit
New Features
Documentation