mirror of
https://github.com/Chia-Network/chia-blockchain.git
synced 2026-08-29 02:24:35 -05:00
* Enable PEP604 Ruff rules * Fix harcoded signature in test * Hack CLVMStreamable test with note to fast follow
227 lines
7.5 KiB
Python
227 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import dataclasses
|
|
import itertools
|
|
import logging
|
|
import traceback
|
|
from collections.abc import AsyncIterator, Iterator
|
|
from typing import Generic, Protocol, TypeVar, final
|
|
|
|
import anyio
|
|
|
|
from chia.util.log_exceptions import log_exceptions
|
|
from chia.util.task_referencer import create_referenced_task
|
|
|
|
|
|
class InvalidTargetWorkerCountError(Exception):
|
|
def __init__(self, o: object) -> None:
|
|
super().__init__(f"target worker count must be one or greater: {o!r}")
|
|
|
|
|
|
class WorkerCallable(Protocol):
|
|
async def __call__(self, worker_id: int) -> object: ...
|
|
|
|
|
|
J = TypeVar("J")
|
|
R = TypeVar("R")
|
|
T = TypeVar("T")
|
|
T_co = TypeVar("T_co", covariant=True)
|
|
T_contra = TypeVar("T_contra", contravariant=True)
|
|
|
|
|
|
class QueuedWorkerCallable(Protocol[T, T_co]):
|
|
async def __call__(self, worker_id: int, job: Job[T]) -> T_co: ...
|
|
|
|
|
|
class JobQueueProtocol(Protocol[T_co]):
|
|
async def get(self) -> T_co: ...
|
|
|
|
|
|
class ResultQueueProtocol(Protocol[T_contra]):
|
|
async def put(self, item: T_contra) -> None: ...
|
|
|
|
|
|
# TODO: how does this compare to just using a future
|
|
@dataclasses.dataclass
|
|
class Job(Generic[T]):
|
|
input: T
|
|
started: asyncio.Event = dataclasses.field(default_factory=asyncio.Event)
|
|
done: asyncio.Event = dataclasses.field(default_factory=asyncio.Event)
|
|
exception: BaseException | None = None
|
|
task: asyncio.Task[object] | None = None
|
|
cancelled: bool = False
|
|
|
|
|
|
@final
|
|
@dataclasses.dataclass
|
|
class QueuedAsyncPool(Generic[J, R]):
|
|
name: str
|
|
job_queue: JobQueueProtocol[Job[J]]
|
|
result_queue: ResultQueueProtocol[R] | None
|
|
worker_async_callable: QueuedWorkerCallable[J, R]
|
|
|
|
@classmethod
|
|
@contextlib.asynccontextmanager
|
|
async def managed(
|
|
cls,
|
|
name: str,
|
|
job_queue: JobQueueProtocol[Job[J]],
|
|
worker_async_callable: QueuedWorkerCallable[J, R],
|
|
target_worker_count: int,
|
|
result_queue: ResultQueueProtocol[R] | None = None,
|
|
log: logging.Logger = logging.getLogger(__name__),
|
|
) -> AsyncIterator[QueuedAsyncPool[J, R]]:
|
|
self = cls(
|
|
name=name,
|
|
job_queue=job_queue,
|
|
result_queue=result_queue,
|
|
worker_async_callable=worker_async_callable,
|
|
)
|
|
|
|
async with AsyncPool.managed(
|
|
name=self.name,
|
|
worker_async_callable=self.worker,
|
|
target_worker_count=target_worker_count,
|
|
log=log,
|
|
):
|
|
yield self
|
|
|
|
async def get_job(self) -> Job[J]:
|
|
while True:
|
|
job = await self.job_queue.get()
|
|
if not job.cancelled:
|
|
# TODO: can the job just be removed from the queue?
|
|
return job
|
|
|
|
async def worker(self, worker_id: int) -> None:
|
|
job = await self.get_job()
|
|
job.task = asyncio.current_task()
|
|
job.started.set()
|
|
|
|
try:
|
|
result = await self.worker_async_callable(worker_id=worker_id, job=job)
|
|
except BaseException as e:
|
|
# TODO: can't you not raise the same exception twice so this has to be
|
|
# just reference and is all... well, i dunno.
|
|
job.exception = e
|
|
raise
|
|
else:
|
|
if self.result_queue is not None:
|
|
await self.result_queue.put(result)
|
|
finally:
|
|
job.done.set()
|
|
|
|
def cancel(self, job: Job[J]) -> None:
|
|
# TODO: should this just be on the Job object? or can we do something useful
|
|
# here like get it out of the queue.
|
|
job.cancelled = True
|
|
if job.task is not None:
|
|
job.task.cancel()
|
|
# TODO: should this happen only after actual cancellation completes?
|
|
job.done.set()
|
|
|
|
|
|
@final
|
|
@dataclasses.dataclass
|
|
class AsyncPool:
|
|
name: str
|
|
log: logging.Logger
|
|
worker_async_callable: WorkerCallable
|
|
_target_worker_count: int
|
|
_workers: dict[asyncio.Task[object], int] = dataclasses.field(init=False, default_factory=dict)
|
|
_worker_id_counter: Iterator[int] = dataclasses.field(init=False, default_factory=itertools.count)
|
|
_started: asyncio.Event = dataclasses.field(default_factory=asyncio.Event)
|
|
_single_use_used: bool = False
|
|
|
|
@classmethod
|
|
@contextlib.asynccontextmanager
|
|
async def managed(
|
|
cls,
|
|
name: str,
|
|
worker_async_callable: WorkerCallable,
|
|
target_worker_count: int,
|
|
log: logging.Logger = logging.getLogger(__name__),
|
|
) -> AsyncIterator[AsyncPool]:
|
|
self = cls(
|
|
name=name,
|
|
log=log,
|
|
worker_async_callable=worker_async_callable,
|
|
_target_worker_count=target_worker_count,
|
|
)
|
|
|
|
if self._target_worker_count < 1:
|
|
raise InvalidTargetWorkerCountError(self._target_worker_count)
|
|
|
|
task = create_referenced_task(self._run(_check_single_use=False))
|
|
try:
|
|
# TODO: should this terminate if the run task ends?
|
|
await self._started.wait()
|
|
yield self
|
|
finally:
|
|
with anyio.CancelScope(shield=True):
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
async def _run(self, *, _check_single_use: bool = True) -> None:
|
|
method_name = f"{type(self).__name__}._run()"
|
|
|
|
try:
|
|
while True:
|
|
with log_exceptions(
|
|
log=self.log,
|
|
consume=True,
|
|
message=f"exception consumed while looping in {method_name} for {self.name!r}",
|
|
):
|
|
await self._run_single()
|
|
finally:
|
|
with anyio.CancelScope(shield=True):
|
|
with log_exceptions(
|
|
log=self.log,
|
|
consume=False,
|
|
message=f"exception while tearing down in {method_name} for {self.name!r}",
|
|
):
|
|
await self._teardown_workers()
|
|
|
|
async def _run_single(self) -> None:
|
|
while len(self._workers) < self._target_worker_count:
|
|
new_worker_id = next(self._worker_id_counter)
|
|
new_worker = create_referenced_task(self.worker_async_callable(new_worker_id))
|
|
self.log.debug(f"{self.name}: adding worker {new_worker_id}")
|
|
self._workers[new_worker] = new_worker_id
|
|
|
|
self._started.set()
|
|
|
|
self.log.debug(f"{self.name}: waiting with {len(self._workers)} workers: {list(self._workers.values())}")
|
|
done_workers, pending_workers = await asyncio.wait(
|
|
self._workers,
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
done_workers_by_id = {task: self._workers[task] for task in done_workers}
|
|
self._workers = {task: self._workers[task] for task in pending_workers}
|
|
|
|
for task, id in done_workers_by_id.items():
|
|
await self._handle_done_worker(task=task, id=id)
|
|
|
|
async def _teardown_workers(self) -> None:
|
|
while True:
|
|
try:
|
|
task, id = self._workers.popitem()
|
|
except KeyError:
|
|
break
|
|
|
|
task.cancel()
|
|
await self._handle_done_worker(task=task, id=id)
|
|
|
|
async def _handle_done_worker(self, task: asyncio.Task[object], id: int) -> None:
|
|
with anyio.CancelScope(shield=True):
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
error_trace = traceback.format_exc()
|
|
self.log.error(f"{self.name}: worker {id} raised exception: {error_trace}")
|