Skip to content

Commit 3e7bdec

Browse files
mikebridgeMike Bridgeclaude
authored
ci(python-unit): run the unit suite in parallel with pytest-xdist (#44178)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 3d01094 commit 3e7bdec

7 files changed

Lines changed: 147 additions & 8 deletions

File tree

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,23 @@ jobs:
6363
uses: $/.github/actions/setup-backend/
6464
with:
6565
python-version: ${{ matrix.python-version }}
66+
# Runs in parallel via pytest-xdist: the serial suite had grown to
67+
# ~27-28 min against this job's 30-minute timeout, so the trailing
68+
# coverage/upload steps were being killed on ordinary runner variance
69+
# even though every test passed. `--dist loadfile` keeps all tests from
70+
# one file on one worker, which preserves module-scoped fixture state;
71+
# 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.
6677
- name: Python unit tests
6778
env:
6879
SUPERSET_TESTENV: true
6980
SUPERSET_SECRET_KEY: not-a-secret
7081
run: |
71-
pytest --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
7283
# COVERAGE_FILE keeps these scoped gates off the default .coverage that
7384
# the step above wrote. pytest-cov starts a fresh data file per run, so
7485
# without it the last gate replaces the full-suite data and the report

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ development = [
326326
"pytest-asyncio",
327327
"pytest-cov",
328328
"pytest-mock",
329+
"pytest-xdist",
329330
"python-ldap>=3.4.7",
330331
"ruff",
331332
"sqloxide",

requirements/development.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ events==0.5
274274
# via opensearch-py
275275
exceptiongroup==1.3.0
276276
# via fastmcp-slim
277+
execnet==2.1.2
278+
# via pytest-xdist
277279
fastmcp==3.4.7
278280
# via apache-superset
279281
fastmcp-slim==3.4.7
@@ -890,6 +892,7 @@ pytest==7.4.4
890892
# pytest-asyncio
891893
# pytest-cov
892894
# pytest-mock
895+
# pytest-xdist
893896
pytest-asyncio==0.23.8
894897
# via apache-superset
895898
pytest-cov==6.0.0
@@ -900,6 +903,8 @@ pytest-mock==3.10.0
900903
# via
901904
# apache-superset
902905
# apache-superset-extensions-cli
906+
pytest-xdist==3.8.0
907+
# via apache-superset
903908
python-calamine==0.8.2
904909
# via
905910
# -c requirements/base-constraint.txt

tests/unit_tests/commands/report/base_test.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,27 +33,33 @@
3333
)
3434
from superset.reports.models import ReportScheduleType
3535

36-
REPORT_TYPES = {
36+
# Tuples, not sets: these feed ``@pytest.mark.parametrize``, and pytest-xdist
37+
# requires every worker to collect the same test ids in the same order. A set
38+
# of strings iterates in per-process hash order (PYTHONHASHSEED), so a set here
39+
# makes workers disagree and xdist aborts with "Different tests were collected".
40+
REPORT_TYPES: tuple[ReportScheduleType, ...] = (
3741
ReportScheduleType.ALERT,
3842
ReportScheduleType.REPORT,
39-
}
43+
)
4044

41-
TEST_SCHEDULES_EVERY_MINUTE = {
45+
TEST_SCHEDULES_EVERY_MINUTE: tuple[str, ...] = (
4246
"* * * * *",
4347
"1-5 * * * *",
4448
"10-20 * * * *",
4549
"0,45,10-20 * * * *",
4650
"23,45,50,51 * * * *",
4751
"10,20,30,40-45 * * * *",
48-
}
52+
)
4953

50-
TEST_SCHEDULES_SINGLE_MINUTES = {
54+
TEST_SCHEDULES_SINGLE_MINUTES: tuple[str, ...] = (
5155
"1,5,8,10,12 * * * *",
5256
"10 1 * * *",
5357
"27,2 1-5 * * *",
54-
}
58+
)
5559

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

5864

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

tests/unit_tests/conftest.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,38 @@ def session(get_session) -> Iterator[Session]:
7373
return get_session()
7474

7575

76+
@pytest.fixture(scope="session", autouse=True)
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.
97+
"""
98+
# Even a guarded module-top import would tie the driver to collection on
99+
# every invocation, including --collect-only and partial runs. Session setup
100+
# imports it once per worker, after conftest/plugin loading and before any
101+
# test can enter freeze_time. Guard ImportError because the driver is optional.
102+
try:
103+
import clickhouse_connect # noqa: F401
104+
except ImportError:
105+
pass
106+
107+
76108
@pytest.fixture(scope="module")
77109
def app(request: SubRequest) -> Iterator[SupersetApp]:
78110
"""

tests/unit_tests/mcp_service/conftest.py

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

25+
from collections.abc import Iterator
26+
from contextvars import Token
27+
2528
import pytest
2629

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

2855
@pytest.fixture(autouse=True)
2956
def disable_mcp_rbac(app):
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
"""Verify the session fixture imports the driver before tests run."""
40+
assert "clickhouse_connect" in sys.modules
41+
42+
43+
@pytest.mark.skipif(
44+
importlib.util.find_spec("clickhouse_connect") is None,
45+
reason="clickhouse_connect is not installed",
46+
)
47+
@freeze_time("2021-04-01T00:00:00Z")
48+
def test_clickhouse_engine_spec_resolves_under_frozen_clock() -> None:
49+
"""Verify the ClickHouse Connect engine spec resolves under a frozen clock."""
50+
# Defer the engine-spec package import until after the session preload;
51+
# a module-top import would execute during collection, before the fixture.
52+
from superset.db_engine_specs import get_engine_spec
53+
54+
assert (
55+
get_engine_spec("clickhousedb", "connect").__name__
56+
== "ClickHouseConnectEngineSpec"
57+
)

0 commit comments

Comments
 (0)