fix(actor): harden lifecycle, supervision, Redis mailbox, and add comprehensive tests

- Fix spawn() zombie cell: clean up registry on start() failure
- Fix shutdown(): cancel + await tasks that exceed graceful timeout
- Fix _shutdown(): await mailbox.close() to release backend resources
- Fix escalate directive: stop failing child before propagating to grandparent
- Fix RedisMailbox.put(): wrap Redis errors in try/except, return False on failure
- Fix retry.py: replace assert with proper raise for last_exc
- Add put_batch() to Mailbox abstraction for single-roundtrip bulk enqueue
- Add RedisMailbox.put_batch() with atomic Lua script for bounded queues
- Add MailboxFullError exception type for semantic backpressure handling
- Add redis>=7.4.0 dependency with public PyPI sources in uv.lock

Tests added (31 total, up from 27):
- test_middleware_on_restart_hook: verifies middleware.on_restart() on supervision restart
- test_ask_propagates_actor_exception: ask() re-raises original exception type
- test_ask_propagates_exception_while_supervised: exception propagates; root actor survives
- test_ask_timeout_late_reply_no_exception: late reply after timeout is silent no-op
- test_actor_backpressure.py: MailboxFullError + dead letter on full mailbox
- test_actor_retry.py: ask_with_retry with exponential backoff
- test_mailbox_redis.py: RedisMailbox put/get/batch/close
- bench_actor_redis.py: RedisMailbox throughput benchmarks
This commit is contained in:
greatmengqi
2026-03-31 10:09:05 +08:00
parent 3e17417122
commit 228a2a66e3
14 changed files with 3156 additions and 2289 deletions
@@ -12,6 +12,12 @@ import asyncio
from typing import Any
BACKPRESSURE_BLOCK = "block"
BACKPRESSURE_DROP_NEW = "drop_new"
BACKPRESSURE_FAIL = "fail"
BACKPRESSURE_POLICIES = {BACKPRESSURE_BLOCK, BACKPRESSURE_DROP_NEW, BACKPRESSURE_FAIL}
class Mailbox(abc.ABC):
"""Abstract mailbox — the message queue for an actor.
@@ -44,6 +50,18 @@ class Mailbox(abc.ABC):
def full(self) -> bool:
"""Return True if mailbox is at capacity."""
async def put_batch(self, msgs: list[Any]) -> int:
"""Enqueue multiple messages. Returns count accepted.
Default implementation falls back to sequential ``put`` calls.
Backends like Redis should override this for efficient bulk push.
"""
count = 0
for msg in msgs:
if await self.put(msg):
count += 1
return count
async def close(self) -> None:
"""Release resources. Default is no-op."""
@@ -55,23 +73,32 @@ class Empty(Exception):
class MemoryMailbox(Mailbox):
"""In-process mailbox backed by ``asyncio.Queue``."""
def __init__(self, maxsize: int = 256) -> None:
def __init__(self, maxsize: int = 256, *, backpressure_policy: str = BACKPRESSURE_BLOCK) -> None:
if backpressure_policy not in BACKPRESSURE_POLICIES:
raise ValueError(
f"Invalid backpressure_policy={backpressure_policy!r}, "
f"expected one of {sorted(BACKPRESSURE_POLICIES)}"
)
self._queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=maxsize)
self._maxsize = maxsize
self._backpressure_policy = backpressure_policy
async def put(self, msg: Any) -> bool:
try:
if self._backpressure_policy == BACKPRESSURE_BLOCK:
await self._queue.put(msg)
return True
except asyncio.QueueFull:
return False
def put_nowait(self, msg: Any) -> bool:
try:
if self._backpressure_policy in (BACKPRESSURE_DROP_NEW, BACKPRESSURE_FAIL):
if self._queue.full():
return False
self._queue.put_nowait(msg)
return True
except asyncio.QueueFull:
return False
def put_nowait(self, msg: Any) -> bool:
if self._queue.full():
return False
self._queue.put_nowait(msg)
return True
async def get(self) -> Any:
return await self._queue.get()