vgi-python 0.46.0__py3-none-any.whl → 0.47.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,7 @@ from dataclasses import dataclass
30
30
  from typing import Annotated, Any, ClassVar, cast
31
31
 
32
32
  import pyarrow as pa
33
+ import pyarrow.compute as pc
33
34
  from vgi_rpc import ArrowSerializableDataclass
34
35
  from vgi_rpc.rpc import OutputCollector
35
36
 
@@ -945,7 +946,7 @@ class CacheBenchFunction(TableFunctionGenerator[CacheBenchArgs, _CacheCountdownS
945
946
  # sequence [0..rows) so COUNT=rows and SUM=rows*(rows-1)/2 regardless of how the
946
947
  # chunks were distributed across workers. Positional Arg(0) so the direct path
947
948
  # `vgi_table_function(w, 'cache_parallel', [rows])` honors the size; a named
948
- # `batch_size` (default 24000) controls batch width for the 2 GB streaming guard.
949
+ # `batch_size` (default 24000) controls batch width for the disk-streaming guard (parallel_2gb.test_slow).
949
950
  _PC_ITEM_FMT = ">QQ" # (start, end) as two uint64
950
951
  _PC_ITEM_SIZE = struct.calcsize(_PC_ITEM_FMT)
951
952
 
@@ -1001,6 +1002,16 @@ class CacheParallelFunction(TableFunctionGenerator[CacheParallelArgs, _CachePara
1001
1002
 
1002
1003
  FunctionArguments = CacheParallelArgs
1003
1004
 
1005
+ _OFFSETS: ClassVar[dict[int, pa.Int64Array]] = {}
1006
+
1007
+ @classmethod
1008
+ def _offsets(cls, n: int) -> pa.Int64Array:
1009
+ """0 .. n-1 as int64, built once per batch size per process."""
1010
+ arr = cls._OFFSETS.get(n)
1011
+ if arr is None:
1012
+ arr = cls._OFFSETS[n] = pa.array(range(n), type=pa.int64())
1013
+ return arr
1014
+
1004
1015
  @classmethod
1005
1016
  def on_init(cls, params: InitParams[CacheParallelArgs]) -> GlobalInitResponse:
1006
1017
  """Primary worker enqueues (start, end) chunks covering [0, rows)."""
@@ -1032,8 +1043,11 @@ class CacheParallelFunction(TableFunctionGenerator[CacheParallelArgs, _CachePara
1032
1043
  state.current_idx = state.current_start
1033
1044
 
1034
1045
  batch_end = min(state.current_idx + params.args.batch_size, state.current_end or 0)
1035
- values = list(range(state.current_idx, batch_end))
1036
- batch = pa.RecordBatch.from_pydict({"v": values}, schema=params.output_schema)
1046
+ # v = current_idx .. batch_end-1, as one vectorised add to a cached 0..n-1
1047
+ # (a Python list costs ~1 µs a row, which the 640 MB streaming guard pays 80M times).
1048
+ base = cls._offsets(params.args.batch_size).slice(0, batch_end - state.current_idx)
1049
+ values = pc.add(base, pa.scalar(state.current_idx, type=pa.int64()))
1050
+ batch = pa.RecordBatch.from_arrays([values], schema=params.output_schema)
1037
1051
  cache_control = CacheControl(ttl=_DEFAULT_TTL_SECONDS) if not state.advertised else None
1038
1052
  cast(VgiOutputCollector, out).emit(batch, cache_control=cache_control)
1039
1053
  state.advertised = True
@@ -1883,6 +1883,345 @@ class BlendedExplodeFunction(RowTransformFunction[_ExplodeArgs]):
1883
1883
  cast("VgiOutputCollector", out).emit(out_batch, parent_rows=parent_rows)
1884
1884
 
1885
1885
 
1886
+ @dataclass(slots=True, frozen=True, kw_only=True)
1887
+ class _PagedArgs:
1888
+ """One positional fan-out count, and how many output rows each reply carries."""
1889
+
1890
+ n: Annotated[int, Arg(0, doc="Fan-out count: emit rows 0..n-1 for this input row")]
1891
+ page_size: Annotated[int, Arg("page_size", doc="Output rows per reply", default=2)] = 2
1892
+ cache_ttl: Annotated[
1893
+ int, Arg("cache_ttl", doc="When > 0, advertise vgi.cache.ttl on the first reply", default=0)
1894
+ ] = 0
1895
+ fail_on_page: Annotated[
1896
+ int, Arg("fail_on_page", doc="When > 0, fail instead of sending that (1-based) reply", default=0)
1897
+ ] = 0
1898
+ revalidate: Annotated[
1899
+ bool, Arg("revalidate", doc="Answer conditional requests with changed, paged output", default=False)
1900
+ ] = False
1901
+ not_modified_more: Annotated[
1902
+ bool,
1903
+ Arg(
1904
+ "not_modified_more",
1905
+ doc="With revalidate, answer a conditional request with an empty not_modified reply that says more",
1906
+ default=False,
1907
+ ),
1908
+ ] = False
1909
+
1910
+
1911
+ @dataclass(kw_only=True)
1912
+ class _PagedState(ArrowSerializableDataclass):
1913
+ """Where the next reply resumes: the input's fan-out counts and a position in them."""
1914
+
1915
+ counts: list[int] | None = None
1916
+ row: int = 0
1917
+ offset: int = 0
1918
+ call_rows: int = 0 # blended_sliced: the input rows of the current call
1919
+ page: int = 0 # replies sent for the current input
1920
+ empty_first: bool = False # not_modified_more: the first reply is empty and says more
1921
+ bump: int = 0 # added to every output value (revalidate: the data changed)
1922
+
1923
+
1924
+ class BlendedPagedFunction(RowTransformFunction[_PagedArgs, _PagedState]):
1925
+ """Blended 1->N fan-out returned across several replies, ``page_size`` rows each.
1926
+
1927
+ Same output as ``blended_explode`` (rows ``0..n-1`` per input row, with
1928
+ ``parent_rows`` provenance), but one input batch's output is split into pages:
1929
+ ``process()`` emits the first with ``more=True`` and ``resume()`` the rest, the
1930
+ way a function backed by a paged API would. The state holds only the counts
1931
+ and a position, never output rows, so it stays small in an HTTP state token.
1932
+ ``cache_ttl > 0`` makes the first reply cacheable, for the result-cache tests.
1933
+ ``fail_on_page`` and ``revalidate`` are described at :func:`_paged_begin`.
1934
+ """
1935
+
1936
+ class Meta:
1937
+ name = "blended_paged"
1938
+ description = "Blended 1->N fan-out (emit 0..n-1 per input row), page_size rows per reply"
1939
+ categories = ["blended", "test"]
1940
+
1941
+ @classmethod
1942
+ def on_bind(cls, params: BindParams[_PagedArgs]) -> BindResponse:
1943
+ if params.args.page_size < 1:
1944
+ raise ValueError(f"page_size must be at least 1, got {params.args.page_size}")
1945
+ if params.args.cache_ttl < 0:
1946
+ raise ValueError(f"cache_ttl must not be negative, got {params.args.cache_ttl}")
1947
+ return BindResponse(output_schema=schema({"i": pa.int64()}))
1948
+
1949
+ @classmethod
1950
+ def initial_state(cls, params: ProcessParams[_PagedArgs]) -> _PagedState:
1951
+ return _PagedState()
1952
+
1953
+ @classmethod
1954
+ def process(
1955
+ cls,
1956
+ params: ProcessParams[_PagedArgs],
1957
+ state: _PagedState,
1958
+ batch: pa.RecordBatch,
1959
+ out: OutputCollector,
1960
+ ) -> None:
1961
+ ttl = params.args.cache_ttl
1962
+ cc = _paged_begin(params, state, batch.column("n").to_pylist(), CacheControl(ttl=ttl) if ttl > 0 else None)
1963
+ cls._emit_page(params, state, out, cc)
1964
+
1965
+ @classmethod
1966
+ def resume(cls, params: ProcessParams[_PagedArgs], state: _PagedState, out: OutputCollector) -> None:
1967
+ cls._emit_page(params, state, out, None)
1968
+
1969
+ @classmethod
1970
+ def _emit_page(
1971
+ cls,
1972
+ params: ProcessParams[_PagedArgs],
1973
+ state: _PagedState,
1974
+ out: OutputCollector,
1975
+ cache_control: CacheControl | None,
1976
+ ) -> None:
1977
+ out_vals, parent_rows, more = _paged_next(params, state)
1978
+ cast("VgiOutputCollector", out).emit(
1979
+ pa.record_batch({"i": pa.array(out_vals, type=pa.int64())}),
1980
+ parent_rows=parent_rows,
1981
+ cache_control=cache_control,
1982
+ more=more,
1983
+ )
1984
+
1985
+
1986
+ def _paged_begin(
1987
+ params: ProcessParams[Any], state: _PagedState, counts: list[Any], cache_control: CacheControl | None
1988
+ ) -> CacheControl | None:
1989
+ """Start a new input for ``blended_paged`` / ``paged_explode``; return its first reply's cache control.
1990
+
1991
+ ``revalidate`` makes the output depend on the request alone. A plain request
1992
+ gets the whole output in one reply, cacheable but immediately stale
1993
+ (``ttl=0``, etag ``v1``, revalidatable), so the client stores it and revalidates
1994
+ every repeat. A conditional request (the client sends ``if_none_match``) is
1995
+ told the data changed: every value is 1000 larger, the etag is ``v2``, and the
1996
+ output is paged by ``page_size``. A client must then return the new pages and
1997
+ store nothing, since that output continued.
1998
+
1999
+ ``not_modified_more`` (with ``revalidate``) breaks the protocol on purpose: a
2000
+ conditional request is answered with an empty ``not_modified`` reply (etag
2001
+ ``v1``) that also says more, then the changed pages. A 304 stands for an input's
2002
+ whole stored output, so a client must refuse it rather than serve its entry.
2003
+ """
2004
+ state.counts = [0 if n is None or n < 0 else int(n) for n in counts]
2005
+ state.row = 0
2006
+ state.offset = 0
2007
+ state.page = 0
2008
+ state.bump = 0
2009
+ state.empty_first = False
2010
+ if not params.args.revalidate:
2011
+ return cache_control
2012
+ if params.if_none_match is None:
2013
+ state.bump = -1 # one reply: the whole output
2014
+ return CacheControl(ttl=0, etag="v1", revalidatable=True)
2015
+ state.bump = 1000
2016
+ if params.args.not_modified_more:
2017
+ state.empty_first = True
2018
+ return CacheControl(not_modified=True, ttl=0, etag="v1", revalidatable=True)
2019
+ return CacheControl(ttl=0, etag="v2", revalidatable=True)
2020
+
2021
+
2022
+ def _paged_next(params: ProcessParams[Any], state: _PagedState) -> tuple[list[int], list[int], bool]:
2023
+ """The next reply of ``blended_paged`` / ``paged_explode``, or the failure ``fail_on_page`` asks for."""
2024
+ state.page += 1
2025
+ if state.page == params.args.fail_on_page:
2026
+ raise RuntimeError(f"failing on page {state.page} as asked")
2027
+ if state.page == 1 and state.empty_first:
2028
+ return [], [], True
2029
+ if state.bump < 0:
2030
+ return _next_page(state, sys.maxsize)
2031
+ out_vals, parent_rows, more = _next_page(state, params.args.page_size)
2032
+ return [v + state.bump for v in out_vals], parent_rows, more
2033
+
2034
+
2035
+ def _next_page(state: _PagedState, page_size: int) -> tuple[list[int], list[int], bool]:
2036
+ """The next ``page_size`` rows of ``blended_paged``'s output, and whether more follow.
2037
+
2038
+ Rows are ``0..n-1`` for each input row in order, so a page may span input rows.
2039
+ Input rows with no output after the last non-empty page are skipped, so the
2040
+ last page is never followed by an empty one.
2041
+ """
2042
+ counts = state.counts or []
2043
+ out_vals: list[int] = []
2044
+ parent_rows: list[int] = []
2045
+ while state.row < len(counts) and len(out_vals) < page_size:
2046
+ take = min(counts[state.row] - state.offset, page_size - len(out_vals))
2047
+ out_vals.extend(range(state.offset, state.offset + take))
2048
+ parent_rows.extend([state.row] * take)
2049
+ state.offset += take
2050
+ if state.offset == counts[state.row]:
2051
+ state.row += 1
2052
+ state.offset = 0
2053
+ while state.row < len(counts) and counts[state.row] == 0:
2054
+ state.row += 1
2055
+ return out_vals, parent_rows, state.row < len(counts)
2056
+
2057
+
2058
+ @dataclass(slots=True, frozen=True, kw_only=True)
2059
+ class _SlicedArgs:
2060
+ """One positional fan-out count, and how many output rows each reply carries."""
2061
+
2062
+ n: Annotated[int, Arg(0, doc="Fan-out count: emit rows 0..n-1 for this input row")]
2063
+ page_size: Annotated[int, Arg("page_size", doc="Output rows per reply", default=1_000_000)] = 1_000_000
2064
+ cache_ttl: Annotated[int, Arg("cache_ttl", doc="If > 0, per-value cacheable for this many seconds", default=0)] = 0
2065
+
2066
+
2067
+ class BlendedSlicedFunction(RowTransformFunction[_SlicedArgs, _PagedState]):
2068
+ """``blended_paged`` that takes at most 3 input rows per call, and says how many it got.
2069
+
2070
+ Declares ``Meta.input_rows_per_call = 3``, so the client must split every input
2071
+ chunk into calls of at most 3 rows. Each output row carries ``call_rows``, the
2072
+ row count of the call that produced it, so a test can see the split. Output is
2073
+ otherwise ``blended_paged``'s (rows ``0..n-1`` per input row, ``page_size``
2074
+ rows per reply), which covers a split chunk whose calls also continue.
2075
+ ``cache_ttl > 0`` advertises per-value caching on each call's first reply, so the
2076
+ client stores each call's values for reuse.
2077
+ """
2078
+
2079
+ class Meta:
2080
+ name = "blended_sliced"
2081
+ description = "Blended 1->N fan-out taking at most 3 input rows per call, reporting each call's size"
2082
+ categories = ["blended", "test"]
2083
+ input_rows_per_call = 3
2084
+
2085
+ @classmethod
2086
+ def on_bind(cls, params: BindParams[_SlicedArgs]) -> BindResponse:
2087
+ if params.args.page_size < 1:
2088
+ raise ValueError(f"page_size must be at least 1, got {params.args.page_size}")
2089
+ if params.args.cache_ttl < 0:
2090
+ raise ValueError(f"cache_ttl must not be negative, got {params.args.cache_ttl}")
2091
+ return BindResponse(output_schema=schema({"i": pa.int64(), "call_rows": pa.int64()}))
2092
+
2093
+ @classmethod
2094
+ def initial_state(cls, params: ProcessParams[_SlicedArgs]) -> _PagedState:
2095
+ return _PagedState()
2096
+
2097
+ @classmethod
2098
+ def process(
2099
+ cls,
2100
+ params: ProcessParams[_SlicedArgs],
2101
+ state: _PagedState,
2102
+ batch: pa.RecordBatch,
2103
+ out: OutputCollector,
2104
+ ) -> None:
2105
+ if batch.num_rows > 3:
2106
+ raise RuntimeError(f"blended_sliced declares input_rows_per_call = 3 but got {batch.num_rows} rows")
2107
+ state.counts = [0 if n is None or n < 0 else int(n) for n in batch.column("n").to_pylist()]
2108
+ state.row = 0
2109
+ state.offset = 0
2110
+ state.call_rows = batch.num_rows
2111
+ ttl = params.args.cache_ttl
2112
+ cls._emit_page(params, state, out, CacheControl(ttl=ttl, per_value=True) if ttl > 0 else None)
2113
+
2114
+ @classmethod
2115
+ def resume(cls, params: ProcessParams[_SlicedArgs], state: _PagedState, out: OutputCollector) -> None:
2116
+ cls._emit_page(params, state, out, None)
2117
+
2118
+ @classmethod
2119
+ def _emit_page(
2120
+ cls,
2121
+ params: ProcessParams[_SlicedArgs],
2122
+ state: _PagedState,
2123
+ out: OutputCollector,
2124
+ cache_control: CacheControl | None,
2125
+ ) -> None:
2126
+ out_vals, parent_rows, more = _next_page(state, params.args.page_size)
2127
+ cast("VgiOutputCollector", out).emit(
2128
+ pa.record_batch(
2129
+ {
2130
+ "i": pa.array(out_vals, type=pa.int64()),
2131
+ "call_rows": pa.array([state.call_rows] * len(out_vals), type=pa.int64()),
2132
+ }
2133
+ ),
2134
+ parent_rows=parent_rows,
2135
+ cache_control=cache_control,
2136
+ more=more,
2137
+ )
2138
+
2139
+
2140
+ @dataclass(slots=True, frozen=True, kw_only=True)
2141
+ class _PagedTableArgs:
2142
+ """An input table whose first column is the fan-out count, and the page size."""
2143
+
2144
+ data: Annotated[TableInput, Arg(0, doc="Input table; its first column is the fan-out count")]
2145
+ page_size: Annotated[int, Arg("page_size", doc="Output rows per reply", default=2)] = 2
2146
+ cache_ttl: Annotated[
2147
+ int, Arg("cache_ttl", doc="When > 0, advertise vgi.cache.ttl on the first reply", default=0)
2148
+ ] = 0
2149
+ fail_on_page: Annotated[
2150
+ int, Arg("fail_on_page", doc="When > 0, fail instead of sending that (1-based) reply", default=0)
2151
+ ] = 0
2152
+ revalidate: Annotated[
2153
+ bool, Arg("revalidate", doc="Answer conditional requests with changed, paged output", default=False)
2154
+ ] = False
2155
+ not_modified_more: Annotated[
2156
+ bool,
2157
+ Arg(
2158
+ "not_modified_more",
2159
+ doc="With revalidate, answer a conditional request with an empty not_modified reply that says more",
2160
+ default=False,
2161
+ ),
2162
+ ] = False
2163
+
2164
+
2165
+ class PagedExplodeFunction(TableInOutGenerator[_PagedTableArgs, _PagedState]):
2166
+ """Classic (TABLE-input) table-in-out continuing its output across replies.
2167
+
2168
+ For each input row with count ``n`` (the table's first column), emits rows
2169
+ ``i = 0..n-1``, ``page_size`` per reply, with ``more=True`` on every reply but
2170
+ an input batch's last. The classic counterpart of ``blended_paged``: it runs
2171
+ through the extension's stock in-out path, on a per-substream connection.
2172
+ ``cache_ttl > 0`` makes each input's first reply cacheable. ``fail_on_page`` and
2173
+ ``revalidate`` are described at :func:`_paged_begin`.
2174
+ """
2175
+
2176
+ class Meta:
2177
+ name = "paged_explode"
2178
+ description = "Classic table-in-out 1->N fan-out, page_size rows per reply"
2179
+ categories = ["table_in_out", "test"]
2180
+
2181
+ @classmethod
2182
+ def on_bind(cls, params: BindParams[_PagedTableArgs]) -> BindResponse:
2183
+ if params.args.page_size < 1:
2184
+ raise ValueError(f"page_size must be at least 1, got {params.args.page_size}")
2185
+ if params.args.cache_ttl < 0:
2186
+ raise ValueError(f"cache_ttl must not be negative, got {params.args.cache_ttl}")
2187
+ return BindResponse(output_schema=schema({"i": pa.int64()}))
2188
+
2189
+ @classmethod
2190
+ def initial_state(cls, params: ProcessParams[_PagedTableArgs]) -> _PagedState:
2191
+ return _PagedState()
2192
+
2193
+ @classmethod
2194
+ def process(
2195
+ cls,
2196
+ params: ProcessParams[_PagedTableArgs],
2197
+ state: _PagedState,
2198
+ batch: pa.RecordBatch,
2199
+ out: OutputCollector,
2200
+ ) -> None:
2201
+ counts = batch.column(0).to_pylist() if batch.num_columns else []
2202
+ ttl = params.args.cache_ttl
2203
+ cls._emit_page(
2204
+ params, state, out, _paged_begin(params, state, counts, CacheControl(ttl=ttl) if ttl > 0 else None)
2205
+ )
2206
+
2207
+ @classmethod
2208
+ def resume(cls, params: ProcessParams[_PagedTableArgs], state: _PagedState, out: OutputCollector) -> None:
2209
+ cls._emit_page(params, state, out, None)
2210
+
2211
+ @classmethod
2212
+ def _emit_page(
2213
+ cls,
2214
+ params: ProcessParams[_PagedTableArgs],
2215
+ state: _PagedState,
2216
+ out: OutputCollector,
2217
+ cache_control: CacheControl | None,
2218
+ ) -> None:
2219
+ out_vals, _parent_rows, more = _paged_next(params, state)
2220
+ cast("VgiOutputCollector", out).emit(
2221
+ pa.record_batch({"i": pa.array(out_vals, type=pa.int64())}), cache_control=cache_control, more=more
2222
+ )
2223
+
2224
+
1886
2225
  @dataclass(slots=True, frozen=True, kw_only=True)
1887
2226
  class _AnyArgs:
1888
2227
  """One positional ANY-typed input column."""
@@ -279,6 +279,8 @@ from vgi._test_fixtures.table_in_out import (
279
279
  BlendedAnyVarargsFunction,
280
280
  BlendedDropFunction,
281
281
  BlendedExplodeFunction,
282
+ BlendedPagedFunction,
283
+ BlendedSlicedFunction,
282
284
  BufferEmitWideFunction,
283
285
  BufferInputFunction,
284
286
  CachedDoubleFunction,
@@ -304,6 +306,7 @@ from vgi._test_fixtures.table_in_out import (
304
306
  MultiBatchFinishFunction,
305
307
  OrderedBufferInputFunction,
306
308
  OrderedSourceFunction,
309
+ PagedExplodeFunction,
307
310
  ProjectableBlendedFunction,
308
311
  RepeatInputsFunction,
309
312
  RowSumFunction,
@@ -445,6 +448,9 @@ _EXAMPLE_CATALOG = Catalog(
445
448
  BlendedAnyFunction,
446
449
  BlendedAnyVarargsFunction,
447
450
  BlendedExplodeFunction,
451
+ BlendedPagedFunction,
452
+ BlendedSlicedFunction,
453
+ PagedExplodeFunction,
448
454
  CachedExplodeFunction,
449
455
  ProjectableBlendedFunction,
450
456
  HostileProvenanceFunction,
@@ -797,6 +797,9 @@ class FunctionInfo(CatalogSchemaObject, ArrowSerializableDataclass):
797
797
  subclassing; the C++ extension reads it to enter the in-out
798
798
  registration branch with real-typed args and drive the literal
799
799
  single-row scan-mode.
800
+ input_rows_per_call: For a blended ``RowTransformFunction``, the most
801
+ input rows one worker call receives; the client splits each input
802
+ chunk into calls of at most this many rows. Null sends a whole chunk.
800
803
  required_settings: Settings required by the function.
801
804
  required_secrets: Secrets required by the function (each entry has
802
805
  secret_type, optional secret_name, optional scope).
@@ -865,6 +868,10 @@ class FunctionInfo(CatalogSchemaObject, ArrowSerializableDataclass):
865
868
  # branch with real-typed args and drive the literal single-row scan-mode.
866
869
  input_from_args: bool = False
867
870
 
871
+ # Blended only: the most input rows per worker call; the client splits each
872
+ # input chunk into calls of at most this many rows. None = a whole chunk.
873
+ input_rows_per_call: int | None = None
874
+
868
875
  required_settings: list[str] = field(default_factory=list)
869
876
 
870
877
  required_secrets: list[SecretLookupEntry] = field(default_factory=list)
@@ -3698,6 +3705,7 @@ class ReadOnlyCatalogInterface(CatalogInterface):
3698
3705
  sink_order_dependent=meta.sink_order_dependent,
3699
3706
  requires_input_batch_index=meta.requires_input_batch_index,
3700
3707
  input_from_args=False if is_scalar else meta.input_from_args,
3708
+ input_rows_per_call=None if is_scalar else meta.input_rows_per_call,
3701
3709
  # Settings
3702
3710
  required_settings=meta.required_settings,
3703
3711
  # Secrets
vgi/client/client.py CHANGED
@@ -124,6 +124,9 @@ from vgi.invocation import (
124
124
  GlobalInitResponse,
125
125
  )
126
126
  from vgi.protocol import (
127
+ CONTINUE,
128
+ HAVE_MORE_OUTPUT,
129
+ STATUS_KEY,
127
130
  BindRequest,
128
131
  CopyFromContext,
129
132
  CopyToContext,
@@ -2147,9 +2150,10 @@ class Client(CatalogClientMixin, AggregateClientMixin):
2147
2150
  ) -> tuple[list[pa.RecordBatch], list[list[int]] | None]:
2148
2151
  """Send a batch to a worker and collect all output batches.
2149
2152
 
2150
- Sends the input batch via stream.exchange(), then checks the vgi.status
2151
- metadata. If the worker returns HAVE_MORE_OUTPUT, sends the same input
2152
- again. Continues until NEED_MORE_INPUT or no status (scalar functions).
2153
+ Sends the input batch via stream.exchange(), then checks the reply's
2154
+ vgi.status. While the worker returns HAVE_MORE_OUTPUT, sends a zero-row tick
2155
+ (``vgi.status: CONTINUE``) for its next batch. Stops at NEED_MORE_INPUT
2156
+ or no status (scalar functions).
2153
2157
 
2154
2158
  Args:
2155
2159
  worker: The worker connection to use. Must have stream initialized.
@@ -2181,17 +2185,26 @@ class Client(CatalogClientMixin, AggregateClientMixin):
2181
2185
 
2182
2186
  output_batches: list[pa.RecordBatch] = []
2183
2187
  parent_rows_batches: list[list[int]] | None = [] if decode_parent_rows else None
2188
+ tick = False
2184
2189
 
2185
2190
  while True:
2186
2191
  _logger.debug(
2187
- "sending_batch_to_worker worker_index=%s batch_index=%s num_rows=%s",
2192
+ "sending_batch_to_worker worker_index=%s batch_index=%s num_rows=%s tick=%s",
2188
2193
  worker.worker_index,
2189
2194
  batch_index,
2190
2195
  input_batch.num_rows,
2196
+ tick,
2191
2197
  )
2192
2198
 
2193
2199
  try:
2194
- annotated = self._maybe_externalize_input_batch(input_batch)
2200
+ if tick:
2201
+ # More output for the same input: ask for it with a zero-row tick.
2202
+ annotated = AnnotatedBatch(
2203
+ batch=input_batch.slice(0, 0),
2204
+ custom_metadata=pa.KeyValueMetadata({STATUS_KEY: CONTINUE}),
2205
+ )
2206
+ else:
2207
+ annotated = self._maybe_externalize_input_batch(input_batch)
2195
2208
  output = worker.stream.exchange(annotated)
2196
2209
  except RpcError as e:
2197
2210
  raise ClientError.from_rpc_error(e) from e
@@ -2216,13 +2229,13 @@ class Client(CatalogClientMixin, AggregateClientMixin):
2216
2229
  except RuntimeError as e:
2217
2230
  raise ClientError(str(e)) from e
2218
2231
 
2219
- # Check vgi.status for table-in-out status
2232
+ # HAVE_MORE_OUTPUT: this input has more output, fetched with a tick.
2220
2233
  status = None
2221
2234
  if output.custom_metadata:
2222
- status = output.custom_metadata.get(b"vgi.status")
2235
+ status = output.custom_metadata.get(STATUS_KEY)
2223
2236
 
2224
- # status is None for scalar functions (no status metadata)
2225
- if status == b"HAVE_MORE_OUTPUT":
2237
+ if status == HAVE_MORE_OUTPUT:
2238
+ tick = True
2226
2239
  continue
2227
2240
  elif status == b"NEED_MORE_INPUT" or status is None:
2228
2241
  break
vgi/http/demo_storage.py CHANGED
@@ -118,8 +118,9 @@ class BlobResource:
118
118
  resp.content_type = "application/octet-stream"
119
119
  resp.set_header("Accept-Ranges", "none")
120
120
  if content_encoding:
121
+ # HTTP clients decode this standard header automatically. Sending
122
+ # the custom VGI header too makes clients try to decode it again.
121
123
  resp.set_header("Content-Encoding", content_encoding)
122
- resp.set_header("X-VGI-Content-Encoding", content_encoding)
123
124
 
124
125
  def on_head(self, req: Any, resp: Any, blob_id: str) -> None: # noqa: D102
125
126
  # Mirror on_get headers (Content-Length/-Type/-Encoding) without a body.
@@ -137,7 +138,6 @@ class BlobResource:
137
138
  resp.set_header("Accept-Ranges", "none")
138
139
  if content_encoding:
139
140
  resp.set_header("Content-Encoding", content_encoding)
140
- resp.set_header("X-VGI-Content-Encoding", content_encoding)
141
141
 
142
142
  def on_put(self, req: Any, resp: Any, blob_id: str) -> None: # noqa: D102
143
143
  # vgi_rpc's _CompressionMiddleware drains ``req.bounded_stream`` when
vgi/metadata.py CHANGED
@@ -551,6 +551,10 @@ class ResolvedMetadata:
551
551
  positional args ARE its per-row input columns, so one registration
552
552
  serves the literal / column / LATERAL call shapes. Detected from
553
553
  ``RowTransformFunction`` subclassing.
554
+ input_rows_per_call: Only for a ``RowTransformFunction``: the most input
555
+ rows one worker call receives. The client splits each input chunk into
556
+ calls of at most this many rows (``1`` = a row per call). None (the
557
+ default) sends a whole chunk per call.
554
558
 
555
559
  """
556
560
 
@@ -647,6 +651,11 @@ class ResolvedMetadata:
647
651
  # drive the literal single-row scan-mode. function_type stays TABLE.
648
652
  input_from_args: bool = False
649
653
 
654
+ # RowTransformFunction only: the most input rows one worker call receives; the
655
+ # client splits each input chunk into calls of at most this many rows. None =
656
+ # a whole chunk per call.
657
+ input_rows_per_call: int | None = None
658
+
650
659
  def to_dict(self) -> dict[str, Any]:
651
660
  """Convert to dictionary for JSON serialization."""
652
661
  return {
@@ -691,6 +700,7 @@ class ResolvedMetadata:
691
700
  "sink_order_dependent": self.sink_order_dependent,
692
701
  "requires_input_batch_index": self.requires_input_batch_index,
693
702
  "input_from_args": self.input_from_args,
703
+ "input_rows_per_call": self.input_rows_per_call,
694
704
  }
695
705
 
696
706
  @staticmethod
@@ -746,6 +756,7 @@ class ResolvedMetadata:
746
756
  sink_order_dependent=d.get("sink_order_dependent", False),
747
757
  requires_input_batch_index=d.get("requires_input_batch_index", False),
748
758
  input_from_args=d.get("input_from_args", False),
759
+ input_rows_per_call=d.get("input_rows_per_call"),
749
760
  )
750
761
 
751
762
 
@@ -1146,6 +1157,7 @@ _VALID_META_ATTRIBUTES: frozenset[str] = frozenset(
1146
1157
  "filters_exactly_applied", # worker applies pushed filters exactly (engine may drop its own filter)
1147
1158
  "supports_positions", # addressable positions in the data (incremental / streaming reads)
1148
1159
  "split_token_ttl_seconds", # None = unbounded; a client refuses a TTL below its scheduling horizon
1160
+ "input_rows_per_call", # RowTransformFunction: most input rows per worker call (None = a whole chunk)
1149
1161
  "partition_kind", # opt-in to PartitionColumns mode for Hive-style partitioning
1150
1162
  # Table-in-out specific: explicit override for the has_finalize auto-detection.
1151
1163
  # Set to True or False to force the emitted ``in_out_function_final``
@@ -1347,6 +1359,9 @@ def resolve_metadata(cls: type) -> ResolvedMetadata:
1347
1359
  f"TableInput mode for a REQUIRED constant, or a named arg for optional config."
1348
1360
  )
1349
1361
 
1362
+ _validate_resume_state(cls, attrs.get("max_workers"))
1363
+ input_rows_per_call = _validate_input_rows_per_call(cls, attrs.get("input_rows_per_call"), input_from_args)
1364
+
1350
1365
  # Merge annotation-derived setting/secret keys into required_settings/required_secrets
1351
1366
  meta_required_settings: list[str] = list(attrs.get("required_settings", []))
1352
1367
 
@@ -1452,9 +1467,24 @@ def resolve_metadata(cls: type) -> ResolvedMetadata:
1452
1467
  sink_order_dependent=bool(attrs.get("sink_order_dependent", False)),
1453
1468
  requires_input_batch_index=bool(attrs.get("requires_input_batch_index", False)),
1454
1469
  input_from_args=input_from_args,
1470
+ input_rows_per_call=input_rows_per_call,
1455
1471
  )
1456
1472
 
1457
1473
 
1474
+ def _validate_input_rows_per_call(cls: type, value: Any, input_from_args: bool) -> int | None:
1475
+ """Check ``Meta.input_rows_per_call``: a positive int, on a RowTransformFunction only."""
1476
+ if value is None:
1477
+ return None
1478
+ if not input_from_args:
1479
+ raise TypeError(
1480
+ f"{cls.__name__}: Meta.input_rows_per_call applies only to a RowTransformFunction, whose "
1481
+ f"per-row input the client can split into calls"
1482
+ )
1483
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
1484
+ raise ValueError(f"{cls.__name__}: Meta.input_rows_per_call must be a positive int, got {value!r}")
1485
+ return value
1486
+
1487
+
1458
1488
  def _validate_partition_kind(cls: type, kind: PartitionKind) -> PartitionKind:
1459
1489
  """Cross-check ``Meta.partition_kind`` against the bind schema.
1460
1490
 
@@ -1536,6 +1566,35 @@ def _detect_has_finalize(cls: type, function_type: CatalogFunctionType) -> bool:
1536
1566
  return cls.has_finalize_override()
1537
1567
 
1538
1568
 
1569
+ def _validate_resume_state(cls: type, max_workers: object) -> None:
1570
+ """Refuse a table-in-out ``resume()`` the client cannot drive.
1571
+
1572
+ ``resume()`` receives no input, so it continues from ``state`` alone, and over
1573
+ HTTP that state reaches the next turn only through the stream's state token.
1574
+ Without a serializable state the cursor would be silently lost between turns.
1575
+
1576
+ ``max_workers = 1`` is refused too: the client then feeds every pipeline's
1577
+ input through one shared connection, which cannot leave output pending while
1578
+ another pipeline's input waits.
1579
+ """
1580
+ # Lazy imports to avoid a circular dependency.
1581
+ from vgi.protocol import _resolve_state_type
1582
+ from vgi.table_in_out_function import TableInOutGenerator
1583
+
1584
+ if not (isinstance(cls, type) and issubclass(cls, TableInOutGenerator)) or not cls.has_resume_override():
1585
+ return
1586
+ if max_workers == 1:
1587
+ raise TypeError(
1588
+ f"{cls.__name__}: resume() (continued output) is not supported with max_workers = 1; "
1589
+ f"the client runs such a function on one shared connection."
1590
+ )
1591
+ if _resolve_state_type(cls) is None:
1592
+ raise TypeError(
1593
+ f"{cls.__name__}: resume() continues from its state alone, so the class must declare a "
1594
+ f"serializable TState (an ArrowSerializableDataclass), e.g. RowTransformFunction[Args, State]."
1595
+ )
1596
+
1597
+
1539
1598
  def _detect_input_from_args(cls: type) -> bool:
1540
1599
  """Whether ``cls`` is a blended ("UNNEST-style") table-in-out function.
1541
1600
 
@@ -1646,6 +1705,7 @@ _METADATA_SCHEMA = pa.schema(
1646
1705
  pa.field("sink_order_dependent", pa.bool_()),
1647
1706
  pa.field("requires_input_batch_index", pa.bool_()),
1648
1707
  pa.field("input_from_args", pa.bool_()),
1708
+ pa.field("input_rows_per_call", pa.int64(), nullable=True),
1649
1709
  ]
1650
1710
  )
1651
1711