Skip to content

Commit f79312c

Browse files
authored
release(0.13.5): perf — cancellable flush sleep + CI hygiene (#60)
* 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. * remove redundant docs * chore(release): bump version 0.13.4 -> 0.13.5 Pairs with the preceding release/0.13.5 commits: * perf(ci): cancel flush-thread sleep (transport.py:816) * remove redundant docs (drift.md, sdk-v3-migration-gaps.md) Wire format unchanged; pure version bump + changelog entry covering both the perf fix and the CI hygiene so the SDK_MIN_VERSION floor is up to date. No on-wire breaking change; backends on 1.0.0 keep working unchanged. Recommended upgrade path: 0.13.4 -> 0.13.5. * fix(tests): stop transport flush thread between tests so it doesn't race respx PR #60 landed the cancellable-sleep fix in Transport._flush_loop and expected CI wall-clock to drop to 3-5 minutes. The first green run on PR #60 (PR #60 run #1) actually took 9m 47s — the test step dominated by a retry storm: Request failed (attempt 5/11), retrying in 8.46s: ConnectError Request failed (attempt 6/11), retrying in 9.16s: ConnectError ... Circuit breaker OPEN. Batch of 10 events will be re-queued. Root cause: `tests/conftest.py:reset_runtime` teardown nulled the runtime reference WITHOUT calling `runtime.shutdown()`. The transport flush thread therefore kept running across tests, the buffer drained through httpx with no respx context active, and the xdist workers spent the next 9 minutes retry-sending the buffer against the real (unreachable in CI) backend. `_retry_with_backoff (max_retries=10, max_delay=10s)` is 65s of pure sleep per failed batch, and with 4 xdist workers and many buffered batches this multiplied into 9m 47s — i.e. a CI-noise fix that hid a deeper lifecycle bug. Pre-fix CI was already paying this cost (5s shutdown-sleep × 200+ tests ≈ 17 min of teardown per Python leg); the retry storm was always there but masked by the dominant 5s cost. PR #60's 5s fix exposed it. Fix: add `flush: bool = True` to both `Transport.stop()` and `NullRunRuntime.shutdown()`. When False, the transport thread is cancelled WITHOUT a final `_do_flush()` / `_persist_to_wal()`. `tests/conftest.py:reset_runtime` teardown now calls `inst.shutdown(flush=False)` before nilling the reference. This makes the conftest teardown a true no-op for the buffer — the test that wrote the events is responsible for asserting on what it cared about. The production default (`flush=True`) is preserved, so the `nullrun.shutdown()` audit contract ("drain in-flight events") is unchanged. Pins: * `tests/test_transport.py::test_stop_flush_false_skips_final_flush ` — buffers an event, calls `stop(flush=False)` with no respx active, asserts the call returns in <1s AND the buffer is left untouched. Pre-fix this would have hung for 65s+ on the first retry. * `tests/test_init_contract.py::TestShutdownFlushKwarg:: test_runtime_shutdown_flush_false_skips_final_flush` — same contract at the `NullRunRuntime` level: `shutdown(flush=False )` propagates the `flush=False` flag to `Transport.stop()`. Public API additions: * `Transport.stop(timeout=10.0, flush: bool = True)` — `flush =False` is the new flag. * `NullRunRuntime.shutdown(flush: bool = True)` — propagates. * `nullrun.shutdown(timeout=2.0, flush: bool = True)` — passes `flush` through to the runtime. No on-wire or production behaviour change. CI step is expected to drop from ~9m 47s (PR #60 run #1) to ~30-60s on the next run.
1 parent b702390 commit f79312c

13 files changed

Lines changed: 383 additions & 135 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

docs/drift.md

Lines changed: 0 additions & 74 deletions
This file was deleted.

docs/sdk-v3-migration-gaps.md

Lines changed: 0 additions & 36 deletions
This file was deleted.

pyproject.toml

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,18 @@ 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+
# 0.13.3 (2026-07-07): developer-ergonomics — `langgraph` import
21+
# semantics + cleanup of dead `protos/` target. See __version__.py.
22+
# 0.13.4 (2026-07-08): bug-fix — flatten the LangChain usage-
23+
# extraction elif-chain so every attribute source is read (not just
24+
# the first one with ``hasattr`` truthy). Pairs with PR #59.
25+
# 0.13.5 (2026-07-08): perf — make ``Transport._flush_loop`` sleep
26+
# cancellable (``threading.Event.wait`` instead of ``time.sleep``)
27+
# so ``runtime.shutdown()`` returns in ms instead of waiting out
28+
# the full ``flush_interval`` (5s default). Plus CI hygiene:
29+
# pip cache, ``fail-fast`` matrix, ``pytest-xdist -n auto``. No
30+
# on-wire change; backends on 1.0.0 keep working unchanged.
31+
version = "0.13.5"
2132
# Long form used by PyPI page meta-description and search snippets.
2233
# Kept under the 200-char preview threshold so the full line is visible
2334
# without an "expand" click. Keywords are matched against likely search
@@ -140,6 +151,14 @@ dev = [
140151
"ruff>=0.5",
141152
"coverage[toml]>=7.0",
142153
"httpx>=0.27.0,<1.0",
154+
# xdist pins the parallel runner as a first-class dev dep so
155+
# `pip install -e ".[dev]"` brings it in for local runs and
156+
# CI both. The CI workflow also installs it explicitly to
157+
# survive a future pyproject prune. ``-n auto`` is set in
158+
# the workflow rather than ``addopts`` so single-CPU local
159+
# runs (e.g. ``pytest tests/test_one.py``) don't accidentally
160+
# spawn a worker pool.
161+
"pytest-xdist>=3.6",
143162
# The SDK eagerly imports `nullrun.instrumentation.langgraph`
144163
# (from `nullrun.decorators`, imported by `nullrun.__init__` at
145164
# collection time), which itself does `from langchain_core.callbacks
@@ -477,7 +496,15 @@ ignore = [
477496
[tool.pytest.ini_options]
478497
asyncio_mode = "auto"
479498
testpaths = ["tests"]
480-
addopts = "--tb=short -q"
499+
# 2026-07-08: dropped the global ``-q`` so CI logs surface the
500+
# full PASSED line for each test (handy when scanning a red run).
501+
# Per-test verbosity stays low because ``--tb=short`` keeps the
502+
# tracebacks compact. ``-n auto`` lives in the workflow file, not
503+
# here, so a developer running ``pytest tests/test_x.py`` locally
504+
# gets a single process — the worker pool is only worth it on
505+
# the full suite, and some single-file debug sessions actively
506+
# want serial execution.
507+
addopts = "--tb=short"
481508
# Make the tests/ directory importable as a top-level package so
482509
# tests can use `from tests.conftest import BASE_URL`. Without this,
483510
# `from tests.conftest` raises ModuleNotFoundError on Python 3.10/3.11

src/nullrun/__init__.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def my_agent(query):
4141
from nullrun.runtime import track_event, track_llm, track_tool
4242

4343

44-
def shutdown(timeout: float = 2.0) -> None:
44+
def shutdown(timeout: float = 2.0, flush: bool = True) -> None:
4545
"""Gracefully shut down the NullRun runtime.
4646
4747
Sends a clean WebSocket close frame, drains in-flight events, and
@@ -62,6 +62,13 @@ def shutdown(timeout: float = 2.0) -> None:
6262
``NullRunRuntime.shutdown `` already caps WS join at
6363
0.5s and the WS close at 2.0s — this parameter is
6464
reserved for future expansion and is currently unused.
65+
flush: when True (default) the transport drains any
66+
buffered events to the backend on the way out. Pass
67+
False to cancel the flush thread without a final
68+
network call — used by the test conftest to teardown
69+
between tests without racing the respx context exit
70+
(see ``NullRunRuntime.shutdown(flush=False)`` for the
71+
full rationale).
6572
6673
Example::
6774
@@ -75,7 +82,7 @@ def shutdown(timeout: float = 2.0) -> None:
7582
runtime = NullRunRuntime._instance # type: ignore[attr-defined]
7683
if runtime is None:
7784
return
78-
runtime.shutdown()
85+
runtime.shutdown(flush=flush)
7986

8087

8188
def status():

0 commit comments

Comments
 (0)