You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add a third execution path for VGI aggregate functions, alongside the
existing GROUP BY (update/combine/finalize) and windowed (window/_init/
_batch) paths. Functions opt in via Meta.streaming_partitioned=True;
the VGI DuckDB extension's optimizer rule replaces eligible LogicalWindow
nodes with a custom streaming operator that pipes input chunks straight
to the worker — no DuckDB-side partition materialisation.
Three new classmethod hooks on AggregateFunction:
streaming_open(params) -> StreamingState
streaming_chunk(chunk, state, partition_key_count, order_key_count, params) -> pa.Array
streaming_close(state, params) -> None
The worker holds concurrent per-partition state in a hash map keyed by
partition tuple; each input row updates its partition's state and emits
a snapshot. Memory is bounded by partitions × per-partition-state, not
by row count — the structural answer to "running aggregate over
unbounded ordered input."
Wire protocol: three new unary RPCs (aggregate_streaming_open,
aggregate_streaming_chunk, aggregate_streaming_close), all carrying
the standard {request: binary} envelope shape. Session state is held
in an in-process LRU cache for the fast path and persisted to
FunctionStorage (under the existing aggregate_window_partition_put key)
so chunk RPCs landing on a different worker pool entry can rehydrate
correctly. Same affinity pattern as the windowed path.
Eligibility (enforced by the C++ optimizer rule, not this change):
cumulative frame only, no EXCLUDE/DISTINCT/FILTER/arg-orders,
no const-arg parameters in v1. Queries that don't satisfy fall back
to the standard windowed path; the streaming path is additive,
not a replacement.
Documented in docs/aggregate-functions.md — including a note that
pre-aggregation (GROUP BY ... + OVER) is the right pattern for most
analytics shapes; the streaming path's unique value is for shapes
where pre-aggregation isn't algebraically valid (per-fill running
views, very high cardinality, future continuous feeds).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/aggregate-functions.md
+68Lines changed: 68 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -286,6 +286,74 @@ worker = Worker(
286
286
287
287
The framework automatically detects `AggregateFunction` subclasses and registers them with the correct function type in the catalog.
288
288
289
+
## Streaming-Partitioned Variant
290
+
291
+
For `OVER (PARTITION BY ... ORDER BY ...)` queries against unbounded inputs (e.g. running aggregates across years of trade history), the standard windowed path materializes each partition in DuckDB memory before the aggregate sees it — fine for bounded data, OOMs at scale.
292
+
293
+
The `streaming_partitioned` opt-in routes those queries through a custom physical operator in the VGI DuckDB extension: input chunks pipe directly to the worker, the worker maintains concurrent per-partition state in a hash map keyed by partition tuple, and each input chunk produces a same-length output array of cumulative snapshots. No DuckDB-side partition materialization; memory is bounded by `partitions × state_per_partition`, not by row count.
294
+
295
+
```python
296
+
classMyRunningAgg(AggregateFunction[MyState]):
297
+
classMeta:
298
+
name ="my_running_agg"
299
+
streaming_partitioned =True# opt-in
300
+
# supports_window may also be set; the optimizer chooses the
301
+
# streaming path for eligible queries and falls back to the
# Cleanup hook (called once per session). Default: no-op.
330
+
...
331
+
```
332
+
333
+
**Eligibility for the streaming path** is decided by the extension's optimizer rule and requires:
334
+
335
+
-`streaming_partitioned = True` on the function's Meta.
336
+
- A cumulative frame: `ROWS/RANGE/GROUPS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` (or the implicit cumulative frame DuckDB emits when only `ORDER BY` is given).
337
+
- No `EXCLUDE`, `DISTINCT`, `FILTER (WHERE ...)`, or aggregate-arg `ORDER BY`.
338
+
- The worker function declares no const-arg parameters (v1 limitation).
339
+
340
+
Queries that don't satisfy all of these fall back to the standard windowed path automatically. The streaming path is opt-in and additive — it does not replace `update`/`combine`/`finalize`, which still service `GROUP BY` queries normally.
341
+
342
+
**When pre-aggregation is the better answer.** For most analytics shapes — "EOD positions per book per day, carrying forward across days" — pre-aggregating the input is the cleanest pattern in plain SQL:
343
+
344
+
```sql
345
+
WITH per_period_net AS (
346
+
SELECT book, period_key, symbol, SUM(quantity) AS quantity
347
+
FROM trades GROUP BY book, period_key, symbol
348
+
)
349
+
SELECT book, period_key,
350
+
my_running_agg(symbol, quantity)
351
+
OVER (PARTITION BY book ORDER BY period_key) AS running
352
+
FROM per_period_net;
353
+
```
354
+
355
+
The pre-aggregate collapses fills within each period before the OVER sees them, so the per-row output cardinality of the OVER matches the user's actual intent. The streaming path is the right tool when pre-aggregation isn't viable: per-fill running views, very high symbol cardinality per partition, or aggregates whose state isn't algebraically reducible by a pre-aggregate.
356
+
289
357
## Example Functions
290
358
291
359
See `vgi/examples/aggregate.py` for complete implementations:
0 commit comments