Files
chia-blockchain/chia/_tests/util/test_async_pool.py
Matt HauffandGitHub 40db4635a8 [LABS-245] Enable PEP604 Ruff rules (#20269)
* Enable PEP604 Ruff rules

* Fix harcoded signature in test

* Hack CLVMStreamable test with note to fast follow
2025-11-18 12:34:00 -08:00

366 lines
11 KiB
Python

from __future__ import annotations
import asyncio
import random
import re
from dataclasses import dataclass
import anyio
import pytest
from chia.util.async_pool import AsyncPool, InvalidTargetWorkerCountError, Job, QueuedAsyncPool
from chia.util.timing import adjusted_timeout
async def forever_worker(worker_id: int) -> None:
forever = asyncio.Event()
await forever.wait()
@pytest.mark.parametrize("count", [1, 2, 10, 1000])
@pytest.mark.anyio
async def test_creates_expected_worker_count_immediately(count: int) -> None:
async with AsyncPool.managed(
name="test pool",
worker_async_callable=forever_worker,
target_worker_count=count,
) as pool:
assert len(pool._workers) == count
@pytest.mark.parametrize("count", [1, 2, 10, 1000])
@pytest.mark.anyio
async def test_workers_list_empty_after_management(count: int) -> None:
async with AsyncPool.managed(
name="test pool",
worker_async_callable=forever_worker,
target_worker_count=count,
) as pool:
assert len(pool._workers) == count
assert len(pool._workers) == 0
@pytest.mark.parametrize(argnames="count", argvalues=[-5, 0])
@pytest.mark.anyio
async def test_managed_raises_usefully_for_invalid_target_count(count: int) -> None:
with pytest.raises(InvalidTargetWorkerCountError, match=f"{count}"):
async with AsyncPool.managed(
name="test pool",
worker_async_callable=forever_worker,
target_worker_count=count,
):
# testing that the context manager raises on entry so we should never
# cover below
pass # pragma: no cover
@pytest.mark.parametrize("count", [1, 2, 10, 1000])
@pytest.mark.anyio
async def test_does_not_exceed_expected_concurrency(count: int) -> None:
short_time = 0.000_1
long_time = 50 * short_time
@dataclass
class InstrumentedWorkers:
current: int = 0
peak: int = 0
async def work(self, worker_id: int) -> None:
self.current += 1
self.peak = max(self.peak, self.current)
plus_minus = 0.1
percentage = (random.random() * 2 * plus_minus) - plus_minus
try:
await asyncio.sleep(short_time * (1 + percentage))
finally:
self.current -= 1
instrumented_workers = InstrumentedWorkers()
async with AsyncPool.managed(
name="test pool",
worker_async_callable=instrumented_workers.work,
target_worker_count=count,
):
await asyncio.sleep(long_time)
assert instrumented_workers.peak == count
@pytest.mark.anyio
async def test_worker_id_counts() -> None:
expected_results = [0, 1, 2, 3, 4, 5]
result_queue: asyncio.Queue[int] = asyncio.Queue()
async def worker(
worker_id: int,
result_queue: asyncio.Queue[int] = result_queue,
hang_on_worker_id: int = expected_results[-1],
) -> None:
await result_queue.put(worker_id)
if worker_id == hang_on_worker_id:
forever = asyncio.Event()
await forever.wait()
async with AsyncPool.managed(
name="test pool",
worker_async_callable=worker,
target_worker_count=1,
):
results: list[int] = []
with anyio.fail_after(adjusted_timeout(10)):
for _ in expected_results:
results.append(await result_queue.get())
assert results == expected_results
@pytest.mark.anyio
async def test_worker_exception_logged(caplog: pytest.LogCaptureFixture) -> None:
expected_name = "test pool"
expected_message = "this is the exception message"
class CustomException(Exception):
def __init__(self) -> None:
super().__init__(expected_message)
work_queue: asyncio.Queue[Exception | None] = asyncio.Queue()
result_queue: asyncio.Queue[None] = asyncio.Queue()
async def worker(
worker_id: int,
work_queue: asyncio.Queue[Exception | None] = work_queue,
result_queue: asyncio.Queue[None] = result_queue,
) -> None:
work = await work_queue.get()
if work is None:
await result_queue.put(None)
else:
raise work
async with AsyncPool.managed(
name=expected_name,
worker_async_callable=worker,
target_worker_count=1,
):
await work_queue.put(CustomException())
await work_queue.put(None)
with anyio.fail_after(adjusted_timeout(10)):
# wait until the second worker has processed the input to be sure the
# first worker has raised and the result has been processed
await result_queue.get()
# located inside the pool manager as leaving should not be required for
# logging to happen
log_text = caplog.text
match = re.search(
pattern=(
f"{re.escape(expected_name)}: .*"
+ f"{re.escape(CustomException.__name__)}: {re.escape(expected_message)}"
),
string=log_text,
flags=re.DOTALL,
)
assert match is not None, repr(log_text)
@pytest.mark.anyio
async def test_simple_queue_example() -> None:
inputs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
expected_results = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
work_queue: asyncio.Queue[int] = asyncio.Queue()
result_queue: asyncio.Queue[int] = asyncio.Queue()
async def worker(
worker_id: int,
work_queue: asyncio.Queue[int] = work_queue,
result_queue: asyncio.Queue[int] = result_queue,
) -> None:
x = await work_queue.get()
await result_queue.put(x**2)
async with AsyncPool.managed(
name="test pool",
worker_async_callable=worker,
target_worker_count=2,
):
for input in inputs:
await work_queue.put(input)
results: list[int] = []
with anyio.fail_after(adjusted_timeout(10)):
for _ in inputs:
results.append(await result_queue.get())
assert sorted(results) == expected_results
@pytest.mark.anyio
async def test_simple_queue_example_using_queued() -> None:
inputs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
expected_results = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
work_queue: asyncio.Queue[Job[int]] = asyncio.Queue()
result_queue: asyncio.Queue[int] = asyncio.Queue()
async def worker(
worker_id: int,
job: Job[int],
) -> int:
return job.input**2
async with QueuedAsyncPool.managed(
name="test pool",
job_queue=work_queue,
result_queue=result_queue,
worker_async_callable=worker,
target_worker_count=2,
):
jobs = [Job(input=input) for input in inputs]
for job in jobs:
await work_queue.put(job)
results: list[int] = []
with anyio.fail_after(adjusted_timeout(10)):
for _ in expected_results:
results.append(await result_queue.get())
assert sorted(results) == expected_results
@pytest.mark.anyio
async def test_queued_pre_cancel() -> None:
cancel = {4, 7}
inputs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
expected_results = [1, 4, 9, 25, 36, 64, 81, 100]
work_queue: asyncio.Queue[Job[int]] = asyncio.Queue()
result_queue: asyncio.Queue[int] = asyncio.Queue()
async def worker(
worker_id: int,
job: Job[int],
) -> int:
return job.input**2
async with QueuedAsyncPool.managed(
name="test pool",
job_queue=work_queue,
result_queue=result_queue,
worker_async_callable=worker,
target_worker_count=2,
) as pool:
jobs = [Job(input=input) for input in inputs]
for job in jobs:
if job.input in cancel:
pool.cancel(job=job)
await work_queue.put(job)
results: list[int] = []
with anyio.fail_after(adjusted_timeout(10)):
for _ in expected_results:
results.append(await result_queue.get())
assert sorted(results) == expected_results
@pytest.mark.anyio
async def test_queued_active_cancel() -> None:
cancel = {4, 7}
inputs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
expected_results = [1, 4, 9, 25, 36, 64, 81, 100]
work_queue: asyncio.Queue[Job[int]] = asyncio.Queue()
result_queue: asyncio.Queue[int] = asyncio.Queue()
async def worker(
worker_id: int,
job: Job[int],
) -> int:
if job.input in cancel:
forever = asyncio.Event()
await forever.wait()
return job.input**2
async with QueuedAsyncPool.managed(
name="test pool",
job_queue=work_queue,
result_queue=result_queue,
worker_async_callable=worker,
target_worker_count=2,
) as pool:
jobs = [Job(input=input) for input in inputs]
for job in jobs:
await work_queue.put(job)
results: list[int] = []
with anyio.fail_after(adjusted_timeout(10)):
for job in jobs:
await job.started.wait()
if job.input in cancel:
pool.cancel(job=job)
else:
results.append(await result_queue.get())
assert sorted(results) == expected_results
@pytest.mark.anyio
async def test_queued_raises() -> None:
raises = {4, 7}
inputs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
expected_results = [1, 4, 9, 25, 36, 64, 81, 100]
work_queue: asyncio.Queue[Job[int]] = asyncio.Queue()
result_queue: asyncio.Queue[int] = asyncio.Queue()
async def worker(
worker_id: int,
job: Job[int],
) -> int:
if job.input in raises:
raise Exception(job.input)
return job.input**2
async with QueuedAsyncPool.managed(
name="test pool",
job_queue=work_queue,
result_queue=result_queue,
worker_async_callable=worker,
target_worker_count=2,
):
jobs = [Job(input=input) for input in inputs]
for job in jobs:
await work_queue.put(job)
results: list[int] = []
with anyio.fail_after(adjusted_timeout(10)):
for _ in expected_results:
results.append(await result_queue.get())
raising_jobs = [job for job in jobs if job.input in raises]
expected_exceptions = [(Exception, (job.input,)) for job in raising_jobs]
exceptions: list[tuple[type[BaseException], tuple[int]]] = []
for job in raising_jobs:
exception = job.exception
assert isinstance(exception, BaseException)
exceptions.append((type(exception), exception.args))
assert exceptions == expected_exceptions
assert sorted(results) == expected_results