Skip to content

Commit 5ab014a

Browse files
rustyconoverclaude
andcommitted
Share fixture workers through the launcher in tests; add launch: worker strings
Client(server_path) now accepts the DuckDB extension's launch:<argv> scheme, selecting transport="launch" with the argv split shlex-style, so one worker string names the same shared worker in both clients. The test suite uses it to reach its fixture workers by default: every fixture-worker Client goes through vgi_rpc.launcher, one warm worker per command shared by every xdist process, instead of each process growing its own pool (and subprocess-direct spawning a worker per test). At -n 32 on a 48-core box that was hundreds of live vgi-fixture-worker processes. - VGI_TEST_CLIENT_TRANSPORT=subprocess restores the per-process pools (the default on Windows, which has no AF_UNIX); the client_transport matrix runs launch + http, or the subprocess legs under that setting. - A per-session VGI_RPC_TEST_SESSION lands in the launcher's hash domain, so a session never reuses a warm worker from an earlier run of older code; the controller stops the session's workers at unconfigure. - The shared worker runs with --max-connections 0: at 64, scans holding connections while their next one waits in the backlog can deadlock. - Tests of the subprocess transport itself (stderr capture, force-stop, worker debug) and the catalog DDL suite (fresh InMemoryCatalog per test) stay on subprocess; test_subprocess_transport.py keeps pooled and direct covered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d6b3a73 commit 5ab014a

21 files changed

Lines changed: 264 additions & 71 deletions

