|
| 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() |
0 commit comments