Aggregate State
This page covers aggregate state in a DuckDB aggregate function written in Rust:
the AggregateState trait and FfiState<T>, which manages each state's lifecycle
(allocation, initialisation, access and destruction) so that you do not write
raw-pointer code for it.
Known DuckDB limitation. Every C-API aggregate, and so every aggregate that uses
FfiState<T>, reads out of bounds underagg(x) OVER ()(whole-partition window frames) andagg(x ORDER BY y). This is a DuckDB C API defect; see Aggregate Functions for the details and DuckDB source lines. Do not use C-API aggregates in those two query shapes.Reported upstream as duckdb/duckdb#26109.
AggregateState trait
Any type that is Default + Send + Sync + 'static can be used as aggregate state by
implementing the AggregateState marker trait. The Sync bound is new in 0.18.0: a
window's segment tree lets several threads read the same state as a combine source at
once, so a state containing a Cell or RefCell would race. Use atomics or a Mutex
instead, or keep that data outside the state.
#![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. state_init uses Default to create each
fresh state.
FfiState<T>
FfiState<T> names the layout of the bytes DuckDB allocates for each group's
state, and the callbacks that manage them. The type itself is never constructed.
A small T, aligned no more strictly than usize and at most 256 bytes, is
stored in those bytes directly. A larger or more strictly aligned T is boxed,
and the slot holds the pointer. On wasm32, where usize is 4 bytes but u64,
i64 and f64 are 8-byte aligned, a state containing one of them is therefore
boxed. Either way the slot starts with a tag.
Memory layout
DuckDB-allocated slot (state_size bytes, a multiple of sizeof(usize)):
[ tag: usize ][ T, padded to whole words ] T stored inline
[ tag: usize ][ *mut T ] T boxed
│
└──→ Box<T> (on the Rust heap)
Storing T inline matters because DuckDB 1.4.4 to 1.5.5 does not destroy
every state. When a grouped aggregate's result scan stops early (a LIMIT
above it, an error, an interrupt), the states it never reached are never
destroyed; so is one state per row of a window frame with EXCLUDE. An inline
T's bytes belong to DuckDB, which frees them with the hash table; only what
T itself owns on the heap, or a boxed T's box, leaks. See
Known Limitations.
The tag marks the slot initialised. When one state_init call fails (a
panicking T::default(), say), DuckDB 1.4.4 to 1.5.5 still runs the
destructor over every state it created, including states whose state_init
never ran, so a slot can hold arbitrary bytes. destroy_callback drops only a
slot carrying the tag init_callback wrote, and clears the tag first. The tag
is derived from a hash of T's TypeId (and, for a boxed T, the box's
address), not from the slot's address, because DuckDB moves states by copying
their bytes. The check turns dropping garbage from a certainty into a matter
of chance: uninitialised bytes that happen to equal the tag. It mitigates the
DuckDB defect (described in the repository's docs/upstream-duckdb-reports.md);
it cannot guarantee against it.
Lifecycle callbacks
#![allow(unused)] fn main() { use libduckdb_sys::{duckdb_aggregate_state, duckdb_bind_info, duckdb_connection, duckdb_data_chunk, duckdb_function_info, duckdb_init_info, duckdb_vector, idx_t}; use quack_rs::aggregate::{AggregateState, FfiState}; #[derive(Default, Debug)] struct MyState { config: usize, total: i64 } impl AggregateState for MyState {} unsafe fn demo(_info: duckdb_function_info, info: duckdb_function_info, state: duckdb_aggregate_state, states: *mut duckdb_aggregate_state, count: idx_t) { // state_size: DuckDB calls this whenever an operator sizes its state buffers FfiState::<MyState>::size_callback(_info); // Returns: FfiState::<MyState>::size() (on a 64-bit target, a tag word, then the // usize and i64 inline) // state_init: DuckDB calls this for every state slot it allocates, combine // targets included FfiState::<MyState>::init_callback(info, state); // Effect: writes MyState::default() into the slot (or a box holding it), then the tag // destructor: DuckDB calls this after finalize, on combine's source states once // merged, and (after a failed state_init) on states never initialised, which // the tag makes it skip; not on every state (see Known Limitations) FfiState::<MyState>::destroy_callback(states, count); // Effect: for each state whose tag matches: clear the tag, then drop the T } }
Wiring them up: ffi_state::<T>()
The recommended way to register those three callbacks is ffi_state::<T>()
(new in 0.18.0). It installs size_callback, init_callback and
destroy_callback for the same T in one call, so the size DuckDB allocates
and the state init writes cannot disagree. Wired one by one with the
state_size, init and destructor setters, a size callback for one type
paired with an init callback for a larger one writes past DuckDB's allocation.
#![allow(unused)] fn main() { use libduckdb_sys::{duckdb_aggregate_state, duckdb_connection, duckdb_data_chunk, duckdb_function_info, duckdb_vector, idx_t}; use quack_rs::prelude::*; #[derive(Default, Debug)] struct MyState { config: usize, total: i64 } impl AggregateState for MyState {} unsafe extern "C" fn update(_: duckdb_function_info, _: duckdb_data_chunk, _: *mut duckdb_aggregate_state) {} unsafe extern "C" fn combine(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: *mut duckdb_aggregate_state, _: idx_t) {} unsafe extern "C" fn finalize(_: duckdb_function_info, _: *mut duckdb_aggregate_state, _: duckdb_vector, _: idx_t, _: idx_t) {} unsafe fn register(con: duckdb_connection) -> Result<(), ExtensionError> { unsafe { AggregateFunctionBuilder::new("my_agg") .param(TypeId::BigInt) .returns(TypeId::BigInt) .ffi_state::<MyState>() // state_size + init + destructor .update(update) .combine(combine) .finalize(finalize) .register(con)?; } Ok(()) } }
AggregateOverloadBuilder has the same method, for each overload of an
AggregateFunctionSetBuilder; the set builder itself has
none. update, combine and finalize still read the state through
FfiState::<T>::with_state / with_state_mut with the same T.
Accessing state in callbacks
#![allow(unused)] fn main() { use libduckdb_sys::{duckdb_aggregate_state, duckdb_bind_info, duckdb_connection, duckdb_data_chunk, duckdb_function_info, duckdb_init_info, duckdb_vector, idx_t}; use quack_rs::aggregate::{AggregateState, FfiState}; #[derive(Default, Debug)] struct MyState { config: usize, total: i64 } impl AggregateState for MyState {} unsafe fn demo(state_ptr: duckdb_aggregate_state, delta: i64) { // 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; } } }
The methods return Option<&T> and Option<&mut T> respectively: None if the
slot's tag does not match, which happens after destroy_callback has run or when
T::default() panicked in state_init. Returning Option instead of panicking
keeps a panic from unwinding across the FFI boundary
(Pitfall L3).
The double-free problem — solved
Without quack-rs, a naive destructor looks like:
#![allow(unused)] fn main() { use libduckdb_sys::{duckdb_aggregate_state, idx_t}; struct MyState; // A hand-written boxed layout: this is the code *without* quack-rs. #[repr(C)] struct FfiState<T> { inner: *mut T } // ❌ 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 clears the slot's tag before dropping the T,
and drops only a slot whose tag matches. If DuckDB calls destroy again, the tag
no longer matches, the slot is skipped, and with_state returns None.
Testing state logic without DuckDB
AggregateTestHarness<S> simulates the DuckDB aggregate lifecycle in pure Rust:
#![allow(unused)] fn main() { use quack_rs::aggregate::AggregateState; #[derive(Default, Debug)] struct MyState { config: usize, total: i64 } impl AggregateState for MyState {} 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.