Skip to content

Commit fc35b85

Browse files
authored
test: deflake recurring task test and fix tests hanging on Python 3.10 (#2287)
Two ubuntu / Python 3.10 CI failures on master. `test_recurring_task.py::test_execution` slept 200 ms and expected at least 3 calls at a 30 ms delay, but a slow runner got 1 ([run](https://github.com/apify/crawlee-python/actions/runs/38214082347/job/114694625489)). It now waits for the third call with a 5 s timeout and no longer needs `run_alone`. A worker hung in a fixture teardown and the job ran until GitHub cancelled it after 6 hours ([run](https://github.com/apify/crawlee-python/actions/runs/38172798916/job/114568777725)). The cause is in `backports.asyncio.runner`, which pytest-asyncio uses on 3.10. `Runner.run(coro, context=...)` patches `contextvars.copy_context` process-wide while it creates the task, so the test server's thread can pick up the fixture's context. When both threads enter it at once, `Context.run` raises `cannot enter context: ... is already entered`, the task step is dropped, and the teardown waits forever. On Python 3.10, `pytest-asyncio` is pinned to 1.0.x, the last release before it started using the backports runner. That pin also brings in pytest 8, and ty can't type-check `pytest.skip()` with it, so the six runtime skips in the storage tests raise `pytest.skip.Exception` instead. For any future hang, `timeout_method = "thread"` kills the stuck worker (the default signal method never fired in the 6 h run), and `faulthandler_timeout = 600` dumps every thread's stack into the log. The job-level cap is in apify/workflows#333. *✍️ Drafted by Claude Code*
1 parent ca71cd2 commit fc35b85

6 files changed

Lines changed: 144 additions & 73 deletions

File tree

‎pyproject.toml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,10 @@ dev = [
130130
"pre-commit<5.0.0",
131131
"proxy-py<3.0.0",
132132
"pydoc-markdown<5.0.0",
133-
"pytest-asyncio<2.0.0",
133+
# pytest-asyncio 1.1+ runs tests through `backports.asyncio.runner` on Python 3.10, whose process-wide
134+
# `contextvars.copy_context` patch can leak the fixture context into the test server thread and hang the test.
135+
"pytest-asyncio<2.0.0; python_version >= '3.11'",
136+
"pytest-asyncio>=1.0.0,<1.1.0; python_version < '3.11'",
134137
"pytest-cov<8.0.0",
135138
"pytest-rerunfailures<17.0.0",
136139
"pytest-timeout<3.0.0",
@@ -267,6 +270,11 @@ addopts = "-r a --verbose --dist worksteal"
267270
asyncio_default_fixture_loop_scope = "function"
268271
asyncio_mode = "auto"
269272
timeout = 1800
273+
# A thread timer kills the stuck worker even when its main thread is blocked and a SIGALRM handler can never run.
274+
timeout_method = "thread"
275+
# pytest-timeout writes its stack dump to the xdist worker's stdout, which never reaches the console. faulthandler
276+
# writes straight to the stderr file descriptor, so a test stuck this long leaves its stacks in the log.
277+
faulthandler_timeout = 600
270278
markers = [
271279
"run_alone: marks tests that must run in isolation",
272280
]

‎tests/unit/_utils/test_recurring_task.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,17 @@ async def test_start_and_stop(function: AsyncMock, delay: timedelta) -> None:
4242
assert rt.task.done()
4343

4444

45-
@pytest.mark.run_alone
4645
async def test_execution(function: AsyncMock, delay: timedelta) -> None:
46+
"""The recurring task calls its function repeatedly."""
47+
third_call_done = asyncio.Event()
48+
function.side_effect = lambda: third_call_done.set() if function.call_count >= 3 else None
4749
task = RecurringTask(function, delay)
4850

4951
task.start()
50-
await asyncio.sleep(0.2) # Wait enough for the task to execute a few times
52+
await asyncio.wait_for(third_call_done.wait(), timeout=5)
5153
await task.stop()
5254

53-
assert isinstance(task.func, AsyncMock) # To let type checker know that the function is a mock
54-
assert task.func.call_count >= 3
55-
56-
await task.stop()
55+
assert function.call_count >= 3
5756

5857

5958
async def test_execution_continues_after_exception(delay: timedelta, caplog: pytest.LogCaptureFixture) -> None:

‎tests/unit/storages/test_dataset.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,7 @@ async def test_purge_on_start_enabled(storage_client: StorageClient) -> None:
930930

931931
# Skip this test for memory storage since it doesn't persist data between client instances.
932932
if isinstance(storage_client, MemoryStorageClient):
933-
pytest.skip('Memory storage does not persist data between client instances.')
933+
raise pytest.skip.Exception('Memory storage does not persist data between client instances.')
934934

935935
configuration = Configuration(purge_on_start=True)
936936

@@ -1016,7 +1016,7 @@ async def test_purge_on_start_disabled(storage_client: StorageClient) -> None:
10161016

10171017
# Skip this test for memory storage since it doesn't persist data between client instances.
10181018
if isinstance(storage_client, MemoryStorageClient):
1019-
pytest.skip('Memory storage does not persist data between client instances.')
1019+
raise pytest.skip.Exception('Memory storage does not persist data between client instances.')
10201020

10211021
configuration = Configuration(purge_on_start=False)
10221022

‎tests/unit/storages/test_key_value_store.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -994,7 +994,7 @@ async def test_purge_on_start_enabled(storage_client: StorageClient) -> None:
994994

995995
# Skip this test for memory storage since it doesn't persist data between client instances.
996996
if isinstance(storage_client, MemoryStorageClient):
997-
pytest.skip('Memory storage does not persist data between client instances.')
997+
raise pytest.skip.Exception('Memory storage does not persist data between client instances.')
998998

999999
configuration = Configuration(purge_on_start=True)
10001000

@@ -1080,7 +1080,7 @@ async def test_purge_on_start_disabled(storage_client: StorageClient) -> None:
10801080

10811081
# Skip this test for memory storage since it doesn't persist data between client instances.
10821082
if isinstance(storage_client, MemoryStorageClient):
1083-
pytest.skip('Memory storage does not persist data between client instances.')
1083+
raise pytest.skip.Exception('Memory storage does not persist data between client instances.')
10841084

10851085
configuration = Configuration(purge_on_start=False)
10861086

‎tests/unit/storages/test_request_queue.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,7 @@ async def test_purge_on_start_enabled(storage_client: StorageClient) -> None:
12791279

12801280
# Skip this test for memory storage since it doesn't persist data between client instances.
12811281
if isinstance(storage_client, MemoryStorageClient):
1282-
pytest.skip('Memory storage does not persist data between client instances.')
1282+
raise pytest.skip.Exception('Memory storage does not persist data between client instances.')
12831283

12841284
configuration = Configuration(purge_on_start=True)
12851285

@@ -1405,7 +1405,7 @@ async def test_purge_on_start_disabled(storage_client: StorageClient) -> None:
14051405

14061406
# Skip this test for memory storage since it doesn't persist data between client instances.
14071407
if isinstance(storage_client, MemoryStorageClient):
1408-
pytest.skip('Memory storage does not persist data between client instances.')
1408+
raise pytest.skip.Exception('Memory storage does not persist data between client instances.')
14091409

14101410
configuration = Configuration(purge_on_start=False)
14111411

0 commit comments

Comments
 (0)