Skip to content

Commit e365dc6

Browse files
Mike Bridgeclaude
andcommitted
ci(python-unit): narrow the driver preload; isolate the MCP user ContextVar
Addresses the first CI run and a three-lens cross-model review. CI: the parallel step ran in 14m07s on the 4-core runners with one failure, tests/unit_tests/mcp_service/test_middleware_logging.py (`assert 1 == 42`). That is a leaked ContextVar, not xdist: sync tests that call `_setup_user_context()` (test_gauge_chart's resource path resolves a real user, id 1; six test_auth_user_resolution tests leave mocks) set `_mcp_user_id_var` in the thread's base context and never clear it, and pytest-asyncio copies that context into every later async test's task. `on_call_tool`'s `finally` then overrides the patched user id with the leaked one. It passed serially only because test_mcp_e2e_smoke sorts between leaker and victim and exercises the real middleware, whose `finally` resets the var; `loadfile` put that file on another worker. A per-test reset fixture in tests/unit_tests/mcp_service/conftest.py scopes the var to each test. Control: gauge -> middleware_logging in one process reproduces the exact failure without the fixture and passes with it. Review (unanimous across lenses): the session-wide `load_engine_specs()` preload was the wrong seam -- full discovery, including third-party entry points, in every worker and both serial coverage gates, and importing Superset's ClickHouse spec before any app exists caches `product_name="dev"` for the session. Narrowed to pre-importing only `clickhouse_connect`, the third-party module that actually reads the clock at import; engine-spec discovery itself stays lazy, so tests that exercise it still see a cold state. Seven unit-test files combine freeze_time with engine-spec resolution, so a narrow autouse preload beats per-test opt-in. A direct pin of the preload's contract is added. Also from review: docstring opening line and deferred-import justification per the constitution; explicit annotations on the tuple constants; `--durations=0` so the pre-existing `--durations-min` actually emits a report (it only filters an enabled one) -- which immediately shows the critical path under loadfile: one 68s test and ~15s of module-scoped app setup per file; and a note on `--maxfail` semantics. Local full run, 8 workers, coverage on: 15,128 passed in 3m36s. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EA4MGEkUcDpyLReqjQBhPy
1 parent 6d741a4 commit e365dc6

5 files changed

Lines changed: 115 additions & 21 deletions

File tree

.github/workflows/superset-python-unittest.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,17 @@ jobs:
6969
# even though every test passed. `--dist loadfile` keeps all tests from
7070
# one file on one worker, which preserves module-scoped fixture state;
7171
# pytest-cov combines the per-worker coverage data automatically.
72+
# `--durations=0` makes `--durations-min` effective (the min flag only
73+
# filters an enabled report) so the slow-file critical path under
74+
# loadfile stays visible. `--maxfail` is a controller-level stop: tests
75+
# already dispatched to workers finish, so the final failure count can
76+
# slightly exceed it.
7277
- name: Python unit tests
7378
env:
7479
SUPERSET_TESTENV: true
7580
SUPERSET_SECRET_KEY: not-a-secret
7681
run: |
77-
pytest -n auto --dist loadfile --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
82+
pytest -n auto --dist loadfile --durations=0 --durations-min=0.5 --cov-report= --cov=superset ./tests/common ./tests/unit_tests --cache-clear --maxfail=50 --junit-xml=test-results/junit-unit.xml
7883
# COVERAGE_FILE keeps these scoped gates off the default .coverage that
7984
# the step above wrote. pytest-cov starts a fresh data file per run, so
8085
# without it the last gate replaces the full-suite data and the report

tests/unit_tests/commands/report/base_test.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@
3737
# requires every worker to collect the same test ids in the same order. A set
3838
# of strings iterates in per-process hash order (PYTHONHASHSEED), so a set here
3939
# makes workers disagree and xdist aborts with "Different tests were collected".
40-
REPORT_TYPES = (
40+
REPORT_TYPES: tuple[ReportScheduleType, ...] = (
4141
ReportScheduleType.ALERT,
4242
ReportScheduleType.REPORT,
4343
)
4444

45-
TEST_SCHEDULES_EVERY_MINUTE = (
45+
TEST_SCHEDULES_EVERY_MINUTE: tuple[str, ...] = (
4646
"* * * * *",
4747
"1-5 * * * *",
4848
"10-20 * * * *",
@@ -51,13 +51,15 @@
5151
"10,20,30,40-45 * * * *",
5252
)
5353

54-
TEST_SCHEDULES_SINGLE_MINUTES = (
54+
TEST_SCHEDULES_SINGLE_MINUTES: tuple[str, ...] = (
5555
"1,5,8,10,12 * * * *",
5656
"10 1 * * *",
5757
"27,2 1-5 * * *",
5858
)
5959

60-
TEST_SCHEDULES = TEST_SCHEDULES_EVERY_MINUTE + TEST_SCHEDULES_SINGLE_MINUTES
60+
TEST_SCHEDULES: tuple[str, ...] = (
61+
TEST_SCHEDULES_EVERY_MINUTE + TEST_SCHEDULES_SINGLE_MINUTES
62+
)
6163

6264

6365
def dynamic_alert_minimum_interval(**kwargs) -> int:

tests/unit_tests/conftest.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -74,23 +74,34 @@ def session(get_session) -> Iterator[Session]:
7474

7575

7676
@pytest.fixture(scope="session", autouse=True)
77-
def _preload_engine_specs() -> None:
77+
def _preload_clock_reading_drivers() -> None:
78+
"""Import third-party drivers that read the clock at import, before any test.
79+
80+
``clickhouse_connect`` normalises the local timezone through ``dateutil``
81+
at module import time, which raises ``AttributeError`` under a frozen
82+
``freeze_time`` clock. Superset's ClickHouse engine spec imports it
83+
lazily, on the first ``load_engine_specs()`` call, so whichever test
84+
first resolves an engine spec pays that import -- and if that test runs
85+
under ``freeze_time`` (seven unit-test files combine ``freeze_time`` with
86+
engine-spec resolution) the import fails. Serially some earlier test
87+
always paid it outside a frozen clock; under pytest-xdist each worker
88+
starts cold, so the failure moves with worker assignment.
89+
90+
Only the third-party module is imported here, deliberately: importing
91+
Superset's own engine-spec modules this early would also cache
92+
app-context-dependent values (the ClickHouse spec resolves its
93+
``product_name`` from ``current_app`` on import), and running full
94+
``load_engine_specs()`` discovery would execute every registered
95+
third-party entry point in every worker. Engine-spec discovery itself
96+
stays lazy, so tests that exercise it still observe a cold state.
7897
"""
79-
Import every engine-spec module once per test session, before any test.
80-
81-
``load_engine_specs()`` imports the spec modules lazily on first use, and
82-
some of them pull in third-party drivers that read the local timezone at
83-
import time (``clickhouse_connect`` via ``dateutil``). That import fails
84-
under a ``freeze_time`` clock. In a serial run some earlier test always
85-
paid the import cost outside a frozen clock, so the dependency was
86-
invisible; under pytest-xdist every worker starts cold, and whichever
87-
``freeze_time`` test is the first engine-spec importer on its worker
88-
fails -- a flake that moves with worker assignment. Loading here gives
89-
every worker the same import state a serial run reached by accident.
90-
"""
91-
from superset.db_engine_specs import load_engine_specs
92-
93-
load_engine_specs()
98+
# Deferred, guarded import: the driver is an optional dependency, so a
99+
# module-top import would make this conftest fail to load in an
100+
# environment where it is not installed.
101+
try:
102+
import clickhouse_connect # noqa: F401
103+
except ImportError:
104+
pass
94105

95106

96107
@pytest.fixture(scope="module")

tests/unit_tests/mcp_service/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,34 @@
2222
RBAC logic is tested directly in test_auth_rbac.py.
2323
"""
2424

25+
from collections.abc import Iterator
26+
2527
import pytest
2628

29+
from superset.mcp_service.auth import _mcp_user_id_var
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def isolate_mcp_user_id_var() -> Iterator[None]:
34+
"""Reset the ``_mcp_user_id_var`` ContextVar around every MCP test.
35+
36+
``_setup_user_context()`` sets the var and relies on ``on_call_tool``'s
37+
``finally`` to clear it. Tests that call ``_setup_user_context()`` (or set
38+
the var) directly, without going through the middleware, leave it set in
39+
the thread's base context, and pytest-asyncio copies that context into
40+
every later async test's task -- so a resolved user id leaks into
41+
unrelated tests. In a serial run a middleware-exercising test file that
42+
sorts between the leaking file and its victim happened to clear the var;
43+
under pytest-xdist the files land on different workers and the leak
44+
surfaces as a flake. Scoping the var to each test removes the ordering
45+
dependence.
46+
"""
47+
token = _mcp_user_id_var.set(None)
48+
try:
49+
yield
50+
finally:
51+
_mcp_user_id_var.reset(token)
52+
2753

2854
@pytest.fixture(autouse=True)
2955
def disable_mcp_rbac(app):
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""Pin the harness guarantee behind ``_preload_clock_reading_drivers``.
18+
19+
``clickhouse_connect`` reads the local timezone at import time and fails
20+
under a ``freeze_time`` clock, so the unit-test session imports it before any
21+
test runs. A test can therefore resolve the ClickHouse engine spec under a
22+
frozen clock on any worker, regardless of what ran before it. This test
23+
asserts that guarantee directly; if the preload fixture is removed, it fails
24+
on any worker where nothing else happened to import the driver first.
25+
"""
26+
27+
import importlib.util
28+
import sys
29+
30+
import pytest
31+
from freezegun import freeze_time
32+
33+
34+
@pytest.mark.skipif(
35+
importlib.util.find_spec("clickhouse_connect") is None,
36+
reason="clickhouse_connect is not installed",
37+
)
38+
def test_clock_reading_driver_is_preloaded_before_tests() -> None:
39+
assert "clickhouse_connect" in sys.modules
40+
41+
42+
@pytest.mark.skipif(
43+
importlib.util.find_spec("clickhouse_connect") is None,
44+
reason="clickhouse_connect is not installed",
45+
)
46+
@freeze_time("2021-04-01T00:00:00Z")
47+
def test_clickhouse_engine_spec_resolves_under_frozen_clock() -> None:
48+
from superset.db_engine_specs import get_engine_spec
49+
50+
assert get_engine_spec("clickhousedb", "connect") is not None

0 commit comments

Comments
 (0)