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.
- vgi/_test_fixtures/table/cache.py +17 -3
- vgi/_test_fixtures/table_in_out.py +339 -0
- vgi/_test_fixtures/worker.py +6 -0
- vgi/catalog/catalog_interface.py +8 -0
- vgi/client/client.py +22 -9
- vgi/http/demo_storage.py +2 -2
- vgi/metadata.py +60 -0
- vgi/protocol.py +85 -9
- vgi/reporting/__init__.py +72 -0
- vgi/reporting/_arrow.py +26 -0
- vgi/reporting/_enums.py +69 -0
- vgi/reporting/_metadata.py +60 -0
- vgi/reporting/alerts.py +454 -0
- vgi/reporting/common.py +575 -0
- vgi/reporting/delegations.py +78 -0
- vgi/reporting/discovery.py +249 -0
- vgi/reporting/notify.py +178 -0
- vgi/reporting/objects.py +349 -0
- vgi/reporting/ownership.py +65 -0
- vgi/reporting/render.py +130 -0
- vgi/reporting/reports.py +330 -0
- vgi/reporting/resource_identity.py +186 -0
- vgi/reporting/resource_ownership.py +56 -0
- vgi/reporting/schedules.py +401 -0
- vgi/reporting/schedules_v2.py +406 -0
- vgi/reporting/sources.py +138 -0
- vgi/reporting/sql_tasks.py +476 -0
- vgi/table_in_out_function.py +40 -1
- {vgi_python-0.46.0.dist-info → vgi_python-0.47.0.dist-info}/METADATA +1 -1
- {vgi_python-0.46.0.dist-info → vgi_python-0.47.0.dist-info}/RECORD +33 -14
- {vgi_python-0.46.0.dist-info → vgi_python-0.47.0.dist-info}/WHEEL +0 -0
- {vgi_python-0.46.0.dist-info → vgi_python-0.47.0.dist-info}/entry_points.txt +0 -0
- {vgi_python-0.46.0.dist-info → vgi_python-0.47.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
-
|
|
1036
|
-
|
|
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."""
|
vgi/_test_fixtures/worker.py
CHANGED
|
@@ -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,
|
vgi/catalog/catalog_interface.py
CHANGED
|
@@ -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
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
2235
|
+
status = output.custom_metadata.get(STATUS_KEY)
|
|
2223
2236
|
|
|
2224
|
-
|
|
2225
|
-
|
|
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
|
|