Skip to content

Commit 6ec7671

Browse files
committed
perf(ci): cancel flush-thread sleep so shutdown() returns in ms, not 5s
The Transport flush loop used `time.sleep(self.config.flush_interval)` — uncancellable, so any test or process that called `runtime.shutdown()` while the thread was mid-sleep blocked on `thread.join()` for the full default 5s flush_interval. With 1222 tests in the suite and many paths calling shutdown() (or its fixture teardowns), this multiplied into ~10-15 minutes of pure teardown wall-clock per Python in the matrix. Replace the bare sleep with `Event.wait`, which returns the instant `stop()` sets the event. `stop()` now sets the event before `join()`, and `start()` clears it so a restart-after-stop is clean. Pin contract in tests/test_transport.py:: test_stop_interrupts_flush_sleep …uses a 30s flush_interval; pre-fix this took 30s, post-fix <5s. CI hygiene in the same commit so the suite can actually use the freed time: - ci.yml / publish*.yml: enable pip cache (`cache: pip` + `cache-dependency-path: pyproject.toml`) — saves ~60-90s of cold install per matrix leg. - ci.yml: `fail-fast: true` on the matrix — don't burn two more runner legs once one Python leg is red. - ci.yml / coverage / publish*.yml: install `pytest-xdist>=3.6` and pass `-n auto` to pytest. `pytest-xdist` is also added to `[project.optional-dependencies.dev]` so a local `pip install -e .[dev]` brings it in. - pyproject.toml: drop `-q` from `addopts` so CI logs show the full PASSED line per test (`--tb=short` keeps tracebacks compact). `-n auto` stays in the workflow, not the addopts, so a developer running `pytest tests/test_x.py` gets a single process. No public API change. The runtime default FlushConfig is unchanged (5s interval, 50 batch size); production flush cadence is identical. The fix only shortens the worst-case shutdown latency.
1 parent b702390 commit 6ec7671

6 files changed

Lines changed: 119 additions & 13 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ jobs:
1111
runs-on: ubuntu-latest
1212
permissions:
1313
contents: read
14+
# 2026-07-08: fail-fast on the first matrix failure instead of
15+
# wasting runner minutes on the remaining Python versions when
16+
# the suite is already red. Speed gain is per-run, not per-test.
1417
strategy:
18+
fail-fast: true
1519
matrix:
1620
python: ["3.10", "3.11", "3.12"]
1721

@@ -22,14 +26,29 @@ jobs:
2226
uses: actions/setup-python@v5
2327
with:
2428
python-version: ${{ matrix.python }}
29+
# Cache pip's download cache keyed on the lock-relevant
30+
# surfaces of pyproject.toml. Skips the ~60-90s cold
31+
# install on warm caches; the action also reuses the
32+
# cache across matrix legs when the key matches.
33+
cache: "pip"
34+
cache-dependency-path: pyproject.toml
2535

2636
- name: Install dependencies
2737
run: |
2838
python -m pip install --upgrade pip
29-
pip install -e ".[dev]"
39+
# xdist ships in the dev tree already; pin it explicitly so
40+
# a future deps churn can't drop it without breaking CI.
41+
pip install -e ".[dev]" "pytest-xdist>=3.6"
3042
3143
- name: Run tests
32-
run: pytest
44+
# `-n auto` lets xdist pick a worker count from the runner's
45+
# CPU count. With the transport cancellable-sleep fix the
46+
# 5s-per-shutdown multiplier is gone, and xdist plus the
47+
# existing respx-based mocking keeps the per-test wall clock
48+
# near single-thread baseline (no shared state between
49+
# workers — ``reset_runtime`` autouse fixture in conftest
50+
# is per-process by construction under xdist).
51+
run: pytest -n auto --durations=20
3352

3453
- name: Run ruff
3554
run: ruff check src/
@@ -46,11 +65,16 @@ jobs:
4665
- uses: actions/setup-python@v5
4766
with:
4867
python-version: "3.12"
49-
- run: pip install -e ".[dev]"
50-
- run: coverage run -m pytest
68+
cache: "pip"
69+
cache-dependency-path: pyproject.toml
70+
- run: pip install -e ".[dev]" "pytest-xdist>=3.6"
71+
# Single Python leg for coverage — multi-version coverage
72+
# reports don't add signal and double the runner time. 3.12
73+
# is the modern floor for typing-only changes.
74+
- run: coverage run -m pytest -n auto
5175
- uses: codecov/codecov-action@v4
5276
if: always()
5377
with:
5478
token: ${{ secrets.CODECOV_TOKEN }}
5579
files: ./coverage.xml
56-
fail_ci_if_error: false
80+
fail_ci_if_error: false

