Skip to content

Commit efc2396

Browse files
committed
test(fixtures): a streaming finalize that emits MANY batches
Every finalize fixture in every SDK emits exactly one batch, and one batch is the easy case: over HTTP a producer is strictly lock-step, so a single-batch flush completes inside its one turn and never needs a continuation. Two or more do — and that path was broken in two independent places, in the Rust worker and in the DuckDB client, for as long as nothing exercised it. multi_batch_finish emits one batch per input row the substream saw: the first carries that substream's total, the rest carry 0. The split is deliberate, so the two failure modes tell themselves apart — a wrong SUM means a batch's CONTENTS were lost or duplicated, a wrong COUNT means a whole BATCH was. That distinction is what catches a truncated flush, because the rows that do arrive are correct and only the count betrays the missing ones. Both invariants hold at any substream fan-out (each substream's partials sum to the whole, and its batch count equals its row count), so the SQL test needs no assumption about thread count. Backs vgi/test/sql/integration/table_in_out/multi_batch_finalize.test.
1 parent c0e38af commit efc2396

3 files changed

Lines changed: 86 additions & 1 deletion

File tree

‎uv.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎vgi/_test_fixtures/table_in_out.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class _LogDrainState(ArrowSerializableDataclass):
9595
"RowSumFunction",
9696
"BlendedDropFunction",
9797
"SubstreamPartialSumFunction",
98+
"MultiBatchFinishFunction",
9899
"SumAllColumnsFunction",
99100
"SumAllColumnsSimpleDistributed",
100101
"ExceptionProcessFunction",
@@ -195,6 +196,88 @@ def finish(
195196
return [pa.RecordBatch.from_pydict({name: [total]}, schema=params.output_schema)]
196197

197198

199+
@dataclass(kw_only=True)
200+
class MultiBatchFinishState(ArrowSerializableDataclass):
201+
"""This substream's running total and the number of rows it saw."""
202+
203+
total: int = 0
204+
rows: int = 0
205+
206+
207+
class MultiBatchFinishFunction(TableInOutFunction[SingleTableArguments, MultiBatchFinishState]):
208+
"""Streaming FINALIZE that emits MANY batches — the shape no SDK had a fixture for.
209+
210+
Every other finalize fixture returns a single batch, and one batch is the
211+
easy case: over HTTP a producer is strictly lock-step (one batch per
212+
response), so a single-batch flush completes in one turn and never needs a
213+
continuation. A flush of TWO OR MORE batches does, and in vgi-rust that path
214+
was a hard error rather than a pagination — invisible for as long as nothing
215+
exercised it.
216+
217+
So this emits one batch per input row the substream saw: ``rows`` batches,
218+
the first carrying that substream's ``total`` and the rest carrying 0. The
219+
split is deliberate — it makes the two failure modes distinguishable:
220+
221+
* ``SUM(n)`` wrong → a batch's CONTENTS were lost, duplicated or reordered.
222+
* ``COUNT(*)`` wrong → a BATCH was lost or repeated. This is the one that
223+
catches a broken continuation: a flush truncated after its first batch
224+
still sums correctly and only the count betrays it.
225+
226+
Both invariants hold regardless of how DuckDB fans the input across
227+
substreams (each substream's partials sum to the whole, and its batch count
228+
equals its row count), so the test needs no assumption about thread count.
229+
"""
230+
231+
class Meta:
232+
name = "multi_batch_finish"
233+
description = "Streaming finalize that emits one batch per input row (multi-batch flush)"
234+
categories = ["testing", "aggregation"]
235+
236+
@classmethod
237+
def cardinality(cls, params: BindParams[SingleTableArguments]) -> TableCardinality:
238+
# One output row per input row, but the input size is unknown up-front.
239+
return TableCardinality(estimate=1, max=None)
240+
241+
@classmethod
242+
def on_bind(cls, params: BindParams[SingleTableArguments]) -> BindResponse:
243+
assert params.bind_call.input_schema is not None
244+
field = params.bind_call.input_schema.field(0)
245+
return BindResponse(output_schema=schema({field.name: pa.int64()}))
246+
247+
@classmethod
248+
def initial_state(cls, params: ProcessParams[SingleTableArguments]) -> MultiBatchFinishState:
249+
return MultiBatchFinishState(total=0, rows=0)
250+
251+
@classmethod
252+
def transform(
253+
cls,
254+
batch: pa.RecordBatch,
255+
params: ProcessParams[SingleTableArguments],
256+
state: MultiBatchFinishState | None,
257+
) -> list[pa.RecordBatch]:
258+
if state is None:
259+
raise ValueError("State must not be None in transform()")
260+
col_sum = pc.sum(batch.column(0))
261+
if col_sum.is_valid:
262+
state.total += col_sum.as_py()
263+
state.rows += batch.num_rows
264+
return [] # accumulate only; the whole point is the flush
265+
266+
@classmethod
267+
def finish(
268+
cls,
269+
params: ProcessParams[SingleTableArguments],
270+
states: list[MultiBatchFinishState],
271+
) -> list[pa.RecordBatch]:
272+
total = sum(st.total for st in states)
273+
rows = sum(st.rows for st in states)
274+
name = params.output_schema.names[0]
275+
return [
276+
pa.RecordBatch.from_pydict({name: [total if i == 0 else 0]}, schema=params.output_schema)
277+
for i in range(rows)
278+
]
279+
280+
198281
class EchoFunction(TableInOutGenerator[SingleTableArguments]):
199282
"""Passthrough function that emits each input batch unchanged.
200283

‎vgi/_test_fixtures/worker.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@
292292
RepeatInputsFunction,
293293
RowSumFunction,
294294
SecretInOutFunction,
295+
MultiBatchFinishFunction,
295296
SubstreamPartialSumFunction,
296297
SumAllColumnsFunction,
297298
SumAllColumnsSimpleDistributed,
@@ -441,6 +442,7 @@ def _build_fixture_stats() -> tuple[
441442
SecretInOutFunction,
442443
RepeatInputsFunction,
443444
SlowCancellableInOutFunction,
445+
MultiBatchFinishFunction,
444446
SubstreamPartialSumFunction,
445447
SumAllColumnsFunction,
446448
SumAllColumnsSimpleDistributed,

0 commit comments

Comments
 (0)