Skip to content

Commit 072e543

Browse files
rustyconoverclaude
andcommitted
Key table-in-out state by substream, not by process
TableInOutFunction.process saved each connection's state under os.getpid(). Storage is scoped to the execution, which every connection of a fanned-out scan shares, so the key has to tell those connections apart — and a pid only does when each connection is its own process. Under the launcher, TCP or HTTP one process serves them all: they overwrote each other's state and finish() undercounted (substream_partial_sum: 194850 for 1999000). The key is now InitRequest.substream_id, the client-minted id that already exists to key per-substream state and that the DuckDB extension sends on every substream. The Python client never sent one; it now mints one per fan-out connection's INPUT stream (each its own substream of the one execution) and carries the primary's on the finalize. A client that sends none is keyed by pid, as before. The partial-sum tests run over every client transport (the HTTP leg is one process serving the whole fan-out), plus a check that a client sending no substream_id still sums over subprocess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 834ef56 commit 072e543

4 files changed

Lines changed: 86 additions & 21 deletions

File tree

‎tests/table_in_out/generator/test_substream_partial_sum_function.py‎

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,37 @@
22

33
"""Tests for SubstreamPartialSumFunction (parallel streaming finalize, A4).
44
5-
At the single-worker client level there is exactly one substream, so ``finish()``
6-
sees all input and emits one partial row equal to the whole sum. The per-substream
7-
fan-out (many partials re-aggregated by an outer ``SELECT sum()``) is exercised by
8-
the C++ integration test ``table_in_out/parallel_finalize.test`` over both
9-
transports; here we assert the worker-side transform/finish contract in isolation.
5+
The Python client fans the input across its connections, each its own substream
6+
of one execution, and finalizes once: ``finish()`` receives every substream's
7+
state and emits one partial row equal to the whole sum. The DuckDB-side fan-out
8+
(many partials re-aggregated by an outer ``SELECT sum()``) is exercised by the
9+
C++ integration test ``table_in_out/parallel_finalize.test`` over both transports.
10+
11+
Run over every client transport because the state is keyed per substream: over
12+
HTTP one process serves every connection of the fan-out, which is where a
13+
per-process key let the connections overwrite each other's state.
1014
"""
1115

1216
from __future__ import annotations
1317

18+
from typing import Any
19+
1420
import pyarrow as pa
21+
import pytest
1522

1623
from tests.conftest import filter_non_empty, make_schema
1724
from vgi.client import Client
25+
from vgi.client import client as client_module
1826

1927

2028
class TestSubstreamPartialSumFunction:
2129
"""Worker-side contract for substream_partial_sum."""
2230

