Source code for edri.abstract.worker.worker
from logging import getLogger
from multiprocessing.connection import Connection
from pathlib import Path
from time import monotonic
from traceback import format_exc
from typing import Any, Generic, TypeVar
from abc import abstractmethod, ABC
from edri.dataclass.event import Event
from edri.dataclass.worker import WorkerExit
from edri.events.edri.manager import WorkerQuit, WorkerStarted
__all__ = ["Worker", "WorkerCancelled", "T"]
T = TypeVar("T", bound=Event)
[docs]
class WorkerCancelled(Exception):
"""Internal cooperative-cancellation signal raised inside a worker."""
[docs]
class Worker(Generic[T], ABC):
"""
A generic worker class designed to communicate with a manager through events.
Attributes:
_name (str): The name of the worker, used for logging purposes.
_manager_pipe (Connection): The communication pipe to the manager.
event (EventType): The initial event associated with the worker.
logger (Logger): Logger instance for logging messages.
Methods:
__init__: Initializes a new Worker instance.
do: Abstract method defining the main logic of the worker; to be implemented by subclasses.
event_send: Sends an event to the manager.
event_receive: Receives an event from the manager, blocking if no event is available.
run: Entry point for the worker's execution, wrapping the `do` method with error handling and cleanup.
"""
def __init__(self, pipe: Connection, event: T | None, name: str, *args: Any, **kwargs: Any) -> None:
"""
Initializes a new Worker instance.
Parameters:
pipe (Connection): The communication pipe to the manager.
event (EventType): The initial event associated with this worker.
name (str): A name for the worker, used for logging.
*args: Extra positional arguments for a concrete subclass's own
__init__ override; ignored here. Accepted so WorkerThread/
WorkerProcess subclasses that don't override __init__ can still
be started via WorkerController.start(event, cls, *args,
**kwargs) without this passthrough call raising TypeError.
**kwargs: Extra keyword arguments, same rationale as *args.
"""
self._manager_pipe = pipe
self._name = name
self.event = event
self.logger = getLogger(self._name)
self._cancel_event: Any = None
# Bound by the controller for thread runtimes only; see
# _bind_router_queue. Stays None in a process worker, which runs in a
# different address space and must use event_send() instead.
self._router_queue: Any = None
def _bind_lifecycle(self, cancel_event: Any) -> None:
"""Bind controller-owned primitives without changing subclass constructors."""
self._cancel_event = cancel_event
def _bind_router_queue(self, router_queue: Any) -> None:
"""Give a *thread* runtime direct access to its manager's router queue.
Publishing through this queue is not the same thing as
:meth:`event_send`, which writes to the worker-manager pipe and is
answered by the manager. It exists for side-channel traffic a worker
produces for the rest of the system rather than for its own manager --
the Pydantic AI observability action stream is the first such producer.
Bound after construction and before ``start()``, so it never changes a
subclass's constructor contract. It is deliberately *not* bound for
process runtimes: the manager's queue object does not cross a process
boundary that way, and a worker must be able to see that it has no
binding rather than discover it through a broken send.
"""
self._router_queue = router_queue
def _request_cancel(self) -> None:
if self._cancel_event is not None:
self._cancel_event.set()
@property
def cancel_requested(self) -> bool:
return self._cancel_event is not None and self._cancel_event.is_set()
[docs]
def wait_cancelled(self, timeout: float | None = None) -> bool:
if self._cancel_event is None:
return False
return bool(self._cancel_event.wait(timeout))
[docs]
def raise_if_cancelled(self) -> None:
if self.cancel_requested:
raise WorkerCancelled
[docs]
def event_send(self, event: Event) -> None:
"""
Sends an event to the manager.
Parameters:
event (Event): The event to be sent.
"""
event._timing.stamp(self.__class__.__name__, "Sent")
self._manager_pipe.send(event)
self.logger.debug("Event was sent: %s", event)
[docs]
def event_receive(self) -> Event:
"""
Receives an event from the manager. This method blocks until an event is available.
Returns:
Event: The received event.
"""
event: Event = self._manager_pipe.recv()
event._timing.stamp(self.__class__.__name__, "Received")
return event
@staticmethod
def _fix_environment() -> None:
import site, sys, os
exe = Path(sys.executable)
venv_root = exe.parent.parent
version = f"python{sys.version_info.major}.{sys.version_info.minor}"
site_packages = venv_root / "lib" / version / "site-packages"
# prepend new site-packages:
prev = set(sys.path)
site.addsitedir(str(site_packages))
sys.path[:] = [p for p in sys.path if p not in prev] + list(prev)
os.environ["VIRTUAL_ENV"] = str(venv_root.resolve())
sys.real_prefix = sys.prefix # type: ignore[attr-defined]
sys.prefix = str(venv_root)
[docs]
def setup(self) -> None:
"""
Hook run once before :meth:`do`, inside the same error-handling scope.
The default is a no-op. Override it for one-time preparation that must
happen in the worker's own thread/process (building helpers, opening
resources). A failure here is treated exactly like a failure in
:meth:`do`: it is logged and the worker still sends ``WorkerQuit`` so the
manager can reap it.
"""
pass
[docs]
@abstractmethod
def do(self) -> None:
"""
Abstract method that should be implemented by subclasses to define the worker's main logic.
"""
pass
[docs]
def run(self) -> None:
"""
The main entry point for the worker's execution. It runs :meth:`setup`
then :meth:`do`, handling any exceptions and ensuring cleanup and
communication with the manager upon termination.
"""
self._fix_environment()
if self.event:
self.event._timing.stamp(self.__class__.__name__, "Started")
quit_event = WorkerQuit()
try:
self.event_send(WorkerStarted(started=monotonic()))
self.raise_if_cancelled()
self.setup()
self.do()
except WorkerCancelled:
quit_event.exit = WorkerExit.CANCELLED
except BaseException as e:
self.logger.error("Worker %s was closed unexpectedly", self._name, exc_info=e)
quit_event.exit = WorkerExit.RAISED
quit_event.exception_type = type(e).__qualname__
quit_event.exception_message = str(e)
quit_event.traceback = format_exc()
finally:
if quit_event.exit is WorkerExit.RETURNED and self.cancel_requested:
quit_event.exit = WorkerExit.CANCELLED
self.event_send(quit_event)
self._manager_pipe.close()