.github/workflows/publish-test.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ jobs:
1919
- uses: actions/setup-python@v5
2020
with:
2121
python-version: ${{ matrix.python-version }}
22+
cache: "pip"
23+
cache-dependency-path: pyproject.toml
2224

2325
- name: Install dependencies
24-
run: pip install -e ".[dev]"
26+
run: pip install -e ".[dev]" "pytest-xdist>=3.6"
2527

2628
- name: Run tests
27-
run: pytest tests/ -v
29+
run: pytest tests/ -v -n auto
2830

2931
publish:
3032
name: Build and publish to TestPyPI
@@ -62,4 +64,4 @@ jobs:
6264
# re-runs of the same SHA a no-op (matching twine's
6365
# --skip-existing behaviour). Production PyPI cannot
6466
# overwrite anyway, so this flag is harmless there too.
65-
skip-existing: true
67+
skip-existing: true

.github/workflows/publish.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ jobs:
1212
runs-on: ubuntu-latest
1313
permissions:
1414
contents: read
15+
# 2026-07-08: parallel matrix kept (PyPI publish is a one-shot
16+
# event and the runner is already paid for) but pip cache
17+
# brought in for parity with ci.yml.
1518
strategy:
1619
matrix:
1720
python-version: ["3.10", "3.11", "3.12"]
@@ -22,12 +25,14 @@ jobs:
2225
- uses: actions/setup-python@v5
2326
with:
2427
python-version: ${{ matrix.python-version }}
28+
cache: "pip"
29+
cache-dependency-path: pyproject.toml
2530

2631
- name: Install dependencies
27-
run: pip install -e ".[dev]"
32+
run: pip install -e ".[dev]" "pytest-xdist>=3.6"
2833

2934
- name: Run tests
30-
run: pytest tests/ -v
35+
run: pytest tests/ -v -n auto
3136

3237
publish:
3338
name: Build and publish

