fix(metrics): reset global metrics collector during teardown to avoid test pollution - #3781
Conversation
Strix Security ReviewWarning This pull request has 2 commits after the last Strix review ( No security issues found. Updated for Reviewed by Strix |
7679a1f to
0ebc0ae
Compare
Add reset_metrics_collector() and invoke it during FastAPI lifespan shutdown to prevent leaking a real MetricsCollector instance across the process. Add an autouse fixture in pytest configuration to guarantee that tests run with a clean NoOpMetricsCollector and restore original state, preventing cross-test pollution during concurrent test execution.
0ebc0ae to
1d8bf11
Compare
| metrics_module._metrics_collector = original_collector | ||
| def test_collector_is_noop_in_subsequent_test(self): | ||
| """Second of the ordered pair: proves the fixture cleaned up the leak on this worker (#3780).""" | ||
| assert isinstance(get_metrics_collector(), NoOpMetricsCollector) |
There was a problem hiding this comment.
I ran the new ordered pair against 1d8bf11c in a clean python:3.11-slim container. It passes, but it cannot fail for the reason its docstring gives: the fixture resets the global to NoOpMetricsCollector at setup, so this assert is satisfied by the setup half whether or not the teardown restore ever runs.
Four runs of test_creates_collector_without_explicit_reset plus this test, -p no:randomly -n 0, each editing only tests/conftest.py:
as shipped 2 passed
teardown restore replaced with `pass` 2 passed
setup reset line deleted, restore kept 2 passed
both halves removed 1 failed, 1 passed
So either half alone keeps the pair green, and the xdist_group pinning is not buying the ordering guarantee it describes. An autouse fixture that also resets at setup cannot be observed by a sibling test, so nothing scheduled after the leaking test can pin the restore.
The smallest honest edit is to drop test_creates_collector_without_explicit_reset, test_collector_is_noop_in_subsequent_test and the xdist_group marker, and keep test_create_and_reset_metrics_collector, which does exercise reset_metrics_collector() directly. If you want the fixture itself pinned, pytester running a two-test session in a subprocess is the mechanism that can actually see the teardown.
Separately, #3800 is open against the same issue and adds its own autouse fixture to the same region of tests/conftest.py, so whichever lands second will conflict there.
There was a problem hiding this comment.
Good catch and great analysis on the setup fixture behavior! Updated to drop the redundant paired isolation tests and the xdist_group marker, keeping test_create_and_reset_metrics_collector in 749024c. Thanks!
There was a problem hiding this comment.
Thanks for the quick turnaround. 749024c looks right to me: test_create_and_reset_metrics_collector calls reset_metrics_collector() directly, which is the part a sibling test structurally could not observe.
Summary
Fixes #3780.
This PR resolves a process-global metrics collector leak that caused nondeterministic
TypeErrorfailures across LLM provider test suites during concurrentpytest-xdistexecution.1. Problem Architecture & Root Cause Analysis
1.1 State Leak Lifecycle
During
pytest-xdisttest execution, when a worker process executes a test that initializes the FastAPI application lifespan (e.g. integration or HTTP tests),create_metrics_collector()mutates module-level global state_metrics_collector. Because neither application shutdown nor pytest teardown restored this reference, the worker process remained "poisoned" with an activeMetricsCollectorinstance for all subsequent tests scheduled on that worker.sequenceDiagram autonumber participant Worker as xdist Worker Process participant TestApp as Test (Starts FastAPI App) participant Lifespan as FastAPI Lifespan participant Metrics as hindsight_api.metrics participant ProviderTest as Subsequent Provider Test Note over Worker,Metrics: Initial State: _metrics_collector = NoOpMetricsCollector() Worker->>TestApp: Execute test TestApp->>Lifespan: Startup Lifespan->>Metrics: create_metrics_collector() Metrics-->>Worker: Global _metrics_collector = MetricsCollector() (LIVE) TestApp->>Lifespan: Shutdown (Previously NO metrics cleanup) Note over Metrics: Leaked: _metrics_collector remains MetricsCollector() Worker->>ProviderTest: Execute provider test on same worker ProviderTest->>Metrics: get_metrics_collector() Metrics-->>ProviderTest: Returns active MetricsCollector() (POISONED) ProviderTest->>Metrics: record_llm_call(cached_input_tokens=MagicMock) Metrics-->>ProviderTest: TypeError: '>' not supported between 'MagicMock' and 'int'1.2 Failure Mechanism in LLM Provider Mocks
Provider unit tests mock LLM responses with
MagicMock. When_usage_from_openai_responseextractscached_tokens:On a
MagicMock, attribute traversal auto-instantiates childMagicMockinstances. When passed to the activeMetricsCollector,if cached_input_tokens > 0raises aTypeError._metrics_collectorNoOpMetricsCollector()MetricsCollector()cached_input_tokens > 0response.usage = MagicMock()response.usage = MagicMock()cached_input_tokensisMagicMockTypeError)2. Technical Design & Solution
flowchart TD subgraph CoreModule ["Core Module (hindsight_api/metrics.py)"] GlobalVar["_metrics_collector<br/>(Process-Global State)"] ResetAPI["reset_metrics_collector()"] NoOpInst["NoOpMetricsCollector()"] ResetAPI -->|reassigns to| GlobalVar NoOpInst -.->|default value| GlobalVar end subgraph ProdLifecycle ["Production / App Lifecycle (api/http.py)"] ShutdownHook["FastAPI Lifespan Teardown<br/>(on app shutdown)"] -->|invokes| ResetAPI end subgraph TestIsolation ["Test Environment (tests/conftest.py)"] AutouseFixture["Autouse Fixture<br/>_cleanup_leaked_metrics_collector"] AutouseFixture -->|1. Setup: snapshot & reset to NoOp| GlobalVar AutouseFixture -->|2. Teardown: restore previous state| GlobalVar end2.1 Changes Implemented
hindsight_api/metrics.py:reset_metrics_collector()to explicitly restore_metrics_collector = NoOpMetricsCollector().hindsight_api/api/http.py:reset_metrics_collector()to the FastAPIlifespanteardown stage aftermemory.close().tests/conftest.py:_cleanup_leaked_metrics_collectoras an autouse fixture (mirroring the established_cleanup_leaked_span_recorderspattern) to guarantee per-test isolation under xdist.tests/test_metrics.py:reset_metrics_collector()and cross-test fixture isolation.3. Verification & Validation
3.1 Test Execution Matrix
tests/test_metrics.py-n 0(Single-process sequential)tests/test_reasoning_effort_explicit_config.py-n 4(xdist concurrent)test_reasoning_effort_*,test_tool_path_*,test_llm_extra_body,test_lmstudio_*,test_tags_visibility,test_metrics)-n 4(xdist concurrent)3.2 Pre-commit Checks
lint.sh: All lints passed.vulture&knip: Passed.