Skip to content
Query.Farm
Talk with Us

Aggregate functions

On this page

Per-group state update, combine, and finalization.

source
public sealed class AggregateBindParams

Description

Parameters an IAggregateFunction sees on its aggregate_bind call — once per bound SQL call site (shared by every parallel worker connection DuckDB spawns for that one query).

Public members

public Schema? InputSchema { get; init; }

Schema of the NON-const ("Param") input columns — null only for a truly nullary aggregate with zero declared Param columns (e.g. vgi_count() ).

public byte[]? Secrets { get; init; }
public byte[]? Settings { get; init; }
public required TableArguments Arguments { get; init; }

Bind-time constant ( ConstParam ) values, keyed by their own sequential index among JUST the const positions — see Protocol.AggregateBindRequest.Arguments's doc comment.

public required string FunctionName { get; init; }
source
public sealed class AggregateCallParams

Description

Parameters an IAggregateFunction sees on every aggregate_update / _combine / _finalize call. Unlike AggregateBindParams, these three RPCs are each independently worker-pool-acquired unary calls (may land on a different process than the one that ran aggregate_bind ) — so Arguments is recovered from durable storage (stashed at bind time) rather than riding this call's own wire request, mirroring Buffering.TableBufferingProcessParams's doc comment for the same reason.

Public members

public ICallContext? Ctx { get; init; }

In-band log sink (surfaces as a duckdb_logs() row with type='VGI' ) — null only in a unit test that constructs this params object directly.

public required TableArguments Arguments { get; init; }

The SAME bind-time const-argument values AggregateBindParams.Arguments carried — reloaded from storage, since update/combine/finalize don't ride the original bind request.

public required byte[] ExecutionId { get; init; }
public required string FunctionName { get; init; }
source
public interface IAggregateFunction

Description

The raw contract a VGI aggregate function implements — SELECT f(…) FROM t GROUP BY g . Ported from vgi-python's/vgi-java's AggregateFunction , reshaped into the hand-rolled, Arrow-array-native style this port's other four function kinds (Scalar.IScalarFunction, Table.ITableFunction, TableInOut.ITableInOutFunction, Buffering.ITableBufferingFunction) already use, rather than attribute-driven reflection binding. Five phases, matching the C++ extension's AggregateFunction registration ( vgi_aggregate_function_impl.cpp ): Bind — Bind/ResolveOutputSchema, once per bound call site — validate arguments, resolve a dynamic (ANY) output type. Update — Update, once per input batch (parallel across threads/processes) — fold rows into per-group accumulator state, keyed by an opaque group_id the C++ side assigns (NOT the SQL-level GROUP BY key — purely an internal per-DuckDB-aggregate-state handle). Combine — Combine, whenever DuckDB merges two states (parallel aggregation across threads, OR a window segment tree) — fold one group's state into another's. Finalize — Finalize, once per requested batch of group ids — produce the single output value each group id resolves to. Destructor — best-effort cleanup, handled generically by VgiServiceImpl (wipes ALL of this execution's stored state) — no per-function hook needed. State lifecycle: every group's accumulator state is opaque byte[] this function chooses its own encoding for (a few packed primitives for vgi_sum / vgi_avg , an embedded Arrow IPC batch for something that needs to replay raw rows at finalize time like nest_tensor / vgi_percentile ). It is NEVER cached in-memory across calls — DuckDB spawns a SEPARATE OS PROCESS per parallel worker under the stdio/subprocess transport, so anything one call needs a LATER call (possibly in a different process) to see must round-trip through the state bytes Update/Combine return. "Must reassign to persist": Update's states dictionary is pre-populated ONLY with existing on-disk bytes for group ids that already had saved state; a brand-new group id is simply ABSENT until this implementation adds an entry for it. After the call returns, EVERY entry present in the dictionary (new or updated) is persisted — an entry never touched this call (present neither before nor added during it) stays untouched. This is what makes "the post-update state happens to be byte-identical to some notion of empty" harmless: presence in the dictionary, not a value comparison, is what triggers a write.