Files
deer-flow/backend/packages/harness/deerflow/actor/ref.py
T
greatmengqi 3e17417122 feat: asyncio-native Actor framework with supervision, middleware, and pluggable mailbox
Lightweight actor library built on asyncio primitives (~800 lines):

- Actor base class with lifecycle hooks (on_started/on_stopped/on_restart)
- ActorRef with tell (fire-and-forget) and ask (request-response)
- Supervision: OneForOne/AllForOne strategies with restart limits
- Middleware pipeline for cross-cutting concerns
- Pluggable Mailbox interface (MemoryMailbox default, RedisMailbox optional)
- ReplyRegistry + ReplyChannel: ask() works across any mailbox backend
- System-level thread pool for blocking I/O (run_in_executor)
- Dead letter handling, poison message quarantine, parallel shutdown
- 22 tests + benchmark suite
2026-03-30 23:50:54 +08:00

217 lines
7.5 KiB
Python

"""ActorRef — immutable, serializable reference to an actor."""
from __future__ import annotations
import asyncio
import uuid
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .system import _ActorCell
class ActorRef:
"""Immutable handle for sending messages to an actor.
Users never construct this directly — it is returned by
``ActorSystem.spawn`` or ``ActorContext.spawn``.
"""
__slots__ = ("_cell",)
def __init__(self, cell: _ActorCell) -> None:
self._cell = cell
@property
def name(self) -> str:
return self._cell.name
@property
def path(self) -> str:
return self._cell.path
@property
def is_alive(self) -> bool:
return not self._cell.stopped
async def tell(self, message: Any, *, sender: ActorRef | None = None) -> None:
"""Fire-and-forget message delivery."""
if self._cell.stopped:
self._cell.system._dead_letter(self, message, sender)
return
await self._cell.enqueue(_Envelope(message, sender))
async def ask(self, message: Any, *, timeout: float = 5.0) -> Any:
"""Request-response with timeout.
Uses correlation ID + ReplyRegistry instead of passing a Future
through the mailbox. This makes ask work with any Mailbox backend
(memory, Redis, RabbitMQ, etc.).
Raises ``asyncio.TimeoutError`` if the actor doesn't reply in time.
Raises the actor's exception if ``on_receive`` fails.
"""
if self._cell.stopped:
raise ActorStoppedError(f"Actor {self.path} is stopped")
corr_id = uuid.uuid4().hex
future = self._cell.system._replies.register(corr_id)
try:
envelope = _Envelope(message, sender=None, correlation_id=corr_id, reply_to=self._cell.system.system_id)
await self._cell.enqueue(envelope)
return await asyncio.wait_for(future, timeout=timeout)
finally:
self._cell.system._replies.discard(corr_id)
def stop(self) -> None:
"""Request graceful shutdown."""
self._cell.request_stop()
def __repr__(self) -> str:
alive = "alive" if self.is_alive else "dead"
return f"ActorRef({self.path}, {alive})"
def __eq__(self, other: object) -> bool:
if isinstance(other, ActorRef):
return self._cell is other._cell
return NotImplemented
def __hash__(self) -> int:
return id(self._cell)
class ActorStoppedError(Exception):
"""Raised when sending to a stopped actor via ask."""
# ---------------------------------------------------------------------------
# Internal message wrappers (serializable — no Future objects)
# ---------------------------------------------------------------------------
class _Envelope:
"""Message envelope flowing through mailboxes.
All fields are serializable (no asyncio.Future). This is what
enables ask() to work across MQ-backed mailboxes.
"""
__slots__ = ("payload", "sender", "correlation_id", "reply_to")
def __init__(
self,
payload: Any,
sender: ActorRef | None = None,
correlation_id: str | None = None,
reply_to: str | None = None,
) -> None:
self.payload = payload
self.sender = sender
self.correlation_id = correlation_id
self.reply_to = reply_to # System ID of the caller (for cross-process reply routing)
class _Stop:
"""Sentinel placed on the mailbox to trigger graceful shutdown."""
# ---------------------------------------------------------------------------
# ReplyRegistry — maps correlation_id → Future (lives on ActorSystem)
# ---------------------------------------------------------------------------
class _ReplyRegistry:
"""In-memory registry mapping correlation IDs to Futures.
Used by ask() to receive replies without putting Futures in the mailbox.
"""
def __init__(self) -> None:
self._pending: dict[str, asyncio.Future[Any]] = {}
def register(self, corr_id: str) -> asyncio.Future[Any]:
"""Create and register a Future for a correlation ID."""
future: asyncio.Future[Any] = asyncio.get_running_loop().create_future()
self._pending[corr_id] = future
return future
def resolve(self, corr_id: str, result: Any) -> None:
"""Complete a pending ask with a result."""
future = self._pending.pop(corr_id, None)
if future is not None and not future.done():
future.set_result(result)
def reject(self, corr_id: str, error: Exception) -> None:
"""Complete a pending ask with an error."""
future = self._pending.pop(corr_id, None)
if future is not None and not future.done():
future.set_exception(error)
def discard(self, corr_id: str) -> None:
"""Remove a pending entry (e.g. on timeout)."""
self._pending.pop(corr_id, None)
def reject_all(self, error: Exception) -> None:
"""Reject all pending asks (e.g. on system shutdown)."""
for future in self._pending.values():
if not future.done():
future.set_exception(error)
self._pending.clear()
# ---------------------------------------------------------------------------
# ReplyChannel — abstraction for routing replies (local or cross-process)
# ---------------------------------------------------------------------------
class _ReplyMessage:
"""Reply payload sent through ReplyChannel.
Carries the original exception object for local delivery (preserves type).
For cross-process serialization, use ``to_dict``/``from_dict``.
"""
__slots__ = ("correlation_id", "result", "error", "exception")
def __init__(self, correlation_id: str, result: Any = None, error: str | None = None, exception: Exception | None = None) -> None:
self.correlation_id = correlation_id
self.result = result
self.error = error
self.exception = exception # Original exception (local only, not serializable)
def to_dict(self) -> dict[str, Any]:
"""Serialize for cross-process transport (exception becomes string)."""
return {"correlation_id": self.correlation_id, "result": self.result, "error": self.error}
@classmethod
def from_dict(cls, d: dict[str, Any]) -> _ReplyMessage:
return cls(d["correlation_id"], d.get("result"), d.get("error"))
class ReplyChannel:
"""Routes replies from actor back to the caller's ReplyRegistry.
Default implementation: resolve locally (same process).
Override ``send_reply`` for cross-process routing (e.g. via Redis pub/sub).
"""
async def send_reply(self, reply_to: str, reply: _ReplyMessage, local_registry: _ReplyRegistry) -> None:
"""Deliver a reply to the system identified by *reply_to*.
Default: assumes reply_to is the local system → resolve directly.
Override for MQ-backed cross-process delivery.
"""
if reply.exception is not None:
# Local: preserve original exception type
local_registry.reject(reply.correlation_id, reply.exception)
elif reply.error is not None:
# Cross-process: exception was serialized to string
local_registry.reject(reply.correlation_id, RuntimeError(reply.error))
else:
local_registry.resolve(reply.correlation_id, reply.result)
async def start_listener(self, system_id: str, registry: _ReplyRegistry) -> None:
"""Start listening for inbound replies (no-op for local)."""
async def stop_listener(self) -> None:
"""Stop the reply listener (no-op for local)."""