mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-24 08:55:59 +00:00
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
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
"""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)."""
|
||||
Reference in New Issue
Block a user