‎tests/catalog/test_client_catalog.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,9 @@
1010
see tests/catalog/test_integration.py which exercises all catalog operations.
1111
"""
1212

13-
import sys
14-
13+
from tests.conftest import CATALOG_WORKER
1514
from vgi.client import Client
1615

17-
# Worker command for catalog tests
18-
CATALOG_WORKER = f"{sys.executable} -m vgi._test_fixtures.catalog"
19-
2016

2117
class TestClientCatalogStatelessOperations:
2218
"""Test catalog operations that work independently."""

‎tests/catalog/test_example_worker_catalog.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pyarrow as pa
1010

11+
from tests.conftest import FIXTURE_WORKER
1112
from vgi._test_fixtures.worker import ExampleWorker
1213
from vgi.catalog import (
1314
AttachOpaqueData,
@@ -23,7 +24,7 @@
2324
from vgi.client import Client
2425

2526
# Worker command for catalog tests
26-
EXAMPLE_WORKER = "vgi-fixture-worker"
27+
EXAMPLE_WORKER = FIXTURE_WORKER
2728

2829

2930
def _get_expected_function_names() -> set[str]:

‎tests/conformance/test_function_inventory.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import pytest
2121

22+
from tests.conftest import FIXTURE_WORKER
2223
from vgi.catalog.catalog_interface import AttachOpaqueData, SchemaObjectType
2324
from vgi.client.client import Client
2425

@@ -79,7 +80,7 @@ class TypeCoverage:
7980
@pytest.fixture(scope="module")
8081
def attached_example() -> tuple[str, AttachOpaqueData]:
8182
"""Attach to the ``example`` catalog once per module and yield ``(worker, attach_opaque_data)``."""
82-
worker = "vgi-fixture-worker"
83+
worker = FIXTURE_WORKER
8384
client = Client(worker)
8485
result = client.catalog_attach(
8586
name=EXAMPLE_CATALOG_NAME,
@@ -92,7 +93,7 @@ def attached_example() -> tuple[str, AttachOpaqueData]:
9293

9394
def test_example_catalog_is_attachable() -> None:
9495
"""Sanity: the example worker advertises a catalog named ``example``."""
95-
client = Client("vgi-fixture-worker")
96+
client = Client(FIXTURE_WORKER)
9697
names = [c.name for c in client.catalogs()]
9798
assert EXAMPLE_CATALOG_NAME in names, (
9899
f"Example worker should advertise the {EXAMPLE_CATALOG_NAME!r} catalog; got {names!r}"
@@ -149,7 +150,7 @@ def test_fixture_worker_registers_at_least_one_per_category(
149150
broken — both are drift we want to catch.
150151
"""
151152
_worker, attach_opaque_data = attached_example
152-
client = Client("vgi-fixture-worker")
153+
client = Client(FIXTURE_WORKER)
153154
infos = client.schema_contents(
154155
attach_opaque_data=attach_opaque_data,
155156
path=[EXAMPLE_SCHEMA_NAME],

‎tests/conftest.py‎

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,82 @@
33
"""Shared fixtures for VGI tests."""
44

55
import logging
6+
import os
7+
import shlex
8+
import shutil
9+
import subprocess
10+
import sys
11+
import uuid
612
from typing import Any
713

814
import pyarrow as pa
915
import pytest
1016

1117
from vgi import schema
1218

19+
# =============================================================================
20+
# Fixture-worker transport
21+
# =============================================================================
22+
23+
# How tests reach the fixture workers. ``launch`` (the default wherever AF_UNIX
24+
# exists) routes every fixture-worker ``Client`` through ``vgi_rpc.launcher``:
25+
# one warm worker per command, shared by every test process, so ``pytest -n 48``
26+
# talks to one worker instead of each xdist process growing its own pool (and
27+
# the matrix's ``subprocess-direct`` leg spawning a fresh worker per test) —
28+
# which put hundreds of ``vgi-fixture-worker`` processes on a 48-core box.
29+
# ``VGI_TEST_CLIENT_TRANSPORT=subprocess`` restores the per-process pools.
30+
TEST_TRANSPORT = os.environ.get("VGI_TEST_CLIENT_TRANSPORT") or ("subprocess" if sys.platform == "win32" else "launch")
31+
if TEST_TRANSPORT not in ("launch", "subprocess"):
32+
raise pytest.UsageError(f"VGI_TEST_CLIENT_TRANSPORT must be 'launch' or 'subprocess', got {TEST_TRANSPORT!r}")
33+
34+
# Part of the launcher's hash domain (it hashes every ``VGI_RPC_*`` variable), so
35+
# each session gets its own workers: never a warm one left by an earlier run of
36+
# older code, and never one another checkout on the machine is using. Set here,
37+
# before xdist spawns its workers, so every process of one session agrees on it.
38+
_SESSION_ENV = "VGI_RPC_TEST_SESSION"
39+
os.environ.setdefault(_SESSION_ENV, uuid.uuid4().hex)
40+
41+
_LAUNCHED: list[list[str]] = []
42+
43+
44+
def _fixture_command(*argv: str) -> str:
45+
"""Return the ``Client`` worker string for a fixture command under ``TEST_TRANSPORT``."""
46+
if TEST_TRANSPORT == "subprocess":
47+
return shlex.join(argv)
48+
# Every test process shares the one worker, so it must not cap connections:
49+
# at the default 64 a scan holding its connections while its next one waits
50+
# in the backlog can wait on the others doing the same, forever.
51+
launch_argv = [shutil.which(argv[0]) or argv[0], *argv[1:], "--max-connections", "0"]
52+
_LAUNCHED.append(launch_argv)
53+
return "launch:" + shlex.join(launch_argv)
54+
55+
56+
#: The protocol fixture worker, and the catalog fixture worker.
57+
FIXTURE_WORKER = _fixture_command("vgi-fixture-worker")
58+
CATALOG_WORKER = _fixture_command(sys.executable, "-m", "vgi._test_fixtures.catalog")
59+
60+
#: The protocol fixture worker as a plain subprocess, for tests of the
61+
#: subprocess transport itself (stderr capture, pooling).
62+
SUBPROCESS_FIXTURE_WORKER = "vgi-fixture-worker"
63+
64+
65+
def pytest_unconfigure(config: pytest.Config) -> None:
66+
"""Stop this session's launched workers rather than leaving them to idle out.
67+
68+
Only the controlling process does it — an xdist worker finishing early must
69+
not pull the shared worker out from under the others.
70+
"""
71+
if hasattr(config, "workerinput") or not _LAUNCHED or sys.platform == "win32":
72+
return
73+
from vgi_rpc.launcher import compute_hash, default_state_dir, gc_state_dir
74+
75+
for argv in _LAUNCHED:
76+
# The worker's argv carries ``--unix <state dir>/<hash>.sock``, and the
77+
# session variable makes that hash this session's alone.
78+
subprocess.run(["pkill", "-TERM", "-f", f"{compute_hash(argv)}.sock"], check=False)
79+
gc_state_dir(default_state_dir())
80+
81+
1382
# =============================================================================
1483
# Utility Functions (not fixtures, can be imported directly)
1584
# =============================================================================
@@ -72,8 +141,8 @@ def test_logger() -> logging.Logger:
72141

73142
@pytest.fixture
74143
def fixture_worker() -> str:
75-
"""Return the path to the example worker."""
76-
return "vgi-fixture-worker"
144+
"""Return the example worker, as a ``Client`` worker string for ``TEST_TRANSPORT``."""
145+
return FIXTURE_WORKER
77146

78147

79148
@pytest.fixture(scope="session")
@@ -118,8 +187,11 @@ def _start(*, extra_args: list[str] | None = None, env: dict[str, str] | None =
118187
# Keys used to identify transport modes in conformance tests. Keeping the
119188
# literal values here (rather than inside the fixture) makes it easy for
120189
# individual tests to opt out with ``@pytest.mark.parametrize("client_transport",
121-
# ["subprocess-pooled"], indirect=True)``.
122-
_CLIENT_TRANSPORT_MODES = ["subprocess-pooled", "subprocess-direct", "http"]
190+
# ["subprocess-pooled"], indirect=True)``. The subprocess legs join the default
191+
# matrix only under ``VGI_TEST_CLIENT_TRANSPORT=subprocess``.
192+
_CLIENT_TRANSPORT_MODES = (
193+
["launch", "http"] if TEST_TRANSPORT == "launch" else ["subprocess-pooled", "subprocess-direct", "http"]
194+
)
123195

124196

125197
@pytest.fixture(scope="session")
@@ -160,7 +232,8 @@ def client_transport(
160232
returned client as a context manager.
161233
162234
Modes:
163-
subprocess-pooled: Pool-backed subprocess (the default path).
235+
launch: The launcher-shared worker (``launch:`` worker string).
236+
subprocess-pooled: Pool-backed subprocess.
164237
subprocess-direct: ``pool=None`` — direct Popen management.
165238
http: ``Client.from_http(base_url)`` backed by a per-test
166239
``vgi-fixture-http`` subprocess. Skips if the HTTP transport
@@ -171,10 +244,14 @@ def client_transport(
171244
mode = request.param
172245

173246
def _make() -> Client:
247+
if mode == "launch":
248+
if TEST_TRANSPORT != "launch":
249+
pytest.skip("VGI_TEST_CLIENT_TRANSPORT=subprocess")
250+
return Client(fixture_worker)
174251
if mode == "subprocess-pooled":
175-
return Client(fixture_worker, pool=_default_pool)
252+
return Client(SUBPROCESS_FIXTURE_WORKER, pool=_default_pool)
176253
if mode == "subprocess-direct":
177-
return Client(fixture_worker, pool=None)
254+
return Client(SUBPROCESS_FIXTURE_WORKER, pool=None)
178255
if mode == "http":
179256
if not _HTTP_TRANSPORT_READY:
180257
pytest.skip("Client HTTP transport arrives in Phase 2 of whimsical-mccarthy plan")

‎tests/table/generator/test_exception_function.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pyarrow as pa
66
import pytest
77

8+
from tests.conftest import FIXTURE_WORKER
89
from vgi.arguments import Arguments
910
from vgi.client import Client, ClientError
1011

@@ -15,7 +16,7 @@ class TestGeneratorExceptionFunctionViaClient:
1516
def test_raises_exception_after_batches(self) -> None:
1617
"""Function should raise exception after specified batches via Client."""
1718
with (
18-
Client("vgi-fixture-worker") as client,
19+
Client(FIXTURE_WORKER) as client,
1920
pytest.raises(ClientError) as exc_info,
2021
):
2122
list(
@@ -31,7 +32,7 @@ def test_raises_exception_after_batches(self) -> None:
3132
def test_raises_exception_immediately(self) -> None:
3233
"""Function with fail_after=0 should raise immediately via Client."""
3334
with (
34-
Client("vgi-fixture-worker") as client,
35+
Client(FIXTURE_WORKER) as client,
3536
pytest.raises(ClientError) as exc_info,
3637
):
3738
list(
@@ -46,7 +47,7 @@ def test_raises_exception_immediately(self) -> None:
4647

4748
def test_outputs_batches_before_failure(self) -> None:
4849
"""Function should output batches before failing via Client."""
49-
with Client("vgi-fixture-worker") as client:
50+
with Client(FIXTURE_WORKER) as client:
5051
outputs: list[pa.RecordBatch] = []
5152
try:
5253
for batch in client.table_function(

‎tests/table/generator/test_filter_echo_function.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pyarrow as pa
66

7+
from tests.conftest import FIXTURE_WORKER
78
from vgi.arguments import Arguments
89
from vgi.client import Client
910

@@ -13,7 +14,7 @@ class TestFilterEchoFunction:
1314

1415
def test_no_filter_output(self) -> None:
1516
"""Without filters, pushed_filters should be '(none)' for all rows."""
16-
with Client("vgi-fixture-worker") as client:
17+
with Client(FIXTURE_WORKER) as client:
1718
batches = list(
1819
client.table_function(
1920
function_name="filter_echo",
@@ -36,7 +37,7 @@ def test_no_filter_output(self) -> None:
3637

3738
def test_count_argument(self) -> None:
3839
"""Verify row count matches the count argument."""
39-
with Client("vgi-fixture-worker") as client:
40+
with Client(FIXTURE_WORKER) as client:
4041
batches = list(
4142
client.table_function(
4243
function_name="filter_echo",
@@ -50,7 +51,7 @@ def test_count_argument(self) -> None:
5051

5152
def test_s_column_values(self) -> None:
5253
"""The s column should match 'row_{n}' pattern."""
53-
with Client("vgi-fixture-worker") as client:
54+
with Client(FIXTURE_WORKER) as client:
5455
batches = list(
5556
client.table_function(
5657
function_name="filter_echo",

‎tests/table/generator/test_logging_function.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pyarrow as pa
66

7+
from tests.conftest import FIXTURE_WORKER
78
from vgi.arguments import Arguments
89
from vgi.client import Client
910

@@ -13,7 +14,7 @@ class TestLoggingGeneratorFunctionViaClient:
1314

1415
def test_generates_correct_output(self) -> None:
1516
"""Function should generate correct output via Client."""
16-
with Client("vgi-fixture-worker") as client:
17+
with Client(FIXTURE_WORKER) as client:
1718
outputs = list(
1819
client.table_function(
1920
function_name="logging_generator",
@@ -31,7 +32,7 @@ def test_generates_correct_output(self) -> None:
3132

3233
def test_zero_count_produces_no_output(self) -> None:
3334
"""Function with count=0 should produce no output via Client."""
34-
with Client("vgi-fixture-worker") as client:
35+
with Client(FIXTURE_WORKER) as client:
3536
outputs = list(
3637
client.table_function(
3738
function_name="logging_generator",
@@ -44,7 +45,7 @@ def test_zero_count_produces_no_output(self) -> None:
4445

4546
def test_large_output(self) -> None:
4647
"""Function should handle larger outputs via Client."""
47-
with Client("vgi-fixture-worker") as client:
48+
with Client(FIXTURE_WORKER) as client:
4849
outputs = list(
4950
client.table_function(
5051
function_name="logging_generator",

‎tests/table/generator/test_partitioned_function.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import pyarrow as pa
1010

11+
from tests.conftest import FIXTURE_WORKER
1112
from vgi.arguments import Arguments
1213
from vgi.client import Client
1314

@@ -22,7 +23,7 @@ class TestPartitionedSequenceFunctionMultiWorker:
2223

2324
def test_two_workers_produce_complete_sequence(self) -> None:
2425
"""Two workers should together produce the complete sequence."""
25-
with Client("vgi-fixture-worker", worker_limit=2) as client:
26+
with Client(FIXTURE_WORKER, worker_limit=2) as client:
2627
outputs = list(
2728
client.table_function(
2829
function_name="partitioned_sequence",
@@ -39,7 +40,7 @@ def test_two_workers_produce_complete_sequence(self) -> None:
3940

4041
def test_three_workers_produce_complete_sequence(self) -> None:
4142
"""Three workers should together produce the complete sequence."""
42-
with Client("vgi-fixture-worker", worker_limit=3) as client:
43+
with Client(FIXTURE_WORKER, worker_limit=3) as client:
4344
outputs = list(
4445
client.table_function(
4546
function_name="partitioned_sequence",
@@ -56,7 +57,7 @@ def test_three_workers_produce_complete_sequence(self) -> None:
5657

5758
def test_workers_produce_large_sequence(self) -> None:
5859
"""Multiple workers should handle large sequences."""
59-
with Client("vgi-fixture-worker", worker_limit=4) as client:
60+
with Client(FIXTURE_WORKER, worker_limit=4) as client:
6061
outputs = list(
6162
client.table_function(
6263
function_name="partitioned_sequence",
@@ -73,7 +74,7 @@ def test_workers_produce_large_sequence(self) -> None:
7374

7475
def test_uneven_distribution(self) -> None:
7576
"""Workers should handle sequences that don't divide evenly."""
76-
with Client("vgi-fixture-worker", worker_limit=3) as client:
77+
with Client(FIXTURE_WORKER, worker_limit=3) as client:
7778
outputs = list(
7879
client.table_function(
7980
function_name="partitioned_sequence",
@@ -90,7 +91,7 @@ def test_uneven_distribution(self) -> None:
9091

9192
def test_single_worker_fallback(self) -> None:
9293
"""worker_limit=1 should work like single worker mode."""
93-
with Client("vgi-fixture-worker", worker_limit=1) as client:
94+
with Client(FIXTURE_WORKER, worker_limit=1) as client:
9495
outputs = list(
9596
client.table_function(
9697
function_name="partitioned_sequence",
@@ -107,7 +108,7 @@ def test_single_worker_fallback(self) -> None:
107108

108109
def test_increment_with_multi_workers(self) -> None:
109110
"""Multiple workers should handle increment parameter."""
110-
with Client("vgi-fixture-worker", worker_limit=2) as client:
111+
with Client(FIXTURE_WORKER, worker_limit=2) as client:
111112
outputs = list(
112113
client.table_function(
113114
function_name="partitioned_sequence",

‎tests/table/generator/test_projected_data_function.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pyarrow as pa
66

7+
from tests.conftest import FIXTURE_WORKER
78
from vgi.arguments import Arguments
89

910

@@ -14,7 +15,7 @@ def test_projection_via_client(self) -> None:
1415
"""Projection should work correctly via Client subprocess."""
1516
from vgi.client import Client
1617

17-
with Client("vgi-fixture-worker", worker_limit=1) as client:
18+
with Client(FIXTURE_WORKER, worker_limit=1) as client:
1819
outputs = list(
1920
client.table_function(
2021
function_name="projected_data",
@@ -36,7 +37,7 @@ def test_all_columns_via_client(self) -> None:
3637
"""All columns should be returned when no projection specified."""
3738
from vgi.client import Client
3839

39-
with Client("vgi-fixture-worker", worker_limit=1) as client:
40+
with Client(FIXTURE_WORKER, worker_limit=1) as client:
4041
outputs = list(
4142
client.table_function(
4243
function_name="projected_data",

0 commit comments

Comments
 (0)