Skip to content

Commit 9ce2a0a

Browse files
committed
docs: example worker for ordered buffering input (batch_index)
A buffering sink runs in parallel, so process() sees batches in arbitrary order — and nothing in the docs said how to get the input back in order. Meta.requires_input_batch_index is the answer, and it now works over any source: when the source cannot supply an index (range(), VALUES) the extension serializes the sink and numbers the batches itself rather than aborting the pipeline, so callers no longer have to wrap input in a temp table. Adds examples/batch_index_worker.py, which reports the index and row count of every buffered batch so the guarantee is directly observable, plus the "Getting the input in order" section that embeds it and contrasts the flag with sink_order_dependent.
1 parent 92df3dc commit 9ce2a0a

3 files changed

Lines changed: 187 additions & 0 deletions

File tree

docs/how-to/function-patterns.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,31 @@ ATTACH 'buffers' (TYPE vgi, LOCATION 'uv run row_count_worker.py');
213213
SELECT * FROM buffers.row_count((SELECT * FROM big_table));
214214
```
215215

216+
### Getting the input in order
217+
218+
The sink runs in parallel across DuckDB threads, so `process` sees batches in no particular order.
219+
When output depends on input *order* — a running total, a row pattern match — set
220+
`Meta.requires_input_batch_index = True` and each `process` call receives `params.batch_index`, a
221+
monotonic ordinal you can sort the buffered batches by in `finalize`.
222+
223+
Not every source can supply one (a base table scan can; `range()` and `VALUES` cannot). When it
224+
cannot, the extension serializes the sink and numbers the batches itself, so a worker always gets a
225+
valid index and callers never have to wrap their input. This example reports the indices it saw, one
226+
row per buffered batch:
227+
228+
```python
229+
--8<-- "examples/batch_index_worker.py"
230+
```
231+
232+
```sql
233+
ATTACH 'bi' (TYPE vgi, LOCATION 'uv run batch_index_worker.py');
234+
SELECT * FROM bi.batch_indexes((SELECT * FROM range(5000))) ORDER BY batch_index;
235+
```
236+
237+
The alternative, `Meta.sink_order_dependent = True`, delivers ordered input by forcing a
238+
single-threaded sink. It works with any source but gives up parallel ingest, which is usually the
239+
bulk of a buffering query's wall time. The two flags are mutually exclusive.
240+
216241
??? info "Buffering vs. table-in-out"
217242
Both consume a relation, but a **table-in-out** function emits *per input batch* and never
218243
holds the whole input — use it for streaming transforms (filter, enrich, reshape). Reach for

examples/batch_index_worker.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# /// script
2+
# requires-python = ">=3.13"
3+
# dependencies = ["vgi-python"]
4+
# ///
5+
"""A buffering function that asks for input order, and reports what it got.
6+
7+
A buffering sink normally runs in parallel across DuckDB threads, so batches
8+
arrive in no particular order. Setting ``Meta.requires_input_batch_index`` asks
9+
for DuckDB's per-chunk index alongside each batch, which is what lets a worker
10+
put the input back in order — the prerequisite for anything order-sensitive,
11+
like row pattern matching or a running total.
12+
13+
Not every source can supply one: a base table scan can, while ``range()`` and
14+
``VALUES`` cannot. When it cannot, the extension serializes the sink and numbers
15+
the batches itself, so a worker sees a valid monotonic index either way and never
16+
has to care which route produced it.
17+
18+
This function emits one row per buffered batch — its index and its row count — so
19+
the guarantee is directly observable:
20+
21+
ATTACH 'bi' (TYPE vgi, LOCATION 'uv run batch_index_worker.py');
22+
SELECT * FROM bi.batch_indexes((SELECT * FROM range(5000))) ORDER BY batch_index;
23+
"""
24+
25+
from dataclasses import dataclass
26+
from typing import Annotated
27+
28+
import pyarrow as pa
29+
from vgi_rpc import ArrowSerializableDataclass
30+
31+
from vgi import Arg, Worker
32+
from vgi.arguments import TableInput
33+
from vgi.catalog import Catalog, Schema
34+
from vgi.invocation import BindResponse
35+
from vgi.table_buffering_function import OutputCollector, TableBufferingFunction, TableBufferingParams
36+
from vgi.table_function import BindParams
37+
38+
_RESULT = pa.schema([("batch_index", pa.int64()), ("rows", pa.int64())])
39+
40+
# One log entry per buffered batch: the index DuckDB gave us, and the batch size.
41+
_NS = b"batches"
42+
43+
44+
@dataclass(slots=True, frozen=True, kw_only=True)
45+
class BatchIndexArgs:
46+
"""Arguments: the input table whose batches should be reported."""
47+
48+
data: Annotated[TableInput, Arg(0, doc="Input table")]
49+
50+
51+
@dataclass(kw_only=True)
52+
class DrainState(ArrowSerializableDataclass):
53+
"""Per-finalize-stream cursor: emit the report once, then finish."""
54+
55+
done: bool = False
56+
57+
58+
class BatchIndexes(TableBufferingFunction[BatchIndexArgs, DrainState]):
59+
"""Report the batch index and row count of every buffered input batch."""
60+
61+
class Meta:
62+
"""Function metadata."""
63+
64+
name = "batch_indexes"
65+
# Ask for DuckDB's per-chunk index. Mutually exclusive with
66+
# sink_order_dependent, which orders the input by serializing the sink
67+
# instead of by numbering it.
68+
requires_input_batch_index = True
69+
70+
@classmethod
71+
def on_bind(cls, params: BindParams[BatchIndexArgs]) -> BindResponse:
72+
"""Output shape is fixed: one row per input batch."""
73+
return BindResponse(output_schema=_RESULT)
74+
75+
@classmethod
76+
def process(cls, batch: pa.RecordBatch, params: TableBufferingParams[BatchIndexArgs]) -> bytes:
77+
"""Sink: record this batch's index and size.
78+
79+
``params.batch_index`` is populated because ``Meta`` asked for it; -1
80+
stands in for the absent case so an older host that does not supply one
81+
degrades to a visible marker instead of a crash.
82+
"""
83+
index = params.batch_index if params.batch_index is not None else -1
84+
payload = index.to_bytes(8, "little", signed=True) + batch.num_rows.to_bytes(8, "little")
85+
params.storage.state_append(_NS, b"", payload)
86+
return params.execution_id
87+
88+
@classmethod
89+
def combine(cls, state_ids: list[bytes], params: TableBufferingParams[BatchIndexArgs]) -> list[bytes]:
90+
"""Nothing to reduce: the log already holds one entry per batch."""
91+
return [params.execution_id]
92+
93+
@classmethod
94+
def initial_finalize_state(
95+
cls, finalize_state_id: bytes, params: TableBufferingParams[BatchIndexArgs]
96+
) -> DrainState:
97+
"""One cursor per finalize stream."""
98+
return DrainState()
99+
100+
@classmethod
101+
def finalize(
102+
cls,
103+
params: TableBufferingParams[BatchIndexArgs],
104+
finalize_state_id: bytes,
105+
state: DrainState,
106+
out: OutputCollector,
107+
) -> None:
108+
"""Source: emit the report, ordered by batch index."""
109+
if state.done:
110+
out.finish()
111+
return
112+
entries = [
113+
(
114+
int.from_bytes(value[:8], "little", signed=True),
115+
int.from_bytes(value[8:16], "little"),
116+
)
117+
for _id, value in params.storage.state_log_scan(_NS, b"")
118+
]
119+
entries.sort()
120+
out.emit(
121+
pa.RecordBatch.from_pydict(
122+
{
123+
"batch_index": [index for index, _rows in entries],
124+
"rows": [rows for _index, rows in entries],
125+
},
126+
schema=params.output_schema,
127+
)
128+
)
129+
state.done = True
130+
131+
132+
class BatchIndexWorker(Worker):
133+
"""A worker exposing the ``bi`` catalog."""
134+
135+
catalog = Catalog(
136+
name="bi",
137+
schemas=[Schema(name="main", functions=[BatchIndexes])],
138+
)
139+
140+
141+
if __name__ == "__main__":
142+
BatchIndexWorker().run()

tests/test_examples_workers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ def test_row_count_worker_buffering() -> None:
100100
assert [v for b in out for v in b.column("count").to_pylist()] == [5]
101101

102102

103+
def test_batch_index_worker_reports_input_order() -> None:
104+
"""The batch-index example sees a distinct index for every sunk batch."""
105+
with _spawn("batch_index_worker.py") as client:
106+
batches = [
107+
pa.record_batch({"x": pa.array([1, 2, 3], type=pa.int64())}),
108+
pa.record_batch({"x": pa.array([4, 5], type=pa.int64())}),
109+
pa.record_batch({"x": pa.array([6], type=pa.int64())}),
110+
]
111+
out = list(
112+
client.table_buffering_function(function_name="batch_indexes", schema_name="main", input=iter(batches))
113+
)
114+
indexes = [v for b in out for v in b.column("batch_index").to_pylist()]
115+
rows = [v for b in out for v in b.column("rows").to_pylist()]
116+
# One report row per input batch, ordered by index, and none defaulted to -1.
117+
assert indexes == sorted(indexes)
118+
assert len(set(indexes)) == 3
119+
assert min(indexes) >= 0
120+
assert sum(rows) == 6
121+
122+
103123
def test_greeting_scalar_worker_string_example() -> None:
104124
"""The string-scalar example (used in the function-patterns guide) still serves."""
105125
with _spawn("greeting_scalar_worker.py") as client:

0 commit comments

Comments
 (0)