mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-22 07:56:48 +00:00
3e17417122
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
217 lines
7.5 KiB
Python
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)."""
|