State Management
FfiState<T> manages the lifecycle of aggregate state — allocation, initialization, access,
and destruction — so you never write raw pointer code for state management.
AggregateState trait
Any type that is Default + Send + 'static can be used as aggregate state by implementing
the AggregateState marker trait:
#![allow(unused)] fn main() { use quack_rs::aggregate::AggregateState; #[derive(Default, Debug)] struct MyState { config: usize, // set in update, must be propagated in combine total: i64, // accumulated data } impl AggregateState for MyState {} }
AggregateState has no required methods. The Default bound is used in state_init to
create fresh states.
FfiState<T>
FfiState<T> is a #[repr(C)] struct containing a single raw pointer:
#![allow(unused)] fn main() { #[repr(C)] pub struct FfiState<T> { inner: *mut T, } }
This matches DuckDB's expectation: DuckDB allocates state_size() bytes per group,
and your state lives in a Box<T> heap allocation whose pointer is stored in that space.
Memory layout
DuckDB-allocated slot (state_size bytes = sizeof(*mut T)):
[ inner: *mut T ] ──→ Box<T> (on the Rust heap)
Lifecycle callbacks
#![allow(unused)] fn main() { // state_size: DuckDB calls this once to know how many bytes to allocate per group FfiState::<MyState>::size_callback(_info) // Returns: size_of::<*mut MyState>() // state_init: DuckDB calls this once per group after allocating the slot FfiState::<MyState>::init_callback(info, state) // Effect: writes Box::into_raw(Box::new(MyState::default())) into the slot // state_destroy: DuckDB calls this after finalize for every group FfiState::<MyState>::destroy_callback(states, count) // Effect: for each state: drop(Box::from_raw(inner)); inner = null }
Accessing state in callbacks
#![allow(unused)] fn main() { // Immutable access (in finalize, combine source): if let Some(st) = FfiState::<MyState>::with_state(state_ptr) { let value = st.total; } // Mutable access (in update, combine target): if let Some(st) = FfiState::<MyState>::with_state_mut(state_ptr) { st.total += delta; } }
Both methods return Option<&T> / Option<&mut T>. They return None if inner is
null (which happens after destroy_callback or if initialization failed). Using Option
rather than panicking on null is what keeps the extension panic-free.
The double-free problem — solved
Without quack-rs, a naive destructor looks like:
#![allow(unused)] fn main() { // ❌ Naive — causes double-free if DuckDB calls destroy twice unsafe extern "C" fn destroy(states: *mut duckdb_aggregate_state, count: idx_t) { for i in 0..count as usize { let ffi = &mut *(*states.add(i) as *mut FfiState<MyState>); drop(Box::from_raw(ffi.inner)); // inner is now dangling — crash on second call } } }
FfiState::destroy_callback does:
#![allow(unused)] fn main() { // After drop(Box::from_raw(ffi.inner)): ffi.inner = std::ptr::null_mut(); // ← prevents double-free }
If DuckDB calls destroy again, with_state returns None and the loop body is a no-op.
Testing state logic without DuckDB
AggregateTestHarness<S> simulates the DuckDB aggregate lifecycle in pure Rust:
#![allow(unused)] fn main() { use quack_rs::testing::AggregateTestHarness; #[test] fn combine_propagates_config() { let mut source = AggregateTestHarness::<MyState>::new(); source.update(|s| { s.config = 5; // config field set during update s.total += 100; }); let mut target = AggregateTestHarness::<MyState>::new(); target.combine(&source, |src, tgt| { tgt.config = src.config; // must propagate config — Pitfall L1 tgt.total += src.total; }); let result = target.finalize(); assert_eq!(result.config, 5, "config must be propagated in combine"); assert_eq!(result.total, 100); } }
See the Testing Guide for the full test strategy.