Skip to content

Commit b83ab8c

Browse files
rustyconoverclaude
andcommitted
feat: add TCP transport support (vgi-rpc 0.21.0)
Wire up the raw Arrow-IPC TCP transport added in vgi-rpc 0.21.0: - Worker.main() gains --tcp [HOST:]PORT, mutually exclusive with --http/--unix, emitting the TCP:<host>:<port> discovery line and serving via serve_tcp(). The meta/fixture worker (run_server) picks up --tcp directly from vgi-rpc. - Client gains transport="tcp" + Client.from_tcp(host, port), spawning connections via tcp_connect(); catalog_mixin routes catalog calls over TCP too. - New round-trip tests spawn vgi-fixture-worker --tcp, parse the discovery line, and drive table-function + catalog calls through Client.from_tcp. TCP framing carries no auth/encryption (loopback/trusted networks only); HTTP remains the transport for untrusted networks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent adb29b3 commit b83ab8c

6 files changed

Lines changed: 301 additions & 21 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ dependencies = [
4040
"pyarrow",
4141
"typer>=0.9",
4242
"platformdirs",
43-
"vgi-rpc>=0.20.5",
43+
"vgi-rpc>=0.21.0",
4444
"httpx>=0.24",
4545
]
4646

‎tests/test_tcp_transport.py‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""Round-trip tests for the TCP transport.
4+
5+
Spawns ``vgi-fixture-worker --tcp 127.0.0.1:0`` (raw Arrow-IPC framing over a
6+
TCP socket, served by ``vgi_rpc.rpc.run_server``), parses the ``TCP:host:port``
7+
discovery line it prints on stdout, then drives it through
8+
``Client.from_tcp(...)`` — the TCP analog of the HTTP round-trip in
9+
``tests/_http_fixtures.py``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import queue
15+
import subprocess
16+
import sys
17+
import threading
18+
from collections.abc import Iterator
19+
from contextlib import contextmanager
20+
21+
import pyarrow as pa
22+
import pytest
23+
24+
from vgi.arguments import Arguments
25+
from vgi.client import Client
26+
27+
28+
@contextmanager
29+
def run_tcp_worker(*, bind: str = "127.0.0.1:0") -> Iterator[tuple[str, int]]:
30+
"""Run ``vgi-fixture-worker --tcp`` and yield the bound ``(host, port)``.
31+
32+
The worker prints ``TCP:<host>:<port>`` once bound and then must not write
33+
further to stdout (the cross-language launcher discovery contract), so we
34+
read exactly one line to learn the port. stderr is drained in the
35+
background to keep the worker from blocking on a full pipe buffer.
36+
"""
37+
proc = subprocess.Popen(
38+
[sys.executable, "-m", "vgi._test_fixtures.worker", "--tcp", bind],
39+
stdout=subprocess.PIPE,
40+
stderr=subprocess.PIPE,
41+
text=True,
42+
bufsize=1,
43+
)
44+
45+
def _drain(pipe: object) -> None:
46+
for _ in pipe: # type: ignore[attr-defined]
47+
pass
48+
49+
stderr_thread = threading.Thread(target=_drain, args=(proc.stderr,), daemon=True)
50+
stderr_thread.start()
51+
52+
# Read the discovery line off stdout with a timeout so a worker that never
53+
# binds fails the test instead of hanging it.
54+
line_q: queue.Queue[str] = queue.Queue(maxsize=1)
55+
56+
def _read_line() -> None:
57+
assert proc.stdout is not None
58+
for line in proc.stdout:
59+
if line.startswith("TCP:"):
60+
line_q.put(line.strip())
61+
return
62+
63+
reader = threading.Thread(target=_read_line, daemon=True)
64+
reader.start()
65+
try:
66+
discovery = line_q.get(timeout=30)
67+
except queue.Empty:
68+
proc.terminate()
69+
raise TimeoutError("worker did not emit a TCP: discovery line within 30s") from None
70+
71+
_, host, port_str = discovery.split(":", 2)
72+
try:
73+
yield host, int(port_str)
74+
finally:
75+
proc.terminate()
76+
try:
77+
proc.wait(timeout=10)
78+
except subprocess.TimeoutExpired:
79+
proc.kill()
80+
proc.wait(timeout=5)
81+
stderr_thread.join(timeout=5)
82+
83+
84+
def test_tcp_round_trip_table_function() -> None:
85+
"""A table function streams rows correctly over the TCP transport."""
86+
with run_tcp_worker() as (host, port), Client.from_tcp(host, port) as client:
87+
batches = list(
88+
client.table_function(
89+
function_name="sequence",
90+
arguments=Arguments(positional=(pa.scalar(5),)),
91+
)
92+
)
93+
94+
table = pa.Table.from_batches(batches)
95+
assert table.column("n").to_pylist() == [0, 1, 2, 3, 4]
96+
97+
98+
def test_tcp_round_trip_catalog_listing() -> None:
99+
"""Catalog discovery works over the TCP transport (catalog_mixin path)."""
100+
with run_tcp_worker() as (host, port), Client.from_tcp(host, port) as client:
101+
catalogs = client.catalogs()
102+
103+
assert any(c.name == "example" for c in catalogs)
104+
105+
106+
class TestTcpConstructorValidation:
107+
"""``transport='tcp'`` argument validation."""
108+
109+
def test_requires_host_and_port(self) -> None:
110+
"""Tcp transport without host/port is rejected."""
111+
with pytest.raises(ValueError, match="requires tcp_host and tcp_port"):
112+
Client(transport="tcp", pool=None)
113+
114+
def test_rejects_server_path(self) -> None:
115+
"""server_path is subprocess-only."""
116+
with pytest.raises(ValueError, match="server_path is only meaningful"):
117+
Client("some-worker", transport="tcp", tcp_host="127.0.0.1", tcp_port=1, pool=None)
118+
119+
def test_rejects_base_url(self) -> None:
120+
"""base_url is http-only."""
121+
with pytest.raises(ValueError, match="base_url is only meaningful"):
122+
Client(transport="tcp", tcp_host="127.0.0.1", tcp_port=1, base_url="http://x", pool=None)