pyproject.toml

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ name = "nullrun"
1717
# nullrun._singleton (the metaclass-backing descriptor) and
1818
# nullrun._registry (the runtime registry) so runtime.py stays the
1919
# orchestrator only. See __version__.py for the full changelog.
20-
version = "0.13.3"
20+
version = "0.13.4"
2121
# Long form used by PyPI page meta-description and search snippets.
2222
# Kept under the 200-char preview threshold so the full line is visible
2323
# without an "expand" click. Keywords are matched against likely search
@@ -140,6 +140,14 @@ dev = [
140140
"ruff>=0.5",
141141
"coverage[toml]>=7.0",
142142
"httpx>=0.27.0,<1.0",
143+
# xdist pins the parallel runner as a first-class dev dep so
144+
# `pip install -e ".[dev]"` brings it in for local runs and
145+
# CI both. The CI workflow also installs it explicitly to
146+
# survive a future pyproject prune. ``-n auto`` is set in
147+
# the workflow rather than ``addopts`` so single-CPU local
148+
# runs (e.g. ``pytest tests/test_one.py``) don't accidentally
149+
# spawn a worker pool.
150+
"pytest-xdist>=3.6",
143151
# The SDK eagerly imports `nullrun.instrumentation.langgraph`
144152
# (from `nullrun.decorators`, imported by `nullrun.__init__` at
145153
# collection time), which itself does `from langchain_core.callbacks
@@ -477,7 +485,15 @@ ignore = [
477485
[tool.pytest.ini_options]
478486
asyncio_mode = "auto"
479487
testpaths = ["tests"]
480-
addopts = "--tb=short -q"
488+
# 2026-07-08: dropped the global ``-q`` so CI logs surface the
489+
# full PASSED line for each test (handy when scanning a red run).
490+
# Per-test verbosity stays low because ``--tb=short`` keeps the
491+
# tracebacks compact. ``-n auto`` lives in the workflow file, not
492+
# here, so a developer running ``pytest tests/test_x.py`` locally
493+
# gets a single process — the worker pool is only worth it on
494+
# the full suite, and some single-file debug sessions actively
495+
# want serial execution.
496+
addopts = "--tb=short"
481497
# Make the tests/ directory importable as a top-level package so
482498
# tests can use `from tests.conftest import BASE_URL`. Without this,
483499
# `from tests.conftest` raises ModuleNotFoundError on Python 3.10/3.11

src/nullrun/transport.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,13 @@ def __init__(
535535
# methods) doesn't deadlock.
536536
self._flush_thread: threading.Thread | None = None
537537
self._running = False
538+
# Cancellable sleep primitive for the flush loop. ``Event.wait``
539+
# returns immediately when ``set()`` is called from ``stop()``,
540+
# so a teardown that hits a thread mid-``time.sleep`` no longer
541+
# blocks for the full ``flush_interval`` (default 5s) before
542+
# ``join`` returns. Pin contract: tests/test_transport.py::
543+
# test_stop_interrupts_flush_sleep.
544+
self._stop_event = threading.Event()
538545

539546
# mTLS client certificate support
540547
# NULLRUN_TLS_CLIENT_CERT and NULLRUN_TLS_CLIENT_KEY env vars for client cert auth
@@ -770,6 +777,9 @@ def start(self) -> None:
770777
# Replay any events from WAL that were persisted due to previous crash
771778
self._replay_from_wal()
772779
self._running = True
780+
# Clear the stop latch so a previous stop() does not short-circuit
781+
# the new flush loop on its first sleep.
782+
self._stop_event.clear()
773783
self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
774784
self._flush_thread.start()
775785
logger.info("Transport flush thread started")
@@ -801,6 +811,13 @@ def stop(self, timeout: float = 10.0) -> None:
801811
"""Stop background flush thread and flush remaining events."""
802812
self._running = False
803813
self._stopped = True # Mark as stopped to prevent double flush
814+
# Wake the flush thread out of its cancellable sleep so join()
815+
# returns immediately instead of waiting out the full
816+
# ``flush_interval``. Without this, a teardown that hits the
817+
# thread mid-sleep pays the 5s default flush_interval per
818+
# shutdown — a multiplier on every test that calls
819+
# ``runtime.shutdown()``.
820+
self._stop_event.set()
804821
if self._flush_thread:
805822
self._flush_thread.join(timeout=timeout)
806823
self._do_flush() # Final flush
@@ -816,7 +833,14 @@ def stop(self, timeout: float = 10.0) -> None:
816833
def _flush_loop(self) -> None:
817834
"""Background loop that periodically flushes."""
818835
while self._running:
819-
time.sleep(self.config.flush_interval)
836+
# ``Event.wait`` returns True when ``stop()`` sets the
837+
# event — that is the cancel signal. On timeout it
838+
# returns False and we fall through to a flush. Replaces
839+
# a plain ``time.sleep`` that could not be interrupted
840+
# early, so stop() used to block for the full interval.
841+
cancelled = self._stop_event.wait(timeout=self.config.flush_interval)
842+
if cancelled:
843+
break
820844
if self._running:
821845
self._do_flush()
822846

tests/test_transport.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,41 @@ def test_flush_on_stop(self, transport):
7676
transport.stop()
7777
assert route.called
7878

79+
def test_stop_interrupts_flush_sleep(self):
80+
"""stop() must wake the flush thread out of its cancellable
81+
sleep instead of waiting out the full ``flush_interval``.
82+
83+
Regression pin for the CI-speed fix: the previous loop used a
84+
bare ``time.sleep``, so a test that called ``runtime.shutdown
85+
()`` while the thread was mid-sleep blocked for the full
86+
interval (default 5s). With ``Event.wait`` the join returns
87+
within a few hundred ms — so the whole suite runs in tens of
88+
seconds instead of 15+ minutes. Uses a deliberately long
89+
``flush_interval`` to make the regression obvious if it
90+
creeps back.
91+
"""
92+
from nullrun.transport import FlushConfig
93+
94+
t = Transport(
95+
api_url="https://api.test.nullrun.io",
96+
api_key="test-key-12345678",
97+
config=FlushConfig(flush_interval=30.0), # would be 30s pre-fix
98+
)
99+
t.start()
100+
# Give the thread a beat to enter _flush_loop's wait.
101+
time.sleep(0.05)
102+
started = time.monotonic()
103+
t.stop()
104+
elapsed = time.monotonic() - started
105+
# Allow generous headroom for CI jitter; the contract is
106+
# "much less than flush_interval" — a pre-fix run would hit
107+
# the full 30s and time out this assertion.
108+
assert elapsed < 5.0, (
109+
f"stop() took {elapsed:.2f}s; expected < 5s. The flush "
110+
f"loop is sleeping in plain ``time.sleep`` again — the "
111+
f"cancellable-wait fix regressed."
112+
)
113+
79114
def test_ssl_verification_enabled(self, transport):
80115
# httpx 0.28+ doesn't expose verify as a direct attribute
81116
# SSL verification is enabled by default (verify=True)

0 commit comments

Comments
 (0)