23-
def test_partial_sum_single_batch(self, fixture_worker: str) -> None:
31+
def test_partial_sum_single_batch(self, client_transport: Any) -> None:
2432
"""One substream (single client worker) → one partial = the whole sum."""
2533
schema = make_schema([pa.field("n", pa.int64())])
2634
batch = pa.RecordBatch.from_pydict({"n": [1, 2, 3, 4, 5]}, schema=schema)
27-
with Client(fixture_worker) as client:
35+
with client_transport() as client:
2836
output = list(
2937
client.table_in_out_function(
3038
function_name="substream_partial_sum",
@@ -36,14 +44,14 @@ def test_partial_sum_single_batch(self, fixture_worker: str) -> None:
3644
assert len(non_empty) == 1
3745
assert non_empty[0].to_pydict() == {"n": [15]}
3846

39-
def test_partial_sum_many_batches(self, fixture_worker: str) -> None:
47+
def test_partial_sum_many_batches(self, client_transport: Any) -> None:
4048
"""Accumulates across many input batches, emits one partial at finalize."""
4149
schema = make_schema([pa.field("n", pa.int64())])
4250
batches = [
4351
pa.RecordBatch.from_pydict({"n": list(range(i * 100, (i + 1) * 100))}, schema=schema) for i in range(20)
4452
]
4553
expected = sum(range(0, 2000))
46-
with Client(fixture_worker) as client:
54+
with client_transport() as client:
4755
output = list(
4856
client.table_in_out_function(
4957
function_name="substream_partial_sum",
@@ -55,11 +63,11 @@ def test_partial_sum_many_batches(self, fixture_worker: str) -> None:
5563
assert len(non_empty) == 1
5664
assert non_empty[0].to_pydict() == {"n": [expected]}
5765

58-
def test_partial_sum_empty_input(self, fixture_worker: str) -> None:
66+
def test_partial_sum_empty_input(self, client_transport: Any) -> None:
5967
"""No input rows → the single substream's partial is 0."""
6068
schema = make_schema([pa.field("n", pa.int64())])
6169
empty = pa.RecordBatch.from_pydict({"n": []}, schema=schema)
62-
with Client(fixture_worker) as client:
70+
with client_transport() as client:
6371
output = list(
6472
client.table_in_out_function(
6573
function_name="substream_partial_sum",
@@ -71,3 +79,25 @@ def test_partial_sum_empty_input(self, fixture_worker: str) -> None:
7179
# A single substream with zero rows still finalizes → one 0 partial.
7280
assert len(non_empty) == 1
7381
assert non_empty[0].to_pydict() == {"n": [0]}
82+
83+
def test_a_client_sending_no_substream_id_still_sums_over_subprocess(self, monkeypatch: pytest.MonkeyPatch) -> None:
84+
"""An older client sends no ``substream_id``; the worker keys it by process, as before.
85+
86+
Over subprocess every connection is its own process, so that key still
87+
separates them — the behavior such a client had before the key moved.
88+
"""
89+
monkeypatch.setattr(client_module, "_substream_id_for", lambda phase: None)
90+
schema = make_schema([pa.field("n", pa.int64())])
91+
batches = [
92+
pa.RecordBatch.from_pydict({"n": list(range(i * 100, (i + 1) * 100))}, schema=schema) for i in range(20)
93+
]
94+
# A plain subprocess worker whatever transport the suite runs on.
95+
with Client("vgi-fixture-worker") as client:
96+
output = list(
97+
client.table_in_out_function(
98+
function_name="substream_partial_sum",
99+
schema_path=["main"],
100+
input=iter(batches),
101+
)
102+
)
103+
assert filter_non_empty(output)[0].to_pydict() == {"n": [sum(range(0, 2000))]}

‎vgi/_test_fixtures/table_in_out.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,9 @@ class SubstreamPartialSumFunction(TableInOutFunction[SingleTableArguments, Subst
138138
their finalize outputs, so the caller re-aggregates with an outer
139139
``SELECT sum(...)`` to get the global total — correct no matter how the rows
140140
were partitioned across substreams. Each substream's ``finish()`` reads only
141-
its OWN worker's accumulated state (keyed by the substream's execution_id;
142-
``params.substream_id`` is the stable client-owned key available for workers
143-
that manage cross-backend state themselves). This is the per-substream
141+
its OWN execution's accumulated state — the framework keeps one row per
142+
``params.substream_id`` in storage scoped to that execution, so it is found
143+
even when a finalize lands on a different backend. This is the per-substream
144144
finalize contract A4 enables — it is NOT a global cross-substream combine
145145
(that is a ``TableBufferingFunction``; see ``SumAllColumnsSimpleDistributed``).
146146
@@ -191,8 +191,9 @@ def finish(
191191
params: ProcessParams[SingleTableArguments],
192192
states: list[SubstreamPartialSumState],
193193
) -> list[pa.RecordBatch]:
194-
# `states` are THIS substream's accumulated states (one per worker pid that
195-
# handled this substream's batches); their sum is this substream's partial.
194+
# `states` are this execution's accumulated states, one per substream that
195+
# saw input (the Python client fans one execution across several); their
196+
# sum is this finalize's partial.
196197
total = sum(st.total for st in states)
197198
name = params.output_schema.names[0]
198199
return [pa.RecordBatch.from_pydict({name: [total]}, schema=params.output_schema)]

‎vgi/client/client.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,20 @@ def close(self) -> None:
189189
_LAUNCH_SCHEME = "launch:"
190190

191191
_DEFAULT_ACCEPTED_MAX_RESPONSE_BYTES = 256 * 1024 * 1024
192+
193+
194+
def _substream_id_for(phase: TableInOutFunctionInitPhase | None) -> bytes | None:
195+
"""Mint the ``InitRequest.substream_id`` for a connection's table-in-out INPUT stream.
196+
197+
Each connection a table-in-out call fans out to is its own substream of the
198+
one execution — the worker keeps one accumulated state per substream, and
199+
the single finalize drains them all. Sharing an id (or sending none, which
200+
the worker keys by process) lets connections served by one process
201+
overwrite each other's state. ``None`` for every other kind of init.
202+
"""
203+
return os.urandom(16) if phase == TableInOutFunctionInitPhase.INPUT else None
204+
205+
192206
_MIN_ACCEPTED_MAX_RESPONSE_BYTES = 65536
193207
_MAX_SAFE_HTTP_BYTES = (1 << 53) - 1
194208

@@ -221,11 +235,14 @@ class WorkerConnection:
221235
stream: The active streaming session, if any.
222236
proc: The worker subprocess for direct (non-pooled) subprocess transport.
223237
connection: The RPC connection for direct subprocess transport.
238+
substream_id: The ``InitRequest.substream_id`` this connection's
239+
table-in-out stream was opened with, or None.
224240
"""
225241

226242
proxy: VgiProtocol
227243
worker_index: int = 0
228244
stream: StreamSession | None = None
245+
substream_id: bytes | None = None
229246
# Subprocess transport, direct (non-pooled).
230247
proc: subprocess.Popen[bytes] | None = None
231248
connection: RpcConnection[VgiProtocol] | None = None
@@ -1637,6 +1654,7 @@ def _do_init(
16371654
finalize_state_id: bytes | None = None,
16381655
split_tokens: list[bytes] | None = None,
16391656
join_keys: list[pa.RecordBatch] | None = None,
1657+
substream_id: bytes | None = None,
16401658
) -> StreamSession:
16411659
"""Call init on a worker proxy and return a `StreamSession`.
16421660
@@ -1661,6 +1679,8 @@ def _do_init(
16611679
looked up by column name — see
16621680
``PushdownFilters.get_join_keys_column``), or `None` when not
16631681
applicable.
1682+
substream_id: The table-in-out substream this init opens or
1683+
finalizes (see ``_substream_id_for``), or `None`.
16641684
16651685
Returns:
16661686
`StreamSession` for data exchange or production.
@@ -1681,6 +1701,7 @@ def _do_init(
16811701
init_opaque_data=init_opaque_data,
16821702
finalize_state_id=finalize_state_id,
16831703
split_tokens=split_tokens,
1704+
substream_id=substream_id,
16841705
)
16851706
try:
16861707
stream: StreamSession = proxy.init(request=init_request) # type: ignore[assignment]
@@ -1769,6 +1790,7 @@ def _initialize_stream_common(
17691790
)
17701791
bind_response = self._do_bind(self._primary.proxy, bind_request, bind_result_callback)
17711792

1793+
substream_id = _substream_id_for(phase)
17721794
stream = self._do_init(
17731795
self._primary.proxy,
17741796
bind_request,
@@ -1780,8 +1802,10 @@ def _initialize_stream_common(
17801802
execution_id=split_execution_id,
17811803
init_opaque_data=split_init_opaque_data,
17821804
join_keys=join_keys,
1805+
substream_id=substream_id,
17831806
)
17841807
self._primary.stream = stream
1808+
self._primary.substream_id = substream_id
17851809

17861810
init_response = stream.typed_header(GlobalInitResponse)
17871811
max_workers = 1 if split_tokens is not None else self._determine_max_workers(init_response.max_workers)
@@ -1857,6 +1881,7 @@ def _spawn_additional_workers(
18571881

18581882
def do_init(worker: WorkerConnection) -> None:
18591883
try:
1884+
worker.substream_id = _substream_id_for(phase)
18601885
stream = self._do_init(
18611886
worker.proxy,
18621887
bind_request,
@@ -1867,6 +1892,7 @@ def do_init(worker: WorkerConnection) -> None:
18671892
join_keys=join_keys,
18681893
execution_id=global_init_response.execution_id,
18691894
init_opaque_data=global_init_response.opaque_data,
1895+
substream_id=worker.substream_id,
18701896
)
18711897
worker.stream = stream
18721898
except Exception as e:
@@ -2648,6 +2674,7 @@ def _finalize_primary_worker(
26482674
phase=TableInOutFunctionInitPhase.FINALIZE,
26492675
execution_id=init_response.execution_id,
26502676
init_opaque_data=init_response.opaque_data,
2677+
substream_id=self._primary.substream_id,
26512678
)
26522679

26532680
try:

‎vgi/table_in_out_function.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,8 @@ def finish(
360360
361361
Args:
362362
params: The process parameters — function args, settings, secrets.
363-
states: The accumulated per-partition states from ``transform()``.
363+
states: The accumulated states from ``transform()``, one per
364+
substream of this execution that saw input.
364365
365366
Returns:
366367
List of pa.RecordBatch to emit as final output.
@@ -412,11 +413,17 @@ def process(
412413
"""
413414
result = cls.transform(batch, params, state)
414415

415-
# Save state for distributed processing (upsert semantics)
416+
# Save state for distributed processing (upsert semantics), one row per
417+
# substream. Storage is scoped to the execution, which every connection
418+
# of a fanned-out scan shares, and one process serves many of those
419+
# connections under the launcher, TCP or HTTP — so a per-process key let
420+
# them overwrite each other and finish() undercounted. The pid remains
421+
# only for a client that sends no substream_id, which it keys as before.
416422
if state is not None:
417-
params.storage.state_put(
418-
FrameworkNS.TIO_STATE, BoundStorage.pack_int_key(os.getpid()), state.serialize_to_bytes()
419-
)
423+
key = params.substream_id
424+
if key is None:
425+
key = BoundStorage.pack_int_key(os.getpid())
426+
params.storage.state_put(FrameworkNS.TIO_STATE, key, state.serialize_to_bytes())
420427

421428
# Handle single batch or list of batches — exchange must emit exactly one
422429
if isinstance(result, list):

0 commit comments

Comments
 (0)