‎uv.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎vgi/client/catalog_mixin.py‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,21 +90,25 @@ class CatalogClientMixin:
9090
Catalog methods spawn ephemeral connections under the hood — for
9191
subprocess transport a pooled subprocess worker; for HTTP transport a
9292
short-lived ``http_connect`` session reusing the ``Client``'s shared
93-
``httpx.Client`` (bearer token, headers). Browsing catalogs over HTTP
94-
is the canonical non-DuckDB use case this mixin supports.
93+
``httpx.Client`` (bearer token, headers); for TCP transport a short-lived
94+
``tcp_connect`` session. Browsing catalogs over HTTP is the canonical
95+
non-DuckDB use case this mixin supports.
9596
96-
Other attributes expected from ``Client``: ``_transport`` (subprocess vs
97-
http), ``_base_url`` (HTTP base URL), and ``_get_or_create_httpx_client()``
98-
(shared HTTP client factory).
97+
Other attributes expected from ``Client``: ``_transport`` (subprocess,
98+
http, or tcp), ``_base_url`` (HTTP base URL), ``_tcp_host`` / ``_tcp_port``
99+
(TCP endpoint), and ``_get_or_create_httpx_client()`` (shared HTTP client
100+
factory).
99101
100102
Attributes:
101103
server_path: Worker shell command used for subprocess transport.
102104
"""
103105

104106
# Type hints for attributes expected from Client
105107
server_path: str
106-
_transport: Literal["subprocess", "http"]
108+
_transport: Literal["subprocess", "http", "tcp"]
107109
_base_url: str | None
110+
_tcp_host: str | None
111+
_tcp_port: int | None
108112
_external_location: Any | None
109113

110114
def _get_or_create_httpx_client(self) -> Any: # implemented by Client
@@ -128,7 +132,8 @@ def _catalog_connect(self) -> Iterator[VgiProtocol]:
128132
A typed `[`VgiProtocol`][]` proxy bound to the active transport.
129133
"""
130134
try:
131-
if getattr(self, "_transport", "subprocess") == "http":
135+
transport = getattr(self, "_transport", "subprocess")
136+
if transport == "http":
132137
from vgi_rpc.http import http_connect
133138

134139
httpx_client = self._get_or_create_httpx_client()
@@ -139,6 +144,17 @@ def _catalog_connect(self) -> Iterator[VgiProtocol]:
139144
external_location=getattr(self, "_external_location", None),
140145
) as proxy:
141146
yield proxy
147+
elif transport == "tcp":
148+
from vgi_rpc.rpc import tcp_connect
149+
150+
assert self._tcp_host is not None and self._tcp_port is not None
151+
with tcp_connect(
152+
VgiProtocol, # type: ignore[type-abstract]
153+
self._tcp_host,
154+
self._tcp_port,
155+
external_location=getattr(self, "_external_location", None),
156+
) as proxy:
157+
yield proxy
142158
else:
143159
cmd = shlex.split(self.server_path, posix=sys.platform != "win32")
144160
with _catalog_pool.connect(VgiProtocol, cmd) as proxy: # type: ignore[type-abstract]

0 commit comments

Comments
 (0)