From a1f77800d3e81ddd8a23de5ac744cf1276c188c3 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sun, 18 Jan 2026 21:42:37 +0800 Subject: [PATCH 01/24] Update README and documentation to reflect changes in actor command structure and remove deprecated features - Updated README files to clarify the usage of the `pulsing actor` command, now requiring full class paths for actor types. - Enhanced documentation for starting actors, including examples for the new Router and worker classes. - Removed references to the deprecated `pulsing actor list` command, replacing it with `pulsing inspect` for actor system observation. - Updated various examples and guides to align with the new command structure and improve user understanding. --- README.md | 8 +- README.zh.md | 8 +- docs/actor-list-guide.md | 128 ------------ docs/actor-list-implementation.md | 254 ------------------------ docs/mkdocs.yml | 2 + docs/overrides/home.html | 6 +- docs/src/api_reference.md | 32 ++- docs/src/api_reference.zh.md | 32 ++- docs/src/examples/llm_inference.md | 4 +- docs/src/examples/llm_inference.zh.md | 4 +- docs/src/guide/operations.md | 20 +- docs/src/guide/operations.zh.md | 20 +- docs/src/guide/style.md | 69 +++++++ docs/src/guide/style.zh.md | 69 +++++++ docs/src/quickstart/llm_inference.md | 18 +- docs/src/quickstart/llm_inference.zh.md | 18 +- examples/bash/README.md | 87 ++------ examples/bash/demo_actor_list.sh | 155 --------------- examples/bash/demo_actor_list_remote.sh | 145 -------------- examples/inspect/demo_service.py | 22 +- python/pulsing/actor/helpers.py | 28 +-- python/pulsing/actors/__init__.py | 3 +- python/pulsing/actors/router.py | 119 ++++++++++- python/pulsing/cli/__main__.py | 16 +- test_actor_list_integration.py | 73 ------- test_actor_list_same_process.py | 61 ------ test_actor_system.py | 45 ----- 27 files changed, 424 insertions(+), 1022 deletions(-) delete mode 100644 docs/actor-list-guide.md delete mode 100644 docs/actor-list-implementation.md create mode 100644 docs/src/guide/style.md create mode 100644 docs/src/guide/style.zh.md delete mode 100755 examples/bash/demo_actor_list.sh delete mode 100644 examples/bash/demo_actor_list_remote.sh delete mode 100644 test_actor_list_integration.py delete mode 100644 test_actor_list_same_process.py delete mode 100644 test_actor_system.py diff --git a/README.md b/README.md index 27444117f..12367a7cb 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,13 @@ **Lightweight distributed framework designed for high-performance AI applications.** 🚀 **Zero Dependencies** — Pure Rust + Tokio, no NATS/etcd/Redis + 🌐 **Auto Discovery** — Built-in Gossip protocol for cluster management + 🔀 **Location Transparent** — Same API for local and remote Actors + ⚡ **Streaming Ready** — Native support for LLM streaming responses + 🤖 **Agent Friendly** — Integrates with AutoGen, LangGraph out of the box ## 🚀 Get Started in 5 Minutes @@ -129,10 +133,10 @@ Out-of-the-box GPU cluster inference: ```bash # Start Router (OpenAI-compatible API) -pulsing actor router --addr 0.0.0.0:8000 --http_port 8080 --model_name my-llm +pulsing actor pulsing.actors.Router --addr 0.0.0.0:8000 --http_port 8080 --model_name my-llm # Start vLLM Worker (can have multiple) -pulsing actor vllm --model Qwen/Qwen2.5-0.5B --addr 0.0.0.0:8002 --seeds 127.0.0.1:8000 +pulsing actor pulsing.actors.VllmWorker --model Qwen/Qwen2.5-0.5B --addr 0.0.0.0:8002 --seeds 127.0.0.1:8000 # Test curl http://localhost:8080/v1/chat/completions \ diff --git a/README.zh.md b/README.zh.md index daab762e0..634427498 100644 --- a/README.zh.md +++ b/README.zh.md @@ -10,9 +10,13 @@ **轻量级分布式框架,专为高性能 AI 应用设计。** 🚀 **零外部依赖** — 纯 Rust + Tokio,无需 NATS/etcd/Redis + 🌐 **自动发现** — 内置 Gossip 协议管理集群 + 🔀 **位置透明** — 本地和远程 Actor 使用相同 API + ⚡ **流式支持** — 原生支持 LLM 流式响应 + 🤖 **Agent 友好** — 开箱即用集成 AutoGen、LangGraph ## 🚀 5分钟快速体验 @@ -129,10 +133,10 @@ async with runtime(addr="0.0.0.0:8002", seeds=["node1:8001"]): ```bash # 启动 Router(OpenAI 兼容 API) -pulsing actor router --addr 0.0.0.0:8000 --http_port 8080 --model_name my-llm +pulsing actor pulsing.actors.Router --addr 0.0.0.0:8000 --http_port 8080 --model_name my-llm # 启动 vLLM Worker(可多个) -pulsing actor vllm --model Qwen/Qwen2.5-0.5B --addr 0.0.0.0:8002 --seeds 127.0.0.1:8000 +pulsing actor pulsing.actors.VllmWorker --model Qwen/Qwen2.5-0.5B --addr 0.0.0.0:8002 --seeds 127.0.0.1:8000 # 测试 curl http://localhost:8080/v1/chat/completions \ diff --git a/docs/actor-list-guide.md b/docs/actor-list-guide.md deleted file mode 100644 index 9817b4a4d..000000000 --- a/docs/actor-list-guide.md +++ /dev/null @@ -1,128 +0,0 @@ -# Actor List 命令使用指南 - -!!! note "文档迁移" - 本页已迁移到文档站点的 **Guide** 中,并会以站点版本为准: - - `docs/src/guide/actor_list.zh.md` - - `docs/src/guide/actor_list.md` - -`pulsing actor list` 命令用于列出当前 Actor 系统中的 actors。 - -## 基本用法 - -### 列出用户 actors(默认) - -```bash -pulsing actor list -``` - -默认情况下,只显示用户创建的命名 actors,不包括以 `_` 开头的系统内部 actors。 - -输出示例: - -``` -Name Type Uptime Code Path ---------------------------------------------------------------------------------------------------- -counter-1 user 5m 23s - -counter-2 user 5m 23s - -calculator user 5m 23s - - -Total: 3 actor(s) -``` - -### 列出所有 actors(包括系统 actors) - -```bash -pulsing actor list --all_actors True -``` - -包括系统内部的 actors: - -``` -Name Type Uptime Code Path ---------------------------------------------------------------------------------------------------- -counter-1 user 5m 23s - -_system_internal system 5m 30s - -_python_actor_service system 5m 30s - - -Total: 5 actor(s) -``` - -### JSON 输出格式 - -```bash -pulsing actor list --json True -``` - -以 JSON 格式输出,方便脚本处理: - -```json -[ - { - "name": "counter-1", - "type": "user", - "code_path": null, - "uptime": "5m 23s" - }, - { - "name": "counter-2", - "type": "user", - "code_path": null, - "uptime": "5m 23s" - } -] -``` - -## 在 Python 代码中使用 - -`pulsing actor list` CLI 命令需要在运行 actor system 的进程内调用。更常见的用法是直接在 Python 代码中使用: - -```python -import asyncio -from pulsing.actor import init, remote, get_system -from pulsing.cli.actor_list import list_actors_impl - - -@remote -class Counter: - def __init__(self): - self.count = 0 - - -async def main(): - # 初始化系统 - await init() - system = get_system() - - # 创建一些 actors - await Counter.remote(system, name="counter-1") - await Counter.remote(system, name="counter-2") - - # 列出 actors - await list_actors_impl(all_actors=False, output_format="table") - - # 或者直接使用底层 API - actor_names = system.local_actor_names() - user_actors = [n for n in actor_names if not n.startswith("_")] - print(f"User actors: {user_actors}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -## 字段说明 - -- **Name**: Actor 的名称 -- **Type**: Actor 类型 - - `user`: 用户创建的 actors - - `system`: 系统内部 actors -- **Uptime**: Actor 运行时间(当前为系统启动时间的近似值) -- **Code Path**: Python 类的代码路径(当前版本暂未实现,显示为 `-`) - -## 未来改进 - -- [ ] 显示每个 actor 的精确创建时间/运行时间 -- [ ] 显示 Python actor 的类型(类名)和代码路径 -- [ ] 显示 actor 的消息处理统计(处理数量、错误数等) -- [ ] 支持通过 `--seeds` 参数查询远程集群的 actors -- [ ] 支持过滤和搜索(按名称、类型等) diff --git a/docs/actor-list-implementation.md b/docs/actor-list-implementation.md deleted file mode 100644 index b25f8dd5e..000000000 --- a/docs/actor-list-implementation.md +++ /dev/null @@ -1,254 +0,0 @@ -# Pulsing Actor List 完整实现总结 - -!!! note "文档迁移" - 本页偏实现细节,面向用户的最新版已迁移到文档站点的 **Guide**: - - `docs/src/guide/actor_list.zh.md` - - `docs/src/guide/actor_list.md` - - `docs/src/guide/operations.zh.md` - - `docs/src/guide/operations.md` - -## ✅ 已完成功能 - -### 1. 本地查询模式 -在运行 actor system 的进程内查询 actors: - -```bash -# 在应用代码中 -from pulsing.cli.actor_list import list_actors_impl -await list_actors_impl() - -# 或在同一进程中作为 Python API -from pulsing.actor import get_system -names = get_system().local_actor_names() -``` - -**功能:** -- ✅ 列出用户 actors(默认) -- ✅ 列出所有 actors(`--all_actors True`) -- ✅ 显示 Python 类名(如 `__main__.Counter`) -- ✅ 显示代码路径(如 `/path/to/file.py`) -- ✅ 表格和 JSON 输出格式 - -### 2. 远程查询模式 -从外部连接到远程集群并查询 actors: - -```bash -# 查询整个集群 -pulsing actor list --list_seeds "127.0.0.1:8000" - -# 查询特定节点 -pulsing actor list --list_seeds "127.0.0.1:8000" --node_id 12345 - -# JSON 输出 -pulsing actor list --list_seeds "127.0.0.1:8000" --json True -``` - -**功能:** -- ✅ 通过 seeds 连接远程集群 -- ✅ 自动发现集群中的所有节点 -- ✅ 查询每个节点的 actors -- ✅ 显示节点状态和响应性 -- ✅ 支持查询特定节点(`--node_id`) - -## 实现架构 - -### 组件层次 - -``` -┌─────────────────────────────────────────┐ -│ CLI: pulsing actor list │ -│ (python/pulsing/cli/__main__.py) │ -└────────────┬────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────┐ -│ list_actors_command() │ -│ (python/pulsing/cli/actor_list.py) │ -│ - 解析参数 │ -│ - 选择本地/远程模式 │ -└────────────┬────────────────────────────┘ - │ - ┌──────┴──────┐ - ▼ ▼ -┌───────────┐ ┌──────────────────┐ -│ 本地模式 │ │ 远程模式 │ -│ │ │ │ -│ get_ │ │ create_actor_ │ -│ system() │ │ system(seeds) │ -│ │ │ │ -│ local_ │ │ all_named_ │ -│ actor_ │ │ actors() │ -│ names() │ │ │ -└───────────┘ └──────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────────────────────┐ -│ Metadata Registry │ -│ (_actor_metadata_registry) │ -│ - Python class name │ -│ - Source file path │ -│ - Module name │ -└─────────────────────────────────────────┘ -``` - -### 关键代码位置 - -1. **Rust 侧元信息提取** (`crates/pulsing-py/src/actor.rs`) - ```rust - impl Actor for PythonActorWrapper { - fn metadata(&self) -> HashMap { - // 自动提取 __class__, __module__, __file__ - } - } - ``` - -2. **Python 侧元信息注册** (`python/pulsing/actor/remote.py`) - ```python - def _register_actor_metadata(name: str, cls: type): - """在创建 actor 时注册类型信息""" - - def get_actor_metadata(name: str) -> dict[str, str] | None: - """查询 actor 的元信息""" - ``` - -3. **CLI 实现** (`python/pulsing/cli/actor_list.py`) - - `list_actors_impl()`: 核心查询逻辑 - - `_list_remote_node_actors()`: 远程节点查询 - - `_print_actors_output()`: 格式化输出 - -## 输出示例 - -### 本地查询(表格格式) -``` -Name Type Class Code Path ----------------------------------------------------------------------------------------------------------------------------------- -counter-1 user __main__.Counter /tmp/demo.py -counter-2 user __main__.Counter /tmp/demo.py -calculator user __main__.Calculator /tmp/demo.py - -Total: 3 actor(s) -``` - -### 远程查询(多节点) -``` -Connecting to cluster via seeds: ['127.0.0.1:9001']... -Found 2 nodes in cluster - -================================================================================ -Node 12345 (127.0.0.1:9001) - Status: Alive -================================================================================ - Node is responsive (ping: 1234567890) - Name Type Class Code Path - ---------------------------------------------------------------------------------------------------------------------------------- - service-a-1 user - - - service-a-2 user - - - - Total: 2 actor(s) - -================================================================================ -Node 67890 (127.0.0.1:9002) - Status: Alive -================================================================================ - Node is responsive (ping: 1234567891) - Name Type Class Code Path - ---------------------------------------------------------------------------------------------------------------------------------- - service-b-1 user - - - service-b-2 user - - - service-b-3 user - - - - Total: 3 actor(s) -``` - -## 使用场景 - -### 场景 1: 开发调试 -在应用内部快速查看创建了哪些 actors: - -```python -from pulsing.actor import init, remote, get_system -from pulsing.cli.actor_list import list_actors_impl - -await init() -# ... 创建 actors ... - -# 查看当前 actors -await list_actors_impl() -``` - -### 场景 2: 运维监控 -从外部查看生产集群的 actors 分布: - -```bash -# 查看整个集群 -pulsing actor list --list_seeds "prod-node-1:8000" - -# 查看特定节点 -pulsing actor list --list_seeds "prod-node-1:8000" --node_id 12345 - -# 导出为 JSON 供监控系统使用 -pulsing actor list --list_seeds "prod-node-1:8000" --json True > actors.json -``` - -### 场景 3: 集群诊断 -结合 `pulsing inspect` 使用,全面了解集群状态: - -```bash -# 先查看集群拓扑 -pulsing inspect --seeds "127.0.0.1:8000" - -# 再查看详细的 actor 列表 -pulsing actor list --list_seeds "127.0.0.1:8000" --all_actors True -``` - -## 局限性和未来改进 - -### 当前局限 - -1. **远程元信息缺失**:查询远程节点时,无法获取 Python 类名和代码路径 - - 原因:metadata 存储在本地进程内存中 - - 影响:远程查询只能看到 actor 名字 - -2. **Uptime 精度**:当前显示的是系统 uptime,不是单个 actor 的创建时间 - - 原因:ActorRegistry 存储创建时间,但 local_actor_names() 不返回 - -3. **性能**:查询大集群时需要逐个 ping 节点 - - 可能的优化:并发查询、缓存结果 - -### 建议改进(优先级从高到低) - -- [ ] **P1**: 在 Rust 的 ActorRegistry 中存储并返回 metadata - - 让远程查询也能看到类型信息 - -- [ ] **P2**: 添加每个 actor 的精确 uptime - - 修改 `local_actor_names()` 返回更详细信息 - -- [ ] **P2**: 添加消息统计(处理量、错误率等) - - 从 metrics 系统获取 - -- [ ] **P3**: 支持过滤和搜索 - - 按名称、类型、节点等过滤 - -- [ ] **P3**: 交互式模式(实时刷新) - - 类似 `top` 命令的体验 - -## 测试 - -```bash -# 运行测试 -cd /Users/reiase/workspace/Pulsing -PYTHONPATH=python pyenv exec python -m pytest tests/python/test_actor_list.py -v - -# 运行演示 -bash examples/bash/demo_actor_list.sh -bash examples/bash/demo_actor_list_remote.sh -``` - -## 相关文件 - -- `python/pulsing/cli/actor_list.py` - 核心实现 -- `python/pulsing/cli/__main__.py` - CLI 集成 -- `python/pulsing/actor/remote.py` - 元信息注册 -- `crates/pulsing-py/src/actor.rs` - Rust 元信息提取 -- `tests/python/test_actor_list.py` - 测试用例 -- `examples/bash/demo_actor_list.sh` - 本地演示 -- `examples/bash/demo_actor_list_remote.sh` - 远程演示 -- `docs/actor-list-guide.md` - 用户文档 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index a71091ab8..c701fe72a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -94,6 +94,7 @@ plugins: Operations: CLI 运维 Distributed Queue: 分布式内存队列 Semantics: 语义与保证 + Style Guide: 术语与风格 Agent: Agent 框架 Overview: 概述 Pulsing Native: Pulsing 原生 @@ -138,6 +139,7 @@ nav: - Security: guide/security.md - Distributed Queue: guide/queue.md - Semantics: guide/semantics.md + - Style Guide: guide/style.md - Agent: - agent/index.md - Pulsing Native: agent/native.md diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 601290c3c..bf5d6d3fe 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -874,11 +874,13 @@

LLM Inference Ready

# Start OpenAI-compatible Router
-pulsing actor router --addr 0.0.0.0:8000 \
+pulsing actor pulsing.actors.Router \
+    --addr 0.0.0.0:8000 \
     --http_port 8080 --model_name my-llm
 
 # Start vLLM Worker
-pulsing actor vllm --model Qwen/Qwen2.5-0.5B \
+pulsing actor pulsing.actors.VllmWorker \
+    --model Qwen/Qwen2.5-0.5B \
     --addr 0.0.0.0:8001 --seeds 127.0.0.1:8000
 
 # Test with curl
diff --git a/docs/src/api_reference.md b/docs/src/api_reference.md
index 68c189427..c85794749 100644
--- a/docs/src/api_reference.md
+++ b/docs/src/api_reference.md
@@ -176,7 +176,7 @@ class ActorSystem:
 
 ### ActorRef
 
-Reference to an actor (local or remote).
+Low-level reference to an actor (local or remote). Usually not used directly; prefer `ActorProxy`.
 
 ```python
 class ActorRef:
@@ -193,6 +193,30 @@ class ActorRef:
         pass
 ```
 
+### ActorProxy
+
+High-level proxy wrapper for actors, returned by `@remote` decorator's `spawn()` and `resolve()`.
+**Recommended: use ActorProxy to call methods directly**, no need to manually construct `Message`.
+
+```python
+class ActorProxy:
+    @property
+    def ref(self) -> ActorRef:
+        """Get underlying ActorRef (for low-level ask/tell)"""
+        pass
+
+    # Call actor methods directly, e.g.:
+    # result = await proxy.my_method(arg1, arg2)
+```
+
+**ActorProxy vs ActorRef comparison**:
+
+| Scenario | Recommendation |
+|----------|----------------|
+| Call `@remote` class methods | `ActorProxy`: `await proxy.method()` |
+| Need low-level ask/tell | `ActorRef`: `await proxy.ref.ask(msg)` |
+| Need actor_id | `ActorRef`: `proxy.ref.actor_id` |
+
 ## Decorators
 
 ### @remote
@@ -259,7 +283,11 @@ result = await ask_with_timeout(ref, {"op": "compute"}, timeout=10.0)
 
 After decoration, the class provides:
 
-- `spawn(**kwargs) -> ActorRef`: Create actor (uses global system from `init()`)
+- `spawn(**kwargs) -> ActorProxy`: Create actor and return proxy (uses global system from `init()`)
+- `local(system, **kwargs) -> ActorProxy`: Create actor on specified system
+- `resolve(name) -> ActorProxy`: Resolve an existing actor by name
+
+**Recommended**: Use the returned `ActorProxy` to call methods directly; use `proxy.ref` for low-level `ask/tell`
 
 ## Functions
 
diff --git a/docs/src/api_reference.zh.md b/docs/src/api_reference.zh.md
index 188b7c07f..90910cf8b 100644
--- a/docs/src/api_reference.zh.md
+++ b/docs/src/api_reference.zh.md
@@ -176,7 +176,7 @@ class ActorSystem:
 
 ### ActorRef
 
-Actor 的引用(本地或远程)。
+Actor 的底层引用(本地或远程)。通常不需要直接使用,推荐使用 `ActorProxy`。
 
 ```python
 class ActorRef:
@@ -193,6 +193,30 @@ class ActorRef:
         pass
 ```
 
+### ActorProxy
+
+对 Actor 的高级代理封装,由 `@remote` 装饰器的 `spawn()` 和 `resolve()` 返回。
+**推荐直接使用 ActorProxy 调用方法**,无需手动构造 `Message`。
+
+```python
+class ActorProxy:
+    @property
+    def ref(self) -> ActorRef:
+        """获取底层 ActorRef(需要低级 ask/tell 时使用)"""
+        pass
+
+    # 可直接调用 actor 上的方法,例如:
+    # result = await proxy.my_method(arg1, arg2)
+```
+
+**ActorProxy vs ActorRef 对比**:
+
+| 场景 | 推荐 |
+|------|------|
+| 调用 `@remote` 类的方法 | `ActorProxy`:`await proxy.method()` |
+| 需要底层 ask/tell | `ActorRef`:`await proxy.ref.ask(msg)` |
+| 需要 actor_id | `ActorRef`:`proxy.ref.actor_id` |
+
 ## 装饰器
 
 ### @remote
@@ -259,7 +283,11 @@ result = await ask_with_timeout(ref, {"op": "compute"}, timeout=10.0)
 
 装饰后,类提供:
 
-- `spawn(**kwargs) -> ActorRef`: 创建 actor(使用 `init()` 初始化的全局系统)
+- `spawn(**kwargs) -> ActorProxy`: 创建 actor 并返回代理(使用 `init()` 初始化的全局系统)
+- `local(system, **kwargs) -> ActorProxy`: 在指定 system 上创建 actor
+- `resolve(name) -> ActorProxy`: 按名称解析已存在的 actor
+
+**推荐**:直接使用返回的 `ActorProxy` 调用方法;如需底层 `ask/tell`,使用 `proxy.ref`
 
 ## 函数
 
diff --git a/docs/src/examples/llm_inference.md b/docs/src/examples/llm_inference.md
index f09a41e95..7481f98d7 100644
--- a/docs/src/examples/llm_inference.md
+++ b/docs/src/examples/llm_inference.md
@@ -19,7 +19,7 @@ This guide shows how to run a **router + worker** LLM service with Pulsing, and
 The router needs an **actor system address** so workers can join the same cluster:
 
 ```bash
-pulsing actor pulsing.actors.router.RouterActor \
+pulsing actor pulsing.actors.Router \
   --addr 0.0.0.0:8000 \
   --http_host 0.0.0.0 \
   --http_port 8080 \
@@ -63,7 +63,7 @@ pulsing inspect actors --endpoint 127.0.0.1:8000
 ### Inspect cluster
 
 ```bash
-pulsing inspect --seeds 127.0.0.1:8000
+pulsing inspect cluster --seeds 127.0.0.1:8000
 ```
 
 ## 4) Call the OpenAI-compatible API
diff --git a/docs/src/examples/llm_inference.zh.md b/docs/src/examples/llm_inference.zh.md
index a5ef977aa..da2f4724a 100644
--- a/docs/src/examples/llm_inference.zh.md
+++ b/docs/src/examples/llm_inference.zh.md
@@ -19,7 +19,7 @@
 Router 需要指定 **actor system 地址**,以便其它进程启动的 workers 加入同一集群:
 
 ```bash
-pulsing actor pulsing.actors.router.RouterActor \
+pulsing actor pulsing.actors.Router \
   --addr 0.0.0.0:8000 \
   --http_host 0.0.0.0 \
   --http_port 8080 \
@@ -63,7 +63,7 @@ pulsing inspect actors --endpoint 127.0.0.1:8000
 ### 巡检集群
 
 ```bash
-pulsing inspect --seeds 127.0.0.1:8000
+pulsing inspect cluster --seeds 127.0.0.1:8000
 ```
 
 ## 4)调用 OpenAI 兼容 API
diff --git a/docs/src/guide/operations.md b/docs/src/guide/operations.md
index 628d69c7a..7fc18e19f 100644
--- a/docs/src/guide/operations.md
+++ b/docs/src/guide/operations.md
@@ -12,9 +12,9 @@ The `pulsing actor` command starts actors by providing their full class path. Th
 
 Actor type must be a full class path:
 - Format: `module.path.ClassName`
-- Example: `pulsing.actors.router.RouterActor`
-- Example: `pulsing.actors.worker.TransformersWorker`
-- Example: `pulsing.actors.vllm.VllmWorker`
+- Example: `pulsing.actors.Router`
+- Example: `pulsing.actors.TransformersWorker`
+- Example: `pulsing.actors.VllmWorker`
 - Example: `my_module.my_actor.MyCustomActor`
 
 ### Examples
@@ -22,13 +22,13 @@ Actor type must be a full class path:
 #### Router (OpenAI-compatible HTTP API)
 
 ```bash
-pulsing actor pulsing.actors.router.RouterActor \
+pulsing actor pulsing.actors.Router \
   --addr 0.0.0.0:8000 \
   --http_host 0.0.0.0 \
   --http_port 8080 \
   --model_name my-llm \
   --worker_name worker \
-  --scheduler stream_load
+  --scheduler_type stream_load
 ```
 
 #### Transformers Worker
@@ -69,7 +69,7 @@ pulsing actor pulsing.actors.worker.TransformersWorker \
   --seeds 127.0.0.1:8000
 
 # Router targeting specific worker name
-pulsing actor pulsing.actors.router.RouterActor \
+pulsing actor pulsing.actors.Router \
   --worker_name worker-1 \
   --seeds 127.0.0.1:8000
 ```
@@ -227,10 +227,10 @@ pulsing bench gpt2 --url http://localhost:8080
 
 | Task | Command |
 |------|---------|
-| Start router | `pulsing actor pulsing.actors.router.RouterActor --addr 0.0.0.0:8000 --http_port 8080` |
-| Start worker | `pulsing actor pulsing.actors.worker.TransformersWorker --model_name gpt2 --seeds ...` |
-| Start multiple workers | `pulsing actor pulsing.actors.worker.TransformersWorker --model_name gpt2 --name worker-1 --seeds ...` |
-| Router with custom worker | `pulsing actor pulsing.actors.router.RouterActor --worker_name worker-1 --seeds ...` |
+| Start router | `pulsing actor pulsing.actors.Router --addr 0.0.0.0:8000 --http_port 8080` |
+| Start worker | `pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --seeds ...` |
+| Start multiple workers | `pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --name worker-1 --seeds ...` |
+| Router with custom worker | `pulsing actor pulsing.actors.Router --worker_name worker-1 --seeds ...` |
 | List actors | `pulsing inspect actors --endpoint 127.0.0.1:8000` |
 | Inspect cluster | `pulsing inspect cluster --seeds 127.0.0.1:8000` |
 | Inspect actors | `pulsing inspect actors --seeds 127.0.0.1:8000 --top 10` |
diff --git a/docs/src/guide/operations.zh.md b/docs/src/guide/operations.zh.md
index 759f88257..0e19dc52d 100644
--- a/docs/src/guide/operations.zh.md
+++ b/docs/src/guide/operations.zh.md
@@ -12,9 +12,9 @@ Pulsing 内置 CLI 工具,用于启动 actors、检查系统和基准测试分
 
 Actor 类型必须是完整的类路径:
 - 格式: `module.path.ClassName`
-- 示例: `pulsing.actors.router.RouterActor`
-- 示例: `pulsing.actors.worker.TransformersWorker`
-- 示例: `pulsing.actors.vllm.VllmWorker`
+- 示例: `pulsing.actors.Router`
+- 示例: `pulsing.actors.TransformersWorker`
+- 示例: `pulsing.actors.VllmWorker`
 - 示例: `my_module.my_actor.MyCustomActor`
 
 ### 示例
@@ -22,13 +22,13 @@ Actor 类型必须是完整的类路径:
 #### Router(OpenAI 兼容 HTTP API)
 
 ```bash
-pulsing actor pulsing.actors.router.RouterActor \
+pulsing actor pulsing.actors.Router \
   --addr 0.0.0.0:8000 \
   --http_host 0.0.0.0 \
   --http_port 8080 \
   --model_name my-llm \
   --worker_name worker \
-  --scheduler stream_load
+  --scheduler_type stream_load
 ```
 
 #### Transformers Worker
@@ -69,7 +69,7 @@ pulsing actor pulsing.actors.worker.TransformersWorker \
   --seeds 127.0.0.1:8000
 
 # Router 路由到特定 worker 名称
-pulsing actor pulsing.actors.router.RouterActor \
+pulsing actor pulsing.actors.Router \
   --worker_name worker-1 \
   --seeds 127.0.0.1:8000
 ```
@@ -209,10 +209,10 @@ pulsing bench gpt2 --url http://localhost:8080
 
 | 任务 | 命令 |
 |------|------|
-| 启动 router | `pulsing actor pulsing.actors.router.RouterActor --addr 0.0.0.0:8000 --http_port 8080` |
-| 启动 worker | `pulsing actor pulsing.actors.worker.TransformersWorker --model_name gpt2 --seeds ...` |
-| 启动多个 worker | `pulsing actor pulsing.actors.worker.TransformersWorker --model_name gpt2 --name worker-1 --seeds ...` |
-| Router 指定 worker | `pulsing actor pulsing.actors.router.RouterActor --worker_name worker-1 --seeds ...` |
+| 启动 router | `pulsing actor pulsing.actors.Router --addr 0.0.0.0:8000 --http_port 8080` |
+| 启动 worker | `pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --seeds ...` |
+| 启动多个 worker | `pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --name worker-1 --seeds ...` |
+| Router 指定 worker | `pulsing actor pulsing.actors.Router --worker_name worker-1 --seeds ...` |
 | 列出 actors | `pulsing inspect actors --endpoint 127.0.0.1:8000` |
 | 检查集群 | `pulsing inspect cluster --seeds 127.0.0.1:8000` |
 | 检查 actors | `pulsing inspect actors --seeds 127.0.0.1:8000 --top 10` |
diff --git a/docs/src/guide/style.md b/docs/src/guide/style.md
new file mode 100644
index 000000000..3095c937f
--- /dev/null
+++ b/docs/src/guide/style.md
@@ -0,0 +1,69 @@
+# Terminology & Style Guide
+
+This page defines terminology and style conventions for Pulsing documentation and code to ensure consistency.
+
+## Core Terminology
+
+| Term | Usage | Description |
+|------|-------|-------------|
+| `ActorSystem` | Code symbol | Rust/Python class name, use in code references |
+| actor system | Conceptual | General description, lowercase |
+| `Actor` | Code symbol | Base class name |
+| actor | Conceptual | General description |
+| `ActorRef` | Code symbol | Low-level actor reference |
+| `ActorProxy` | Code symbol | High-level proxy returned by `@remote` decorator |
+
+## Component Naming
+
+| Component | CLI Actor Class Path | Description |
+|-----------|---------------------|-------------|
+| Router | `pulsing.actors.Router` | OpenAI-compatible HTTP router |
+| TransformersWorker | `pulsing.actors.TransformersWorker` | Transformers inference worker |
+| VllmWorker | `pulsing.actors.VllmWorker` | vLLM inference worker |
+
+**Note**: When documentation mentions "Router", it typically refers to the HTTP routing component for LLM inference services. Example code requiring task dispatch logic should use names like `Dispatcher` to avoid confusion.
+
+## CLI Command Format
+
+### Starting Actors
+
+```bash
+pulsing actor  [options]
+
+# Examples
+pulsing actor pulsing.actors.Router --http_port 8080 --model_name my-llm
+pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --device cpu
+```
+
+### Inspect Commands (Observer Mode)
+
+`pulsing inspect` uses a subcommand structure:
+
+```bash
+# Cluster status
+pulsing inspect cluster --seeds 
+ +# Actor distribution +pulsing inspect actors --seeds
[--top N] [--filter ...] +pulsing inspect actors --endpoint
[--detailed] + +# Metrics +pulsing inspect metrics --seeds
[--raw] + +# Live watch +pulsing inspect watch --seeds
[--interval ...] [--kind ...] +``` + +**Removed**: `pulsing actor list` → Use `pulsing inspect actors` instead + +## Code Style + +- **Python**: Follow PEP 8, prefer type annotations +- **Rust**: Use `cargo fmt` and `cargo clippy` +- **Documentation**: Bilingual (`.zh.md` suffix for Chinese) + +## Documentation References + +- Use backticks for code symbols: `` `ActorSystem` `` +- Use code blocks for commands +- Use backticks for file paths: `` `python/pulsing/actor/` `` diff --git a/docs/src/guide/style.zh.md b/docs/src/guide/style.zh.md new file mode 100644 index 000000000..b8ec752cf --- /dev/null +++ b/docs/src/guide/style.zh.md @@ -0,0 +1,69 @@ +# 术语与风格约定 + +本页定义 Pulsing 文档和代码中的术语与风格规范,确保一致性。 + +## 核心术语 + +| 术语 | 用法 | 说明 | +|------|------|------| +| `ActorSystem` | 代码符号 | Rust/Python 类名,用于代码引用 | +| actor system | 概念描述 | 一般性描述时使用小写 | +| `Actor` | 代码符号 | 基类名 | +| actor | 概念描述 | 一般性描述 | +| `ActorRef` | 代码符号 | 底层 actor 引用 | +| `ActorProxy` | 代码符号 | `@remote` 装饰器返回的高级代理 | + +## 组件命名 + +| 组件 | CLI actor 类路径 | 说明 | +|------|------------------|------| +| Router | `pulsing.actors.Router` | OpenAI 兼容 HTTP 路由 | +| TransformersWorker | `pulsing.actors.TransformersWorker` | Transformers 推理 Worker | +| VllmWorker | `pulsing.actors.VllmWorker` | vLLM 推理 Worker | + +**注意**:文档中提到"Router"时,通常指 LLM 推理服务的 HTTP 路由组件。示例代码中若需要任务分发逻辑,应使用 `Dispatcher` 等名称以避免混淆。 + +## CLI 命令格式 + +### 启动 Actor + +```bash +pulsing actor <完整类路径> [选项] + +# 示例 +pulsing actor pulsing.actors.Router --http_port 8080 --model_name my-llm +pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --device cpu +``` + +### 检查命令(观察者模式) + +`pulsing inspect` 使用子命令结构: + +```bash +# 集群状态 +pulsing inspect cluster --seeds <地址> + +# Actor 分布 +pulsing inspect actors --seeds <地址> [--top N] [--filter ...] +pulsing inspect actors --endpoint <地址> [--detailed] + +# 指标 +pulsing inspect metrics --seeds <地址> [--raw] + +# 实时监视 +pulsing inspect watch --seeds <地址> [--interval ...] [--kind ...] +``` + +**已移除**:`pulsing actor list` → 请使用 `pulsing inspect actors` + +## 代码风格 + +- **Python**:遵循 PEP 8,优先使用类型注解 +- **Rust**:使用 `cargo fmt` 和 `cargo clippy` +- **文档**:中英双语(`.zh.md` 为中文版) + +## 文档引用 + +- 代码符号使用反引号:`` `ActorSystem` `` +- 命令使用代码块 +- 文件路径使用反引号:`` `python/pulsing/actor/` `` diff --git a/docs/src/quickstart/llm_inference.md b/docs/src/quickstart/llm_inference.md index 61137d7a0..99a49f6d3 100644 --- a/docs/src/quickstart/llm_inference.md +++ b/docs/src/quickstart/llm_inference.md @@ -43,7 +43,7 @@ Choose a backend: Open **Terminal A**: ```bash -pulsing actor router \ +pulsing actor pulsing.actors.Router \ --addr 0.0.0.0:8000 \ --http_port 8080 \ --model_name my-llm @@ -64,8 +64,8 @@ Open **Terminal B**: === "Transformers (CPU)" ```bash - pulsing actor transformers \ - --model gpt2 \ + pulsing actor pulsing.actors.TransformersWorker \ + --model_name gpt2 \ --device cpu \ --addr 0.0.0.0:8001 \ --seeds 127.0.0.1:8000 @@ -74,7 +74,7 @@ Open **Terminal B**: === "vLLM (GPU)" ```bash - pulsing actor vllm \ + pulsing actor pulsing.actors.VllmWorker \ --model Qwen/Qwen2.5-0.5B \ --addr 0.0.0.0:8002 \ --seeds 127.0.0.1:8000 @@ -82,7 +82,7 @@ Open **Terminal B**: | Flag | Description | |------|-------------| -| `--model` | Model name/path | +| `--model` / `--model_name` | Model name/path (TransformersWorker uses `--model_name`, VllmWorker uses `--model`) | | `--seeds` | Router address to join cluster | --- @@ -91,10 +91,10 @@ Open **Terminal B**: ```bash # List actors -pulsing actor list --endpoint 127.0.0.1:8000 +pulsing inspect actors --endpoint 127.0.0.1:8000 # Inspect cluster state -pulsing inspect --seeds 127.0.0.1:8000 +pulsing inspect cluster --seeds 127.0.0.1:8000 ``` You should see the `router` and `worker` actors. @@ -135,10 +135,10 @@ Add more workers to handle more load: ```bash # Terminal C -pulsing actor transformers --model gpt2 --addr 0.0.0.0:8003 --seeds 127.0.0.1:8000 +pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --addr 0.0.0.0:8003 --seeds 127.0.0.1:8000 # Terminal D -pulsing actor transformers --model gpt2 --addr 0.0.0.0:8004 --seeds 127.0.0.1:8000 +pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --addr 0.0.0.0:8004 --seeds 127.0.0.1:8000 ``` The Router automatically load-balances across all workers. diff --git a/docs/src/quickstart/llm_inference.zh.md b/docs/src/quickstart/llm_inference.zh.md index 3626c93ab..d42dd2982 100644 --- a/docs/src/quickstart/llm_inference.zh.md +++ b/docs/src/quickstart/llm_inference.zh.md @@ -43,7 +43,7 @@ pip install pulsing 打开**终端 A**: ```bash -pulsing actor router \ +pulsing actor pulsing.actors.Router \ --addr 0.0.0.0:8000 \ --http_port 8080 \ --model_name my-llm @@ -64,8 +64,8 @@ pulsing actor router \ === "Transformers (CPU)" ```bash - pulsing actor transformers \ - --model gpt2 \ + pulsing actor pulsing.actors.TransformersWorker \ + --model_name gpt2 \ --device cpu \ --addr 0.0.0.0:8001 \ --seeds 127.0.0.1:8000 @@ -74,7 +74,7 @@ pulsing actor router \ === "vLLM (GPU)" ```bash - pulsing actor vllm \ + pulsing actor pulsing.actors.VllmWorker \ --model Qwen/Qwen2.5-0.5B \ --addr 0.0.0.0:8002 \ --seeds 127.0.0.1:8000 @@ -82,7 +82,7 @@ pulsing actor router \ | 参数 | 说明 | |------|------| -| `--model` | 模型名称/路径 | +| `--model` / `--model_name` | 模型名称/路径(TransformersWorker 用 `--model_name`,VllmWorker 用 `--model`) | | `--seeds` | 加入集群的 Router 地址 | --- @@ -91,10 +91,10 @@ pulsing actor router \ ```bash # 列出 actor -pulsing actor list --endpoint 127.0.0.1:8000 +pulsing inspect actors --endpoint 127.0.0.1:8000 # 检查集群状态 -pulsing inspect --seeds 127.0.0.1:8000 +pulsing inspect cluster --seeds 127.0.0.1:8000 ``` 你应该能看到 `router` 和 `worker` actor。 @@ -135,10 +135,10 @@ curl -N http://localhost:8080/v1/chat/completions \ ```bash # 终端 C -pulsing actor transformers --model gpt2 --addr 0.0.0.0:8003 --seeds 127.0.0.1:8000 +pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --addr 0.0.0.0:8003 --seeds 127.0.0.1:8000 # 终端 D -pulsing actor transformers --model gpt2 --addr 0.0.0.0:8004 --seeds 127.0.0.1:8000 +pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --addr 0.0.0.0:8004 --seeds 127.0.0.1:8000 ``` Router 会自动在所有 Worker 间负载均衡。 diff --git a/examples/bash/README.md b/examples/bash/README.md index 3bf59e9bf..a0083196a 100644 --- a/examples/bash/README.md +++ b/examples/bash/README.md @@ -2,90 +2,27 @@ 这个目录包含用于测试和演示 Pulsing 功能的 Bash 脚本。 -## 脚本列表 +## 当前状态 -### `demo_actor_list.sh` +此目录暂无活跃的演示脚本。 -演示如何在应用中使用 actor list 功能查看当前运行的 actors。 - -**功能演示:** -- 在应用启动后查看 actor 列表 -- 默认模式:只显示用户创建的 actors -- `--all_actors` 模式:显示所有 actors(包括系统内部 actors) -- JSON 输出格式 -- 底层 API 使用(`system.local_actor_names()`) - -**使用方法:** +如需查看/管理 actors,请使用 `pulsing inspect` 命令(观察者模式,不加入 gossip 集群): ```bash -cd examples/bash -./demo_actor_list.sh -``` +# 查询单个节点的 actors +pulsing inspect actors --endpoint 127.0.0.1:8000 -或从项目根目录: - -```bash -bash examples/bash/demo_actor_list.sh -``` +# 查询整个集群的 actors +pulsing inspect actors --seeds 127.0.0.1:8000 -**输出示例:** +# 查看集群状态 +pulsing inspect cluster --seeds 127.0.0.1:8000 +# 实时监视 +pulsing inspect watch --seeds 127.0.0.1:8000 ``` -====================================================================== - Pulsing Actor List 演示 -====================================================================== - -Python: Python 3.12.11 - -运行演示... - -================================================================================ -演示:在应用中使用 pulsing actor list -================================================================================ - -1. 初始化 actor system... - ✓ 系统启动: 0.0.0.0:49724 - -2. 创建业务 actors... - ✓ 创建了 3 个 actors - -3. 使用 Python API 查看 actors: - ---------------------------------------------------------------------------- - 本地 actors: calculator, counter-1, counter-2 -4. 使用 CLI 格式化输出(只显示用户 actors): - ---------------------------------------------------------------------------- -Name Type Uptime Code Path ------------------------------------------------------------------------------------------------------------ -counter-1 user 0s - -counter-2 user 0s - -calculator user 0s - - -Total: 3 actor(s) - -... -``` - -**重要说明:** - -`pulsing actor list` 是设计用于在**运行中的应用进程内**调用的管理功能,而不是独立的命令行工具。这是因为: - -1. Actor system 是进程本地的,需要在同一进程中才能访问 -2. 这种设计更适合集成到应用的管理接口中 -3. 对于外部查看远程集群,应使用 `pulsing inspect --seeds
` - -**在应用中集成:** - -```python -from pulsing.actor import init, get_system -from pulsing.cli.actor_list import list_actors_impl - -await init() -# ... 创建 actors ... - -# 在管理端点或 REPL 中调用 -await list_actors_impl(all_actors=False, output_format='table') -``` +更多 CLI 用法参见 [CLI 命令文档](../../docs/src/guide/operations.zh.md)。 ## 环境要求 diff --git a/examples/bash/demo_actor_list.sh b/examples/bash/demo_actor_list.sh deleted file mode 100755 index 0850dd6a8..000000000 --- a/examples/bash/demo_actor_list.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# 演示 pulsing actor list 命令 -# 使用简单的 HTTP API 查询,不加入 gossip 集群 - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -export PYTHONPATH="$PROJECT_ROOT/python:$PYTHONPATH" - -# 完全禁用 Rust 日志 -export RUST_LOG=off - -# 颜色 -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo "======================================================================" -echo " Pulsing Actor List - 演示" -echo "======================================================================" -echo "" - -# 检查 pyenv -if ! command -v pyenv &> /dev/null; then - echo "错误: 需要 pyenv" - exit 1 -fi - -PYTHON="pyenv exec python" - -# 清理可能残留的进程 -echo -e "${YELLOW}清理残留进程...${NC}" -pkill -f "pulsing_server" 2>/dev/null || true -sleep 1 - -# 使用随机端口避免冲突 -PORT=$((19000 + RANDOM % 1000)) - -# 创建一个临时服务端脚本 -SERVER_SCRIPT=$(mktemp /tmp/pulsing_server_XXXXXX.py) - -cat > "$SERVER_SCRIPT" << EOF -import asyncio -import os -import sys - -# 禁用所有日志 -os.environ["RUST_LOG"] = "off" - -from pulsing.actor import init, remote, get_system - - -@remote -class Counter: - def __init__(self): - self.count = 0 - - def increment(self): - self.count += 1 - return self.count - - -@remote -class Calculator: - def add(self, a, b): - return a + b - - -async def main(): - await init(addr="127.0.0.1:${PORT}") - system = get_system() - - await Counter.remote(system, name="counter-1") - await Counter.remote(system, name="counter-2") - await Calculator.remote(system, name="calculator") - - # Signal ready - print("READY", flush=True) - - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(main()) -EOF - -echo -e "${GREEN}1. 启动 Actor System (127.0.0.1:${PORT})${NC}" - -# 启动服务端(后台运行,完全静默) -$PYTHON "$SERVER_SCRIPT" > /dev/null 2>&1 & -SERVER_PID=$! - -# 等待服务就绪 -echo " 等待服务启动..." -sleep 3 - -echo "" -echo -e "${GREEN}2. 测试连接单个 endpoint (HTTP API)${NC}" -echo " 命令: pulsing actor list --endpoint 127.0.0.1:${PORT}" -echo "" - -$PYTHON -m pulsing.cli actor list --endpoint 127.0.0.1:${PORT} - -echo "" -echo -e "${GREEN}3. 显示所有 actors (包括内部)${NC}" -echo " 命令: pulsing actor list --endpoint 127.0.0.1:${PORT} --all_actors True" -echo "" - -$PYTHON -m pulsing.cli actor list --endpoint 127.0.0.1:${PORT} --all_actors True - -echo "" -echo -e "${GREEN}4. JSON 格式输出${NC}" -echo " 命令: pulsing actor list --endpoint 127.0.0.1:${PORT} --json True" -echo "" - -$PYTHON -m pulsing.cli actor list --endpoint 127.0.0.1:${PORT} --json True - -echo "" -echo -e "${GREEN}5. 使用 --seeds 查询集群${NC}" -echo " 命令: pulsing actor list --seeds 127.0.0.1:${PORT}" -echo "" - -$PYTHON -m pulsing.cli actor list --seeds 127.0.0.1:${PORT} - -# 清理 -echo "" -echo -e "${GREEN}清理...${NC}" -kill $SERVER_PID 2>/dev/null || true -wait $SERVER_PID 2>/dev/null || true -rm -f "$SERVER_SCRIPT" - -echo "" -echo "======================================================================" -echo " 演示完成" -echo "======================================================================" -echo "" -echo "用法总结:" -echo "" -echo -e " ${BLUE}# 查询单个 actor system${NC}" -echo " pulsing actor list --endpoint 127.0.0.1:8000" -echo "" -echo -e " ${BLUE}# 查询整个集群${NC}" -echo " pulsing actor list --seeds 127.0.0.1:8000,127.0.0.1:8001" -echo "" -echo -e " ${BLUE}# 显示所有 actors (包括内部)${NC}" -echo " pulsing actor list --endpoint 127.0.0.1:8000 --all_actors True" -echo "" -echo -e " ${BLUE}# JSON 格式输出${NC}" -echo " pulsing actor list --endpoint 127.0.0.1:8000 --json True" -echo "" -echo -e "${GREEN}✓ 使用简单 HTTP API,不加入 gossip 集群${NC}" -echo -e "${GREEN}✓ 支持显示完整 Python 元信息: 类名、模块、代码路径、Actor ID${NC}" -echo "" diff --git a/examples/bash/demo_actor_list_remote.sh b/examples/bash/demo_actor_list_remote.sh deleted file mode 100644 index 5b9bb0deb..000000000 --- a/examples/bash/demo_actor_list_remote.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bash -# 演示 pulsing actor list 的远程查询功能 - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -export PYTHONPATH="$PROJECT_ROOT/python:$PYTHONPATH" - -# 颜色 -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo "======================================================================" -echo " Pulsing Actor List - 远程查询演示" -echo "======================================================================" -echo "" - -# 检查 pyenv -if ! command -v pyenv &> /dev/null; then - echo "错误: 需要 pyenv" - exit 1 -fi - -PYTHON="pyenv exec python" - -# 创建两个节点的应用 -NODE1_SCRIPT=$(mktemp /tmp/pulsing_node1.XXXXXX.py) -NODE2_SCRIPT=$(mktemp /tmp/pulsing_node2.XXXXXX.py) - -cat > "$NODE1_SCRIPT" << 'EOF' -import asyncio -from pulsing.actor import init, remote, get_system - - -@remote -class ServiceA: - def ping(self): - return "pong from A" - - -async def main(): - await init(addr="127.0.0.1:9001") - system = get_system() - print(f"Node 1 started: {system.addr}") - - # Create actors - await ServiceA.remote(system, name="service-a-1") - await ServiceA.remote(system, name="service-a-2") - print("Created 2 actors on Node 1") - - # Keep running - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(main()) -EOF - -cat > "$NODE2_SCRIPT" << 'EOF' -import asyncio -from pulsing.actor import SystemConfig, create_actor_system, remote - - -@remote -class ServiceB: - def process(self, data): - return f"processed: {data}" - - -async def main(): - config = SystemConfig.with_addr("127.0.0.1:9002").with_seeds(["127.0.0.1:9001"]) - system = await create_actor_system(config) - print(f"Node 2 started: {system.addr}, joining cluster...") - - await asyncio.sleep(1) - - # Create actors - await ServiceB.remote(system, name="service-b-1") - await ServiceB.remote(system, name="service-b-2") - await ServiceB.remote(system, name="service-b-3") - print("Created 3 actors on Node 2") - - # Keep running - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(main()) -EOF - -echo -e "${GREEN}场景: 查询远程多节点集群${NC}" -echo "================================================================" -echo "" - -# 启动节点1 -echo "1. 启动 Node 1 (127.0.0.1:9001)..." -$PYTHON "$NODE1_SCRIPT" 2>&1 | grep -v "INFO" & -NODE1_PID=$! -sleep 2 - -# 启动节点2 -echo "2. 启动 Node 2 (127.0.0.1:9002), 加入集群..." -$PYTHON "$NODE2_SCRIPT" 2>&1 | grep -v "INFO" & -NODE2_PID=$! -sleep 3 - -echo "" -echo -e "${BLUE}3. 使用 pulsing actor list 查询远程集群${NC}" -echo " 命令: pulsing actor list --seeds '127.0.0.1:9001'" -echo "" - -# 使用我们实现的功能查询集群 -$PYTHON -c " -from pulsing.cli.actor_list import list_actors_command -list_actors_command( - all_actors=False, - json_output=False, - seeds='127.0.0.1:9001' -) -" 2>&1 | grep -v "INFO" - -echo "" -echo -e "${BLUE}4. 查询特定节点 (如果实现了 node_id 参数)${NC}" -echo " 这需要先知道 node_id,可以从上面的输出获取" -echo "" - -# 清理 -echo -e "${GREEN}清理...${NC}" -kill $NODE1_PID $NODE2_PID 2>/dev/null || true -wait $NODE1_PID $NODE2_PID 2>/dev/null || true -rm -f "$NODE1_SCRIPT" "$NODE2_SCRIPT" - -echo "" -echo "======================================================================" -echo " 演示完成" -echo "======================================================================" -echo "" -echo "总结:" -echo " ✓ 可以通过 --seeds 参数连接远程集群" -echo " ✓ 自动查询集群中所有节点的 actors" -echo " ✓ 显示每个节点的 actor 列表" -echo "" diff --git a/examples/inspect/demo_service.py b/examples/inspect/demo_service.py index 348645b1a..57c011d72 100644 --- a/examples/inspect/demo_service.py +++ b/examples/inspect/demo_service.py @@ -50,29 +50,29 @@ def receive(self, msg: Message) -> Message: return Message.empty() -class RouterActor(Actor): - """A router actor that distributes tasks to workers""" +class DispatcherActor(Actor): + """A dispatcher actor that distributes tasks to workers (for demo purposes)""" def __init__(self): self.workers = [] - self.tasks_routed = 0 + self.tasks_dispatched = 0 def on_start(self, actor_id: ActorId): - print("[Router] Started") + print("[Dispatcher] Started") def receive(self, msg: Message) -> Message: if msg.msg_type == "RouteTask": - self.tasks_routed += 1 + self.tasks_dispatched += 1 task = msg.to_json().get("task", "") # Simulate routing logic worker_id = f"worker-{random.randint(1, 3)}" return Message.from_json( - "Routed", - {"task": task, "worker": worker_id, "routed": self.tasks_routed}, + "Dispatched", + {"task": task, "worker": worker_id, "dispatched": self.tasks_dispatched}, ) elif msg.msg_type == "GetStats": return Message.from_json( - "Stats", {"router": True, "tasks_routed": self.tasks_routed} + "Stats", {"dispatcher": True, "tasks_dispatched": self.tasks_dispatched} ) return Message.empty() @@ -120,10 +120,10 @@ async def run_node(port: int, seed: str | None): # Create different actors based on node role if seed is None: - # Node 1: Create router and some workers + # Node 1: Create dispatcher and some workers print("Creating actors on node 1...") - await system.spawn("router", RouterActor(), public=True) - print(" ✓ actors/router") + await system.spawn("dispatcher", DispatcherActor(), public=True) + print(" ✓ actors/dispatcher") for i in range(1, 3): worker_name = f"worker-{i}" diff --git a/python/pulsing/actor/helpers.py b/python/pulsing/actor/helpers.py index 573486c1a..ee33bab92 100644 --- a/python/pulsing/actor/helpers.py +++ b/python/pulsing/actor/helpers.py @@ -9,18 +9,18 @@ from . import Actor, ActorSystem -async def run_until_signal( - system: "ActorSystem", actor_name: str | None = None -) -> None: +async def run_until_signal(actor_name: str | None = None) -> None: """ Run until shutdown signal (SIGTERM or SIGINT) Handles graceful shutdown on first signal, force quits on second signal. + Uses the global system via shutdown() to ensure proper cleanup. Args: - system: ActorSystem instance actor_name: Optional actor name for logging """ + from . import get_system, shutdown + shutdown_event = asyncio.Event() shutting_down = False @@ -43,13 +43,15 @@ def signal_handler(): # Perform graceful shutdown try: + system = get_system() if actor_name: await system.stop(actor_name) except Exception as e: print(f"[{actor_name or 'Actor'}] Stop error: {e}") + # Use module-level shutdown() to properly clear global state try: - await system.shutdown() + await shutdown() except Exception as e: print(f"[{actor_name or 'Actor'}] Shutdown error: {e}") @@ -64,7 +66,10 @@ async def spawn_and_run( public: bool = True, ) -> None: """ - Create ActorSystem, spawn actor, and run until signal + Create ActorSystem via init(), spawn actor, and run until signal + + This function uses init() to ensure the global system is set, + making get_system() available inside actor on_start()/receive(). Args: actor: Actor instance @@ -73,14 +78,11 @@ async def spawn_and_run( seeds: List of seed node addresses for cluster discovery public: Whether to register as public named actor """ - from . import SystemConfig, create_actor_system - - config = SystemConfig.with_addr(addr) if addr else SystemConfig.standalone() - if seeds: - config = config.with_seeds(seeds) + from . import get_system, init - system = await create_actor_system(config) + # Use init() to set global system (makes get_system() work inside actors) + system = await init(addr=addr, seeds=seeds) await system.spawn(name, actor, public=public) print(f"[{name}] Started at {system.addr}") - await run_until_signal(system, name) + await run_until_signal(name) diff --git a/python/pulsing/actors/__init__.py b/python/pulsing/actors/__init__.py index 925c7bb72..d7b40bf56 100644 --- a/python/pulsing/actors/__init__.py +++ b/python/pulsing/actors/__init__.py @@ -4,7 +4,7 @@ # Router # Stream load subscription from .load_stream import LoadSnapshot, LoadStreamConsumer, StreamLoadScheduler -from .router import start_router, stop_router +from .router import Router, start_router, stop_router # Scheduler from .scheduler import ( # Base class; Python schedulers; Rust high-performance schedulers; Factory function @@ -29,6 +29,7 @@ __all__ = [ # Core API + "Router", "TransformersWorker", "VllmWorker", "GenerationConfig", diff --git a/python/pulsing/actors/router.py b/python/pulsing/actors/router.py index 0045f9428..952e84f6a 100644 --- a/python/pulsing/actors/router.py +++ b/python/pulsing/actors/router.py @@ -1,5 +1,6 @@ """Router - OpenAI-compatible HTTP API router""" +import asyncio import json import time import uuid @@ -7,7 +8,7 @@ from aiohttp import web -from pulsing.actor import ActorSystem, Message +from pulsing.actor import Actor, ActorId, ActorSystem, Message, get_system @dataclass @@ -427,3 +428,119 @@ async def stop_router(runner: web.AppRunner): await runner.cleanup() print("[Router] HTTP server stopped") + + +class Router(Actor): + """Router Actor - OpenAI-compatible HTTP API router as an Actor + + This actor wraps the start_router/stop_router functions to provide + a CLI-compatible entry point via `pulsing actor pulsing.actors.Router`. + + Args: + http_host: HTTP listen address (default: "0.0.0.0") + http_port: HTTP listen port (default: 8080) + model_name: Model name for API responses (default: "pulsing-model") + worker_name: Worker actor name to route requests to (default: "worker") + scheduler_type: Scheduler type, supports: + - "stream_load": Stream load-aware (default, recommended) + - "random": Random + - "round_robin": Round robin + - "power_of_two": Power-of-Two Choices + - "cache_aware": Cache-aware + + Example: + # Start via CLI + pulsing actor pulsing.actors.Router \\ + --http_host 0.0.0.0 \\ + --http_port 8080 \\ + --model_name my-llm \\ + --worker_name worker + + # Or programmatically + router = Router(http_port=8080, model_name="my-llm") + await system.spawn("router", router, public=True) + """ + + def __init__( + self, + http_host: str = "0.0.0.0", + http_port: int = 8080, + model_name: str = "pulsing-model", + worker_name: str = "worker", + scheduler_type: str = "stream_load", + ): + self.http_host = http_host + self.http_port = http_port + self.model_name = model_name + self.worker_name = worker_name + self.scheduler_type = scheduler_type + + self._runner: web.AppRunner | None = None + self._actor_id: ActorId | None = None + + async def on_start(self, actor_id: ActorId) -> None: + """Start the HTTP server when actor starts""" + self._actor_id = actor_id + + # Get global system (set by CLI via init()) + system = get_system() + + # Start HTTP server + self._runner = await start_router( + system=system, + http_host=self.http_host, + http_port=self.http_port, + model_name=self.model_name, + worker_name=self.worker_name, + scheduler_type=self.scheduler_type, + ) + + print(f"[Router] Actor started: {actor_id}") + + def on_stop(self) -> None: + """Stop the HTTP server when actor stops""" + if self._runner: + # Schedule cleanup in background (on_stop is sync) + asyncio.create_task(self._cleanup()) + + async def _cleanup(self): + """Async cleanup helper""" + if self._runner: + await stop_router(self._runner) + self._runner = None + + def metadata(self) -> dict[str, str]: + """Return router metadata for diagnostics""" + return { + "type": "router", + "http_host": self.http_host, + "http_port": str(self.http_port), + "model_name": self.model_name, + "worker_name": self.worker_name, + "scheduler_type": self.scheduler_type, + } + + async def receive(self, msg: Message) -> Message | None: + """Handle diagnostic messages""" + if msg.msg_type == "HealthCheck": + return Message.from_json( + "Ok", + { + "status": "healthy", + "http_port": self.http_port, + "model_name": self.model_name, + }, + ) + elif msg.msg_type == "GetConfig": + return Message.from_json( + "Config", + { + "http_host": self.http_host, + "http_port": self.http_port, + "model_name": self.model_name, + "worker_name": self.worker_name, + "scheduler_type": self.scheduler_type, + }, + ) + else: + return Message.from_json("Error", {"error": f"Unknown: {msg.msg_type}"}) diff --git a/python/pulsing/cli/__main__.py b/python/pulsing/cli/__main__.py index ace91c6f6..1b32f4514 100644 --- a/python/pulsing/cli/__main__.py +++ b/python/pulsing/cli/__main__.py @@ -18,9 +18,9 @@ def actor( Actor type must be a full class path: - Format: 'module.path.ClassName' - - Example: 'pulsing.actors.router.RouterActor' - - Example: 'pulsing.actors.worker.TransformersWorker' - - Example: 'pulsing.actors.vllm.VllmWorker' + - Example: 'pulsing.actors.Router' + - Example: 'pulsing.actors.TransformersWorker' + - Example: 'pulsing.actors.VllmWorker' - Example: 'my_module.my_actor.MyCustomActor' Pass constructor parameters directly as command-line arguments. @@ -38,17 +38,17 @@ def actor( Examples: # Start a Transformers worker - pulsing actor pulsing.actors.worker.TransformersWorker --model_name gpt2 --device cpu --name my-worker + pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --device cpu --name my-worker # Start a vLLM worker - pulsing actor pulsing.actors.vllm.VllmWorker --model Qwen/Qwen2 --role aggregated --max_new_tokens 512 --name vllm-worker + pulsing actor pulsing.actors.VllmWorker --model Qwen/Qwen2 --role aggregated --max_new_tokens 512 --name vllm-worker # Start a Router with OpenAI-compatible API - pulsing actor pulsing.actors.router.RouterActor --http_host 0.0.0.0 --http_port 8080 --model_name my-llm --worker_name worker + pulsing actor pulsing.actors.Router --http_host 0.0.0.0 --http_port 8080 --model_name my-llm --worker_name worker # Start multiple workers with different names - pulsing actor pulsing.actors.worker.TransformersWorker --model_name gpt2 --name worker-1 --seeds 127.0.0.1:8000 - pulsing actor pulsing.actors.worker.TransformersWorker --model_name gpt2 --name worker-2 --seeds 127.0.0.1:8000 + pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --name worker-1 --seeds 127.0.0.1:8000 + pulsing actor pulsing.actors.TransformersWorker --model_name gpt2 --name worker-2 --seeds 127.0.0.1:8000 """ from .actors import start_generic_actor diff --git a/test_actor_list_integration.py b/test_actor_list_integration.py deleted file mode 100644 index 83341e463..000000000 --- a/test_actor_list_integration.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -"""Integration test for pulsing actor list command""" - -import asyncio -import subprocess -import time -from pulsing.actor import init, remote - - -@remote -class Counter: - def __init__(self): - self.count = 0 - - -@remote -class Calculator: - def add(self, a, b): - return a + b - - -async def main(): - # Start system - await init(addr="0.0.0.0:8888") - from pulsing.actor import get_system - - system = get_system() - print("✓ Actor system started on 0.0.0.0:8888") - - # Create actors - await Counter.remote(system, name="counter-1") - await Counter.remote(system, name="counter-2") - await Calculator.remote(system, name="calculator") - print("✓ Created 3 actors") - - # Wait a bit for actors to fully initialize - await asyncio.sleep(0.5) - - # Run list command (subprocess in same PYTHONPATH) - result = subprocess.run( - ["python", "-m", "pulsing.cli", "actor", "list"], capture_output=True, text=True - ) - - print("\n--- Output from 'pulsing actor list' ---") - print(result.stdout) - - if result.returncode != 0: - print("STDERR:") - print(result.stderr) - - # Test --all flag - result_all = subprocess.run( - ["python", "-m", "pulsing.cli", "actor", "list", "--all_actors", "True"], - capture_output=True, - text=True, - ) - - print("\n--- Output from 'pulsing actor list --all_actors True' ---") - print(result_all.stdout) - - # Test JSON output - result_json = subprocess.run( - ["python", "-m", "pulsing.cli", "actor", "list", "--json", "True"], - capture_output=True, - text=True, - ) - - print("\n--- Output from 'pulsing actor list --json True' ---") - print(result_json.stdout) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_actor_list_same_process.py b/test_actor_list_same_process.py deleted file mode 100644 index 0a2b8258e..000000000 --- a/test_actor_list_same_process.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 -"""Test pulsing actor list in same process""" - -import asyncio -import sys -import os - -# Ensure we're using the local pulsing -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "python")) - -from pulsing.actor import init, remote, get_system -from pulsing.admin import list_actors - - -@remote -class Counter: - def __init__(self): - self.count = 0 - - -@remote -class Calculator: - def add(self, a, b): - return a + b - - -async def main(): - # Start system - await init(addr="0.0.0.0:8888") - system = get_system() - print("✓ Actor system started on 0.0.0.0:8888\n") - - # Create actors - await Counter.remote(system, name="counter-1") - await Counter.remote(system, name="counter-2") - await Calculator.remote(system, name="calculator") - print("✓ Created 3 actors\n") - - # List actors directly (Python API) - print("=== Using Python API (pulsing.admin.list_actors) ===\n") - - # Get all local actor names - names = system.local_actor_names() - print(f"All local actor names: {names}\n") - - # Filter to user actors - user_actors = [n for n in names if not n.startswith("_")] - print(f"User actors: {user_actors}\n") - - print("=== Formatted list ===\n") - print(f"{'Name':<30} {'Type':<20}") - print("-" * 50) - for name in user_actors: - actor_type = "user" - print(f"{name:<30} {actor_type:<20}") - - print(f"\nTotal: {len(user_actors)} actor(s)") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/test_actor_system.py b/test_actor_system.py deleted file mode 100644 index d207f976e..000000000 --- a/test_actor_system.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""Test script to run a system with actors for testing 'pulsing actor list'""" - -import asyncio -from pulsing.actor import init, remote - - -@remote -class Counter: - def __init__(self): - self.count = 0 - - def increment(self): - self.count += 1 - return self.count - - -@remote -class Calculator: - def add(self, a, b): - return a + b - - -async def main(): - await init(addr="0.0.0.0:8888") - print("Actor system started on 0.0.0.0:8888") - - # Create some named actors - _counter1 = await Counter.remote(name="counter-1") - _counter2 = await Counter.remote(name="counter-2") - _calc = await Calculator.remote(name="calculator") - - print("Created actors: counter-1, counter-2, calculator") - print("Actor system is running. Press Ctrl+C to stop.") - - # Keep running - try: - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - print("\nShutting down...") - - -if __name__ == "__main__": - asyncio.run(main()) From ee78a65941a0bdb263a4f15c556a1afbef18dc53 Mon Sep 17 00:00:00 2001 From: Reiase Date: Fri, 23 Jan 2026 23:40:33 +0800 Subject: [PATCH 02/24] Add pulsing examples for multi-agent workflows and chaos-proof functionality - Introduced `pulsing_pingpong.py` demonstrating a simple ping-pong interaction using the @remote decorator. - Added `pulsing_research.py` showcasing a multi-agent research workflow with Researcher, Analyst, and Reporter agents. - Created `chaos_proof.py` to illustrate actor resilience with automatic restarts on failure, simulating task completion despite crashes. - Implemented `function_to_fleet.py` to demonstrate scaling functions into a fleet of workers for improved throughput. - Updated README with new examples and usage instructions for enhanced clarity and user guidance. --- comparison_examples/pulsing_pingpong.py | 22 +++++++ comparison_examples/pulsing_research.py | 52 ++++++++++++++++ crates/pulsing-py/src/actor.rs | 5 +- examples/quickstart/README.md | 79 ++++++++++++++++++++++++ examples/quickstart/chaos_proof.py | 54 ++++++++++++++++ examples/quickstart/function_to_fleet.py | 35 +++++++++++ 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 comparison_examples/pulsing_pingpong.py create mode 100644 comparison_examples/pulsing_research.py create mode 100644 examples/quickstart/chaos_proof.py create mode 100644 examples/quickstart/function_to_fleet.py diff --git a/comparison_examples/pulsing_pingpong.py b/comparison_examples/pulsing_pingpong.py new file mode 100644 index 000000000..31146ed44 --- /dev/null +++ b/comparison_examples/pulsing_pingpong.py @@ -0,0 +1,22 @@ +""" +Pulsing Ping-Pong Example using @remote decorator +Same functionality as AutoGen version +""" +from pulsing.actor import remote, runtime + +# Define Agent +@remote +class PingPongAgent: + async def ping(self, message: str) -> str: + return f"pong: {message}" + +# Run +async def main(): + async with runtime(): + agent = await PingPongAgent.spawn(name="pingpong") + response = await agent.ping("hello") + print(f"Received: {response}") + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) diff --git a/comparison_examples/pulsing_research.py b/comparison_examples/pulsing_research.py new file mode 100644 index 000000000..8665d590f --- /dev/null +++ b/comparison_examples/pulsing_research.py @@ -0,0 +1,52 @@ +""" +Pulsing Multi-Agent Research Workflow using @agent decorator +Same functionality as AutoGen version +""" +from pulsing.actor import resolve +from pulsing.agent import agent, runtime + +# Researcher Agent +@agent(role="Researcher", goal="Research topics") +class ResearcherAgent: + async def research(self, topic: str) -> list[str]: + return [ + f"Research point 1 about {topic}", + f"Research point 2 about {topic}", + ] + +# Analyst Agent +@agent(role="Analyst", goal="Analyze research results") +class AnalystAgent: + async def analyze(self, points: list[str]) -> str: + combined = " ".join(points) + return f"Analysis: {combined[:50]}..." + +# Reporter Agent +@agent(role="Reporter", goal="Write final report") +class ReporterAgent: + async def write(self, summary: str) -> str: + return f"Final Report:\n{summary}" + +# Workflow +async def run_workflow(): + async with runtime(): + # Spawn agents with names + researcher = await ResearcherAgent.spawn(name="researcher") + analyst = await AnalystAgent.spawn(name="analyst") + reporter = await ReporterAgent.spawn(name="reporter") + + # Resolve by name (automatic load balancing) + researcher = await resolve("researcher") + analyst = await resolve("analyst") + reporter = await resolve("reporter") + + # Execute workflow + research = await researcher.research("Quantum Computing") + analysis = await analyst.analyze(research) + report = await reporter.write(analysis) + + print(report) + +if __name__ == "__main__": + import asyncio + asyncio.run(run_workflow()) diff --git a/crates/pulsing-py/src/actor.rs b/crates/pulsing-py/src/actor.rs index a412ed264..af4a7f5b9 100644 --- a/crates/pulsing-py/src/actor.rs +++ b/crates/pulsing-py/src/actor.rs @@ -1110,10 +1110,9 @@ impl PyActorSystem { } else { // handler is a factory let factory = move || { - let handler = handler.clone(); - let event_loop = event_loop.clone(); - Python::with_gil(|py| -> anyhow::Result { + // Clone PyObjects inside GIL + let event_loop = event_loop.clone_ref(py); // Call factory to get instance let instance = handler .call0(py) diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index ba4efc188..9d0d4f2e4 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -55,6 +55,85 @@ python examples/quickstart/ai_chat_room.py python examples/quickstart/ai_chat_room.py --topic "远程办公是否会成为主流?" --rounds 5 ``` +--- + +## ⚡ 进阶示例 + +### 示例 3: Function → Fleet(横向扩展) + +**一行代码把函数变成可横向扩展的服务**——同一份代码,workers 从 1→N,吞吐线性提升: + +```bash +# 8 个 worker 处理 200 个任务 +python examples/quickstart/function_to_fleet.py + +# 调整 worker 数量 +WORKERS=16 ITEMS=500 python examples/quickstart/function_to_fleet.py +``` + +输出: +``` +================================================== +⚡ Function → Fleet Result +================================================== + Workers: 8 + Tasks: 200 + Duration: 0.52s + Throughput: 384.6 qps +================================================== +✅ Same code, more workers = higher throughput +================================================== +``` + +**核心代码**(仅 27 行): +```python +@remote +class Worker: + async def run(self, x: int) -> int: + await asyncio.sleep(0.02) # simulate I/O + return x * x + +async with runtime(): + ws = [await Worker.spawn(name=f"w{i}") for i in range(n)] + res = await asyncio.gather(*(ws[i % n].run(i) for i in range(m))) +``` + +### 示例 4: Chaos-proof(故障自愈) + +**Actor 崩溃后自动重启**——30% 崩溃率下,任务仍然全部完成: + +```bash +python examples/quickstart/chaos_proof.py +``` + +输出: +``` +================================================== +🛡️ Chaos-proof Result +================================================== + Total tasks: 50 + Succeeded: 50 + Retries: 23 + Crash rate: 30% +================================================== +✅ All succeeded! Actor auto-restarted on crash. +================================================== +``` + +**核心代码**: +```python +@remote(restart_policy="on-failure", max_restarts=50) +class FlakyWorker: + def work(self, x: int) -> int: + if random.random() < 0.3: # 30% 概率崩溃 + raise RuntimeError("boom") + return x + 1 +``` + +**亮点**:30% 崩溃率,框架自动重启 Actor,调用方简单重试,最终 100% 完成。 + +--- + ## 核心概念(10秒理解) ```python diff --git a/examples/quickstart/chaos_proof.py b/examples/quickstart/chaos_proof.py new file mode 100644 index 000000000..f919aa137 --- /dev/null +++ b/examples/quickstart/chaos_proof.py @@ -0,0 +1,54 @@ +""" +🛡️ Chaos-proof - Actor 崩溃自动重启,任务不丢失 +""" +import asyncio, random +from pulsing.actor import remote +from pulsing.agent import runtime + + +@remote(restart_policy="on-failure", max_restarts=50) +class FlakyWorker: + def __init__(self): + self.call_count = 0 + + def work(self, x: int) -> int: + self.call_count += 1 + if random.random() < 0.3: # 30% 概率崩溃 + raise RuntimeError(f"boom at call {self.call_count}") + return x + 1 + + +async def main(): + async with runtime(): + w = await FlakyWorker.spawn(name="flaky") + + results, retries = [], 0 + for i in range(50): + for attempt in range(10): # 最多重试 10 次 + try: + results.append(await w.work(i)) + break + except Exception: + retries += 1 + await asyncio.sleep(0.01) + else: + results.append(None) # 真的失败了 + + ok = sum(1 for r in results if r is not None) + print("\n" + "=" * 50) + print("🛡️ Chaos-proof Result") + print("=" * 50) + print(f" Total tasks: 50") + print(f" Succeeded: {ok}") + print(f" Retries: {retries}") + print(f" Crash rate: 30%") + print("=" * 50) + if ok == 50: + print("✅ All succeeded! Actor auto-restarted on crash.") + else: + print(f"⚠️ {50 - ok} tasks failed") + print("=" * 50 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/quickstart/function_to_fleet.py b/examples/quickstart/function_to_fleet.py new file mode 100644 index 000000000..16d94a211 --- /dev/null +++ b/examples/quickstart/function_to_fleet.py @@ -0,0 +1,35 @@ +import asyncio, os, time +from pulsing.actor import remote +from pulsing.agent import runtime + + +@remote +class Worker: + async def run(self, x: int) -> int: + await asyncio.sleep(0.02) # simulate I/O + return x * x + + +async def main(): + n = int(os.getenv("WORKERS", "8")) + m = int(os.getenv("ITEMS", "200")) + async with runtime(): + ws = [await Worker.spawn(name=f"w{i}") for i in range(n)] + t0 = time.perf_counter() + res = await asyncio.gather(*(ws[i % n].run(i) for i in range(m))) + dt = time.perf_counter() - t0 + print("\n" + "=" * 50) + print("⚡ Function → Fleet Result") + print("=" * 50) + print(f" Workers: {n}") + print(f" Tasks: {m}") + print(f" Duration: {dt:.2f}s") + print(f" Throughput: {m/dt:.1f} qps") + print("=" * 50) + print("✅ Same code, more workers = higher throughput") + print("=" * 50 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) + From edec41298bf33118f0866ea78552dc019639bf5f Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 00:45:56 +0800 Subject: [PATCH 03/24] Refactor actor spawning to improve clarity and consistency - Updated actor spawning calls across multiple files to use named parameters for better readability, ensuring the actor name is explicitly defined. - Adjusted print statements in various examples to enhance formatting consistency. - Enhanced the `spawn` method in the actor system to support anonymous actors and custom options, improving flexibility in actor management. - Refactored tests to align with the new actor spawning structure, ensuring comprehensive coverage and reliability in functionality. --- benchmarks/large_scale_stress_test.py | 2 +- .../large_scale_stress_test_pulsing_single.py | 2 +- comparison_examples/pulsing_pingpong.py | 5 + comparison_examples/pulsing_research.py | 7 + crates/pulsing-actor/src/system/mod.rs | 67 +++++- crates/pulsing-py/src/actor.rs | 148 +++++++++---- examples/agent/langgraph/parallel_ideas.py | 12 +- examples/agent/pulsing/mbti_discussion.py | 30 +-- .../agent/pulsing/parallel_ideas_async.py | 14 +- examples/inspect/demo_service.py | 18 +- examples/python/message_patterns.py | 2 +- examples/python/ping_pong.py | 2 +- examples/quickstart/chaos_proof.py | 1 + examples/quickstart/function_to_fleet.py | 3 +- python/pulsing/__init__.py | 209 +++++++++++++++++- python/pulsing/actor/__init__.py | 8 +- python/pulsing/actor/helpers.py | 2 +- python/pulsing/actor/remote.py | 10 +- python/pulsing/actors/router.py | 2 +- python/pulsing/autogen/runtime.py | 4 +- python/pulsing/cli/inspect.py | 10 +- python/pulsing/langgraph/executor.py | 5 +- python/pulsing/queue/manager.py | 6 +- python/pulsing/topic/topic.py | 2 +- tests/python/test_actor_system.py | 42 ++-- tests/python/test_queue.py | 8 +- tests/python/test_queue_backends.py | 6 +- tests/python/test_rendezvous_hash.py | 24 +- tests/python/test_sealed_message.py | 50 +++-- tests/python/test_topic.py | 20 +- 30 files changed, 542 insertions(+), 179 deletions(-) diff --git a/benchmarks/large_scale_stress_test.py b/benchmarks/large_scale_stress_test.py index 8e62a3d53..876404571 100755 --- a/benchmarks/large_scale_stress_test.py +++ b/benchmarks/large_scale_stress_test.py @@ -288,7 +288,7 @@ def flush(self): # Create local workers worker_refs = {} for name, cls in WORKERS.items(): - ref = await system.spawn(f"{name}_{rank}", cls(), public=True) + ref = await system.spawn(cls(), name=f"{name}_{rank}", public=True) worker_refs[name] = ref print(f" Spawned {name}_{rank}") diff --git a/benchmarks/large_scale_stress_test_pulsing_single.py b/benchmarks/large_scale_stress_test_pulsing_single.py index e854604e0..f982e75a0 100644 --- a/benchmarks/large_scale_stress_test_pulsing_single.py +++ b/benchmarks/large_scale_stress_test_pulsing_single.py @@ -243,7 +243,7 @@ async def main(): for name, cls in WORKERS.items(): workers[name] = [] for i in range(args.num_workers): - ref = await system.spawn(f"{name}_{i}", cls()) + ref = await system.spawn(cls(), name=f"{name}_{i}") workers[name].append(ref) print(f"Created {args.num_workers} {name} workers") diff --git a/comparison_examples/pulsing_pingpong.py b/comparison_examples/pulsing_pingpong.py index 31146ed44..2733486d1 100644 --- a/comparison_examples/pulsing_pingpong.py +++ b/comparison_examples/pulsing_pingpong.py @@ -2,14 +2,17 @@ Pulsing Ping-Pong Example using @remote decorator Same functionality as AutoGen version """ + from pulsing.actor import remote, runtime + # Define Agent @remote class PingPongAgent: async def ping(self, message: str) -> str: return f"pong: {message}" + # Run async def main(): async with runtime(): @@ -17,6 +20,8 @@ async def main(): response = await agent.ping("hello") print(f"Received: {response}") + if __name__ == "__main__": import asyncio + asyncio.run(main()) diff --git a/comparison_examples/pulsing_research.py b/comparison_examples/pulsing_research.py index 8665d590f..4e1fbea6c 100644 --- a/comparison_examples/pulsing_research.py +++ b/comparison_examples/pulsing_research.py @@ -2,9 +2,11 @@ Pulsing Multi-Agent Research Workflow using @agent decorator Same functionality as AutoGen version """ + from pulsing.actor import resolve from pulsing.agent import agent, runtime + # Researcher Agent @agent(role="Researcher", goal="Research topics") class ResearcherAgent: @@ -14,6 +16,7 @@ async def research(self, topic: str) -> list[str]: f"Research point 2 about {topic}", ] + # Analyst Agent @agent(role="Analyst", goal="Analyze research results") class AnalystAgent: @@ -21,12 +24,14 @@ async def analyze(self, points: list[str]) -> str: combined = " ".join(points) return f"Analysis: {combined[:50]}..." + # Reporter Agent @agent(role="Reporter", goal="Write final report") class ReporterAgent: async def write(self, summary: str) -> str: return f"Final Report:\n{summary}" + # Workflow async def run_workflow(): async with runtime(): @@ -47,6 +52,8 @@ async def run_workflow(): print(report) + if __name__ == "__main__": import asyncio + asyncio.run(run_workflow()) diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index 708e6acb8..111d7c654 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -30,7 +30,7 @@ use crate::watch::ActorLifecycle; use dashmap::DashMap; use handle::LocalActorHandle; use handler::SystemMessageHandler; -use runtime::run_supervision_loop; +use runtime::{run_actor_instance, run_supervision_loop}; use std::collections::HashMap; use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; @@ -485,6 +485,71 @@ impl ActorSystem { Ok(ActorRef::local(actor_id, sender)) } + /// Spawn an anonymous actor (no name, only accessible via ActorRef) + pub async fn spawn_anonymous(self: &Arc, actor: A) -> anyhow::Result + where + A: Actor, + { + self.spawn_anonymous_with_options(actor, SpawnOptions::default()) + .await + } + + /// Spawn an anonymous actor with custom options + pub async fn spawn_anonymous_with_options( + self: &Arc, + actor: A, + options: SpawnOptions, + ) -> anyhow::Result + where + A: Actor, + { + let actor_id = self.next_actor_id(); + + // Use configured mailbox capacity + let capacity = options + .mailbox_capacity + .unwrap_or(self.default_mailbox_capacity); + let mailbox = Mailbox::with_capacity(capacity); + let (sender, receiver) = mailbox.split(); + + let stats = Arc::new(ActorStats::default()); + + // Create context with system reference + let ctx = ActorContext::with_system( + actor_id, + self.clone() as Arc, + self.cancel_token.clone(), + sender.clone(), + ); + + // Spawn actor loop (no supervision for anonymous actors, they can't restart without a factory) + let stats_clone = stats.clone(); + let cancel = self.cancel_token.clone(); + let actor_id_for_log = actor_id; + + let join_handle = tokio::spawn(async move { + let mut receiver = receiver; + let mut ctx = ctx; + let reason = run_actor_instance(actor, &mut receiver, &mut ctx, cancel, stats_clone).await; + tracing::debug!(actor_id = ?actor_id_for_log, reason = ?reason, "Anonymous actor stopped"); + }); + + // Register using actor_id as key (not user-visible) + let handle = LocalActorHandle { + sender: sender.clone(), + join_handle, + stats: stats.clone(), + metadata: options.metadata.clone(), + named_path: None, + actor_id, + }; + + // Use actor_id string as internal key + self.local_actors.insert(actor_id.to_string(), handle); + + Ok(ActorRef::local(actor_id, sender)) + } + /// Spawn a named actor (publicly accessible via named path) /// /// # Example diff --git a/crates/pulsing-py/src/actor.rs b/crates/pulsing-py/src/actor.rs index af4a7f5b9..4847f6a02 100644 --- a/crates/pulsing-py/src/actor.rs +++ b/crates/pulsing-py/src/actor.rs @@ -130,6 +130,33 @@ impl PyActorId { fn __eq__(&self, other: &PyActorId) -> bool { self.inner == other.inner } + + /// Parse ActorId from string format "node_id:local_id" + #[staticmethod] + fn from_str(s: &str) -> PyResult { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 2 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Invalid ActorId format: '{}'. Expected 'node_id:local_id'", + s + ))); + } + let node_id: u64 = parts[0].parse().map_err(|_| { + pyo3::exceptions::PyValueError::new_err(format!( + "Invalid node_id in ActorId: '{}'", + parts[0] + )) + })?; + let local_id: u64 = parts[1].parse().map_err(|_| { + pyo3::exceptions::PyValueError::new_err(format!( + "Invalid local_id in ActorId: '{}'", + parts[1] + )) + })?; + Ok(Self { + inner: ActorId::new(NodeId::new(node_id), local_id), + }) + } } /// Python wrapper for Message (unified, supports both single and stream) @@ -977,8 +1004,9 @@ impl PyActorSystem { } #[pyo3(signature = ( - name, - handler, + actor, + *, + name=None, public=false, restart_policy="never", max_restarts=3, @@ -989,8 +1017,8 @@ impl PyActorSystem { fn spawn<'py>( &self, py: Python<'py>, - name: String, - handler: PyObject, + actor: PyObject, + name: Option, public: bool, restart_policy: &str, max_restarts: u32, @@ -1029,15 +1057,15 @@ impl PyActorSystem { // This handles the @remote decorator case where we wrap user classes let (module, qualname, file) = { // Check for __original_module__, __original_qualname__, __original_file__ - let orig_module = handler + let orig_module = actor .getattr(py, "__original_module__") .ok() .and_then(|m| m.extract::(py).ok()); - let orig_qualname = handler + let orig_qualname = actor .getattr(py, "__original_qualname__") .ok() .and_then(|q| q.extract::(py).ok()); - let orig_file = handler + let orig_file = actor .getattr(py, "__original_file__") .ok() .and_then(|f| f.extract::(py).ok()); @@ -1046,9 +1074,9 @@ impl PyActorSystem { (orig_module, orig_qualname, orig_file) } else { // Fallback to regular class info - let class = handler + let class = actor .getattr(py, "__class__") - .unwrap_or_else(|_| handler.clone_ref(py)); + .unwrap_or_else(|_| actor.clone_ref(py)); let module = class .getattr(py, "__module__") @@ -1092,46 +1120,62 @@ impl PyActorSystem { .supervision(supervision) .metadata(metadata); - let actor_ref = if matches!(policy, RestartPolicy::Never) { - // handler is the instance - let actor = PythonActorWrapper::new(handler, event_loop); - if public { - let path = ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; + let actor_ref = match name { + // Anonymous actor - no name provided + None => { + // Anonymous actors cannot have supervision (no factory to restart from) + let actor_wrapper = PythonActorWrapper::new(actor, event_loop); system - .spawn_named_with_options(path, &name, actor, options) - .await - .map_err(to_pyerr)? - } else { - system - .spawn_with_options(&name, actor, options) + .spawn_anonymous_with_options(actor_wrapper, options) .await .map_err(to_pyerr)? } - } else { - // handler is a factory - let factory = move || { - Python::with_gil(|py| -> anyhow::Result { - // Clone PyObjects inside GIL - let event_loop = event_loop.clone_ref(py); - // Call factory to get instance - let instance = handler - .call0(py) - .map_err(|e| anyhow::anyhow!("Python factory error: {:?}", e))?; - Ok(PythonActorWrapper::new(instance, event_loop)) - }) - }; + // Named actor + Some(name) => { + if matches!(policy, RestartPolicy::Never) { + // actor is the instance + let actor_wrapper = PythonActorWrapper::new(actor, event_loop); + if public { + let path = + ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; + system + .spawn_named_with_options(path, &name, actor_wrapper, options) + .await + .map_err(to_pyerr)? + } else { + system + .spawn_with_options(&name, actor_wrapper, options) + .await + .map_err(to_pyerr)? + } + } else { + // actor is a factory + let factory = move || { + Python::with_gil(|py| -> anyhow::Result { + // Clone PyObjects inside GIL + let event_loop = event_loop.clone_ref(py); + // Call factory to get instance + let instance = actor + .call0(py) + .map_err(|e| anyhow::anyhow!("Python factory error: {:?}", e))?; + Ok(PythonActorWrapper::new(instance, event_loop)) + }) + }; - if public { - let path = ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; - system - .spawn_named_factory(path, &name, factory, options) - .await - .map_err(to_pyerr)? - } else { - system - .spawn_factory(&name, factory, options) - .await - .map_err(to_pyerr)? + if public { + let path = + ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; + system + .spawn_named_factory(path, &name, factory, options) + .await + .map_err(to_pyerr)? + } else { + system + .spawn_factory(&name, factory, options) + .await + .map_err(to_pyerr)? + } + } } }; @@ -1149,6 +1193,11 @@ impl PyActorSystem { }) } + /// Alias for actor_ref - get actor reference by ID + fn refer<'py>(&self, py: Python<'py>, actor_id: PyActorId) -> PyResult> { + self.actor_ref(py, actor_id) + } + fn members<'py>(&self, py: Python<'py>) -> PyResult> { let system = self.inner.clone(); @@ -1314,6 +1363,17 @@ impl PyActorSystem { }) } + /// Alias for resolve_named - resolve actor by name + #[pyo3(signature = (name, *, node_id=None))] + fn resolve<'py>( + &self, + py: Python<'py>, + name: String, + node_id: Option, + ) -> PyResult> { + self.resolve_named(py, name, node_id) + } + fn stop<'py>(&self, py: Python<'py>, actor_name: String) -> PyResult> { let system = self.inner.clone(); diff --git a/examples/agent/langgraph/parallel_ideas.py b/examples/agent/langgraph/parallel_ideas.py index d6a1aaba8..13a57e00f 100644 --- a/examples/agent/langgraph/parallel_ideas.py +++ b/examples/agent/langgraph/parallel_ideas.py @@ -427,9 +427,9 @@ def collect_ideas(state: AgentState) -> dict[str, Any]: ideas = state.get("ideas", []) current_round = state.get("current_round", 1) - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"[Collect] Round {current_round} - Collected {len(ideas)} solutions") - print(f"{'='*60}") + print(f"{'=' * 60}") for i, idea in enumerate(ideas, 1): agent = idea.get("agent", "unknown") @@ -450,7 +450,7 @@ def collect_ideas(state: AgentState) -> dict[str, Any]: print("│ Risks:") for j, risk in enumerate(risks, 1): print(f"│ {j}. {risk}") - print(f"└{'─'*50}") + print(f"└{'─' * 50}") # Record history history = list(state.get("history", [])) @@ -511,9 +511,9 @@ async def critic(state: AgentState) -> dict[str, Any]: critique_dicts = [asdict(c) for c in critiques] avg_score = sum(c.score for c in critiques) / len(critiques) if critiques else 0 - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"[Critic] Round {current_round} - Review Comments") - print(f"{'='*60}") + print(f"{'=' * 60}") for i, c in enumerate(critique_dicts, 1): agent = c.get("idea_agent", "unknown") @@ -528,7 +528,7 @@ async def critic(state: AgentState) -> dict[str, Any]: print("│ Improvements:") for j, imp in enumerate(improvements, 1): print(f"│ {j}. {imp}") - print(f"└{'─'*50}") + print(f"└{'─' * 50}") print(f"\n>>> Average Score: {avg_score:.1f}/10") diff --git a/examples/agent/pulsing/mbti_discussion.py b/examples/agent/pulsing/mbti_discussion.py index b2c14ffde..638b2ab30 100644 --- a/examples/agent/pulsing/mbti_discussion.py +++ b/examples/agent/pulsing/mbti_discussion.py @@ -195,17 +195,17 @@ async def submit_vote(self, mbti: str, vote: str) -> dict: async def start_discussion(self) -> dict: for r in range(self.rounds): - print(f"\n{'='*60}") - print(f"Round {r+1}: Express Opinions") - print(f"{'='*60}") + print(f"\n{'=' * 60}") + print(f"Round {r + 1}: Express Opinions") + print(f"{'=' * 60}") for agent_info in self.agents: proxy = await resolve(agent_info["name"]) await proxy.form_opinion(self.opinions[-10:]) - print(f"\n{'='*60}") - print(f"Round {r+1}: Free Debate ({self.debate_time}s)") - print(f"{'='*60}") + print(f"\n{'=' * 60}") + print(f"Round {r + 1}: Free Debate ({self.debate_time}s)") + print(f"{'=' * 60}") for agent_info in self.agents: my_opinion = next( @@ -239,9 +239,9 @@ async def start_discussion(self) -> dict: ) print(f" └─ {icon} [{result['to']}]: {result['reply']}") - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print("Final Voting") - print(f"{'='*60}") + print(f"{'=' * 60}") for agent_info in self.agents: proxy = await resolve(agent_info["name"]) @@ -250,9 +250,9 @@ async def start_discussion(self) -> dict: return self._summarize() def _summarize(self) -> dict: - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print("Voting Results") - print(f"{'='*60}") + print(f"{'=' * 60}") total = sum(len(v) for v in self.votes.values()) sorted_votes = sorted(self.votes.items(), key=lambda x: len(x[1]), reverse=True) @@ -269,9 +269,9 @@ def _summarize(self) -> dict: print( f"\nDebate Statistics: {len(self.debates)} exchanges, {success} successful persuasions" ) - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"Final Result: {winner} wins") - print(f"{'='*60}") + print(f"{'=' * 60}") return {"winner": winner, "votes": self.votes, "debates": len(self.debates)} @@ -326,7 +326,7 @@ async def form_opinion(self, others: list[dict]) -> dict: if others else "None" ) - prompt = f"""You are {self.mbti} ({self.info['name']}), personality traits: {self.info['traits']}. + prompt = f"""You are {self.mbti} ({self.info["name"]}), personality traits: {self.info["traits"]}. Topic: {self.topic} Others' views: {ctx} @@ -371,8 +371,8 @@ async def debate(self, target: dict) -> dict: ) target_info = MBTI_TYPES[target_mbti] - prompt2 = f"""You are {target_mbti} ({target_info['name']}), {self.mbti} says: "{message}". -Based on your personality traits ({target_info['traits']}), would you be persuaded? Output JSON: {{"changed": true/false, "reply": "Reply (within 10 characters)"}}""" + prompt2 = f"""You are {target_mbti} ({target_info["name"]}), {self.mbti} says: "{message}". +Based on your personality traits ({target_info["traits"]}), would you be persuaded? Output JSON: {{"changed": true/false, "reply": "Reply (within 10 characters)"}}""" resp2 = await client.ainvoke(prompt2) data = parse_json(resp2.content, {}) changed = data.get("changed", False) diff --git a/examples/agent/pulsing/parallel_ideas_async.py b/examples/agent/pulsing/parallel_ideas_async.py index d8b9edaf8..5afc1415d 100644 --- a/examples/agent/pulsing/parallel_ideas_async.py +++ b/examples/agent/pulsing/parallel_ideas_async.py @@ -120,8 +120,8 @@ async def submit(self, idea: dict, iterations: int) -> dict: remaining = self.deadline - now print( - f" ✓ [{idea['agent']}] v{idea.get('version',1)} " - f"score:{idea.get('score',0)}/10 iterations:{iterations} remaining:{remaining:.1f}s" + f" ✓ [{idea['agent']}] v{idea.get('version', 1)} " + f"score:{idea.get('score', 0)}/10 iterations:{iterations} remaining:{remaining:.1f}s" ) return {"accepted": True, "remaining": remaining} @@ -149,14 +149,14 @@ async def _decide(self): for i, s in enumerate(self.submissions, 1): idea = s["idea"] print( - f" {i}. [{idea['agent']}] {idea['title']} - {idea.get('score',0)}/10" + f" {i}. [{idea['agent']}] {idea['title']} - {idea.get('score', 0)}/10" ) # Select highest score best = max(self.submissions, key=lambda x: x["idea"].get("score", 0)) self.result = { "selected": best["idea"]["agent"], - "reason": f"Highest score ({best['idea'].get('score',0)}/10)", + "reason": f"Highest score ({best['idea'].get('score', 0)}/10)", "proposal": best["idea"]["proposal"], "count": len(self.submissions), } @@ -234,7 +234,7 @@ def assist(self, from_agent: str, context: dict) -> dict: "Suggest multi-angle evaluation", ) if self.idea: - suggestion += f". Reference my proposal: {self.idea.get('title','')}" + suggestion += f". Reference my proposal: {self.idea.get('title', '')}" print(f" [{self.persona}] 💡 Reply to [{from_agent}]: {suggestion[:40]}...") return {"from": self.persona, "suggestion": suggestion} @@ -384,7 +384,7 @@ async def _critique(self) -> dict: Proposal: {json.dumps(self.idea, ensure_ascii=False)} -Available experts: {', '.join(available_experts[:5])} +Available experts: {", ".join(available_experts[:5])} Output JSON (no markdown): {{"score": 1-10, "issues": ["issue1"], "improvements": ["improvement1"], "consult": ["expert name to consult"]}}""" @@ -559,7 +559,7 @@ async def run( if isinstance(r, dict): s = "✓" if r.get("accepted") else "✗" print( - f" {s} [{r['persona']}] score:{r.get('score',0)} iterations:{r.get('iterations')} collabs:{r.get('collabs',0)}" + f" {s} [{r['persona']}] score:{r.get('score', 0)} iterations:{r.get('iterations')} collabs:{r.get('collabs', 0)}" ) return {"final": final, "agents": results} diff --git a/examples/inspect/demo_service.py b/examples/inspect/demo_service.py index 57c011d72..894473847 100644 --- a/examples/inspect/demo_service.py +++ b/examples/inspect/demo_service.py @@ -68,7 +68,11 @@ def receive(self, msg: Message) -> Message: worker_id = f"worker-{random.randint(1, 3)}" return Message.from_json( "Dispatched", - {"task": task, "worker": worker_id, "dispatched": self.tasks_dispatched}, + { + "task": task, + "worker": worker_id, + "dispatched": self.tasks_dispatched, + }, ) elif msg.msg_type == "GetStats": return Message.from_json( @@ -106,9 +110,9 @@ def receive(self, msg: Message) -> Message: async def run_node(port: int, seed: str | None): """Run a node in the cluster""" - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"Pulsing Demo Service - Node on port {port}") - print(f"{'='*60}\n") + print(f"{'=' * 60}\n") config = SystemConfig.with_addr(f"127.0.0.1:{port}") if seed: @@ -122,12 +126,12 @@ async def run_node(port: int, seed: str | None): if seed is None: # Node 1: Create dispatcher and some workers print("Creating actors on node 1...") - await system.spawn("dispatcher", DispatcherActor(), public=True) + await system.spawn(DispatcherActor(), name="dispatcher", public=True) print(" ✓ actors/dispatcher") for i in range(1, 3): worker_name = f"worker-{i}" - await system.spawn(worker_name, WorkerActor(worker_name), public=True) + await system.spawn(WorkerActor(worker_name), name=worker_name, public=True) print(f" ✓ actors/{worker_name}") print("\n✓ Node 1 ready!") @@ -150,7 +154,7 @@ async def run_node(port: int, seed: str | None): print("Creating actors on node 2...") for i in range(3, 5): worker_name = f"worker-{i}" - await system.spawn(worker_name, WorkerActor(worker_name), public=True) + await system.spawn(WorkerActor(worker_name), name=worker_name, public=True) print(f" ✓ actors/{worker_name}") print("\n✓ Node 2 ready!") @@ -158,7 +162,7 @@ async def run_node(port: int, seed: str | None): # Node 3: Add cache await asyncio.sleep(1) print("Creating actors on node 3...") - await system.spawn("cache", CacheActor(), public=True) + await system.spawn(CacheActor(), name="cache", public=True) print(" ✓ actors/cache") print("\n✓ Node 3 ready!") diff --git a/examples/python/message_patterns.py b/examples/python/message_patterns.py index 4e07678d9..c16b55197 100644 --- a/examples/python/message_patterns.py +++ b/examples/python/message_patterns.py @@ -54,7 +54,7 @@ async def produce(): async def main(): system = await create_actor_system(SystemConfig.standalone()) - actor = await system.spawn("demo", PatternDemo()) + actor = await system.spawn(PatternDemo(, name="demo")) # Pattern 1: Dict messages print("--- Dict Messages ---") diff --git a/examples/python/ping_pong.py b/examples/python/ping_pong.py index edd5bd6b8..10c748587 100644 --- a/examples/python/ping_pong.py +++ b/examples/python/ping_pong.py @@ -18,7 +18,7 @@ async def receive(self, msg): async def main(): system = await create_actor_system(SystemConfig.standalone()) - actor = await system.spawn("pingpong", PingPong()) + actor = await system.spawn(PingPong(, name="pingpong")) # Simple string message print(await actor.ask("ping")) # -> pong diff --git a/examples/quickstart/chaos_proof.py b/examples/quickstart/chaos_proof.py index f919aa137..f6a90239e 100644 --- a/examples/quickstart/chaos_proof.py +++ b/examples/quickstart/chaos_proof.py @@ -1,6 +1,7 @@ """ 🛡️ Chaos-proof - Actor 崩溃自动重启,任务不丢失 """ + import asyncio, random from pulsing.actor import remote from pulsing.agent import runtime diff --git a/examples/quickstart/function_to_fleet.py b/examples/quickstart/function_to_fleet.py index 16d94a211..9ff6a5b5a 100644 --- a/examples/quickstart/function_to_fleet.py +++ b/examples/quickstart/function_to_fleet.py @@ -24,7 +24,7 @@ async def main(): print(f" Workers: {n}") print(f" Tasks: {m}") print(f" Duration: {dt:.2f}s") - print(f" Throughput: {m/dt:.1f} qps") + print(f" Throughput: {m / dt:.1f} qps") print("=" * 50) print("✅ Same code, more workers = higher throughput") print("=" * 50 + "\n") @@ -32,4 +32,3 @@ async def main(): if __name__ == "__main__": asyncio.run(main()) - diff --git a/python/pulsing/__init__.py b/python/pulsing/__init__.py index e81ba6366..2c87dab43 100644 --- a/python/pulsing/__init__.py +++ b/python/pulsing/__init__.py @@ -3,22 +3,37 @@ Two API styles: -1. Native async API (recommended): - from pulsing.actor import init, shutdown, remote +1. Actor System style (explicit system management): + import pulsing as pul - await init() + system = await pul.actor_system() - @remote + @pul.remote class Counter: def __init__(self, init=0): self.value = init def incr(self): self.value += 1; return self.value - counter = await Counter.spawn(init=10) + counter = await Counter.spawn(name="counter") result = await counter.incr() - await shutdown() + await system.shutdown() -2. Ray-compatible sync API (for migration): +2. Ray-style async API (global system): + import pulsing as pul + + await pul.init() + + @pul.remote + class Counter: + def __init__(self, init=0): self.value = init + def incr(self): self.value += 1; return self.value + + counter = await Counter.spawn(name="counter") + result = await counter.incr() + + await pul.shutdown() + +3. Ray-compatible sync API (for migration): from pulsing.compat import ray ray.init() @@ -38,4 +53,184 @@ def incr(self): self.value += 1; return self.value - pulsing.compat.ray: Ray-compatible sync API (for migration) """ +import asyncio +from typing import Any + __version__ = "0.1.0" + +# Import from pulsing.actor +from pulsing.actor import ( + # Global system functions + init, + shutdown, + get_system, + is_initialized, + # Decorator + remote, + # Resolve function + resolve, + # Types + Actor, + ActorSystem, + ActorRef, + ActorId, + ActorProxy, + Message, + StreamMessage, + SystemConfig, + # Service + PythonActorService, + PYTHON_ACTOR_SERVICE_NAME, +) + + +async def actor_system( + addr: str | None = None, + *, + seeds: list[str] | None = None, + passphrase: str | None = None, +) -> ActorSystem: + """Create a new ActorSystem (does not set global system) + + This is the Actor System style API for explicit system management. + Use this when you need multiple systems or want explicit control. + + Args: + addr: Bind address (e.g., "0.0.0.0:8000"). None for standalone mode. + seeds: Seed nodes to join cluster + passphrase: Enable TLS with this passphrase + + Returns: + ActorSystem instance + + Example: + import pulsing as pul + + # Standalone mode + system = await pul.actor_system() + + # Cluster mode + system = await pul.actor_system(addr="0.0.0.0:8000") + + # Join existing cluster + system = await pul.actor_system( + addr="0.0.0.0:8001", + seeds=["192.168.1.1:8000"] + ) + + # With TLS + system = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase="my-secret" + ) + """ + # Build config + if addr: + config = SystemConfig.with_addr(addr) + else: + config = SystemConfig.standalone() + + if seeds: + config = config.with_seeds(seeds) + + if passphrase: + config = config.with_passphrase(passphrase) + + loop = asyncio.get_running_loop() + system = await ActorSystem.create(config, loop) + + # Automatically register PythonActorService (for remote actor creation) + service = PythonActorService(system) + await system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) + + return system + + +async def spawn( + actor: Any, + *, + name: str | None = None, + public: bool = False, + restart_policy: str = "never", + max_restarts: int = 3, + min_backoff: float = 0.1, + max_backoff: float = 30.0, +) -> ActorRef: + """Spawn an actor using the global system + + Args: + actor: Actor instance + name: Actor name (auto-generated if None) + public: Whether to register as public named actor + restart_policy: Restart policy ("never", "always", "on-failure") + max_restarts: Maximum restart attempts + min_backoff: Minimum backoff seconds + max_backoff: Maximum backoff seconds + + Returns: + ActorRef to the spawned actor + + Example: + import pulsing as pul + + await pul.init() + + class MyActor: + async def receive(self, msg): + return f"Got: {msg}" + + ref = await pul.spawn(MyActor(), name="my_actor") + """ + system = get_system() + return await system.spawn( + actor, + name=name, + public=public, + restart_policy=restart_policy, + max_restarts=max_restarts, + min_backoff=min_backoff, + max_backoff=max_backoff, + ) + + +async def refer(actorid: ActorId | str) -> ActorRef: + """Get actor reference by ID using global system + + Args: + actorid: Actor ID (ActorId instance or string) + + Returns: + ActorRef to the actor + """ + system = get_system() + if isinstance(actorid, str): + # Parse string to ActorId + actorid = ActorId.from_str(actorid) + return await system.refer(actorid) + + +# Export all public APIs +__all__ = [ + # Version + "__version__", + # Actor System style API + "actor_system", + # Ray-style async API (global system) + "init", + "shutdown", + "spawn", + "refer", + "resolve", + "get_system", + "is_initialized", + # Decorator + "remote", + # Types + "Actor", + "ActorSystem", + "ActorRef", + "ActorId", + "ActorProxy", + "Message", + "StreamMessage", +] diff --git a/python/pulsing/actor/__init__.py b/python/pulsing/actor/__init__.py index 80012a0a3..009a052ab 100644 --- a/python/pulsing/actor/__init__.py +++ b/python/pulsing/actor/__init__.py @@ -207,6 +207,12 @@ async def tell_with_timeout( "StreamMessage", "SystemConfig", "ActorSystem", + "ActorRef", + "ActorId", + "ActorProxy", + # Service (for actor_system function) + "PythonActorService", + "PYTHON_ACTOR_SERVICE_NAME", # Advanced constructor (documented) "create_actor_system", ] @@ -236,7 +242,7 @@ async def create_actor_system(config: SystemConfig) -> ActorSystem: # Automatically register PythonActorService (for remote actor creation) service = PythonActorService(system) - await system.spawn(PYTHON_ACTOR_SERVICE_NAME, service, public=True) + await system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) return system diff --git a/python/pulsing/actor/helpers.py b/python/pulsing/actor/helpers.py index ee33bab92..f6d162f57 100644 --- a/python/pulsing/actor/helpers.py +++ b/python/pulsing/actor/helpers.py @@ -82,7 +82,7 @@ async def spawn_and_run( # Use init() to set global system (makes get_system() work inside actors) system = await init(addr=addr, seeds=seeds) - await system.spawn(name, actor, public=public) + await system.spawn(actor, name=name, public=public) print(f"[{name}] Started at {system.addr}") await run_until_signal(name) diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index 8e98fa011..63d599e48 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -493,8 +493,8 @@ def factory(): return _WrappedActor(instance) actor_ref = await self.system.spawn( - actor_name, factory, + name=actor_name, public=public, restart_policy=restart_policy, max_restarts=max_restarts, @@ -505,7 +505,9 @@ def factory(): # Standard spawn instance = cls(*args, **kwargs) actor = _WrappedActor(instance) - actor_ref = await self.system.spawn(actor_name, actor, public=public) + actor_ref = await self.system.spawn( + actor, name=actor_name, public=public + ) # Register actor metadata _register_actor_metadata(actor_name, cls) @@ -629,8 +631,8 @@ def factory(): return _WrappedActor(instance) actor_ref = await system.spawn( - actor_name, factory, + name=actor_name, public=True, restart_policy=self._restart_policy, max_restarts=self._max_restarts, @@ -640,7 +642,7 @@ def factory(): else: instance = self._cls(*args, **kwargs) actor = _WrappedActor(instance) - actor_ref = await system.spawn(actor_name, actor, public=True) + actor_ref = await system.spawn(actor, name=actor_name, public=True) # Register actor metadata _register_actor_metadata(actor_name, self._cls) diff --git a/python/pulsing/actors/router.py b/python/pulsing/actors/router.py index 952e84f6a..551e326ff 100644 --- a/python/pulsing/actors/router.py +++ b/python/pulsing/actors/router.py @@ -458,7 +458,7 @@ class Router(Actor): # Or programmatically router = Router(http_port=8080, model_name="my-llm") - await system.spawn("router", router, public=True) + await system.spawn(router, name="router", public=True) """ def __init__( diff --git a/python/pulsing/autogen/runtime.py b/python/pulsing/autogen/runtime.py index 571c79a26..9fef0e8ff 100644 --- a/python/pulsing/autogen/runtime.py +++ b/python/pulsing/autogen/runtime.py @@ -376,7 +376,7 @@ async def register_agent_instance( # Create wrapper and spawn wrapper = AutoGenAgentWrapper(agent_instance, self) - actor_ref = await self._system.spawn(full_key, wrapper, public=True) + actor_ref = await self._system.spawn(wrapper, name=full_key, public=True) self._instantiated_agents[full_key] = agent_instance self._agent_refs[full_key] = actor_ref @@ -546,7 +546,7 @@ async def _ensure_agent(self, agent_type: str, agent_key: str = "default") -> No # Create wrapper and spawn wrapper = AutoGenAgentWrapper(agent, self) - actor_ref = await self._system.spawn(full_key, wrapper, public=True) + actor_ref = await self._system.spawn(wrapper, name=full_key, public=True) self._instantiated_agents[full_key] = agent self._agent_refs[full_key] = actor_ref diff --git a/python/pulsing/cli/inspect.py b/python/pulsing/cli/inspect.py index 464cdb250..e2ffbe33b 100644 --- a/python/pulsing/cli/inspect.py +++ b/python/pulsing/cli/inspect.py @@ -236,7 +236,7 @@ def _print_actors_table(actors_data: list[dict], detailed: bool = False): print( f" {'Name':<25} {'Type':<8} {'Actor ID':<20} {'Class':<25} {'Module':<30}" ) - print(f" {'-'*110}") + print(f" {'-' * 110}") for actor in actors_data: name = actor.get("name", "") @@ -251,7 +251,7 @@ def _print_actors_table(actors_data: list[dict], detailed: bool = False): print(f" {name:<25} {actor_type:<8} {actor_id:<20} {cls:<25} {module:<30}") else: print(f" {'Name':<40} {'Type':<10} {'Actor ID':<20}") - print(f" {'-'*72}") + print(f" {'-' * 72}") for actor in actors_data: name = actor.get("name", "") @@ -437,9 +437,9 @@ def inspect_metrics( if raw: # Output raw metrics (as text) - print(f"{'='*80}") - print(f"[{i+1}/{len(alive_members)}] Node {node_id} ({addr})") - print(f"{'='*80}") + print(f"{'=' * 80}") + print(f"[{i + 1}/{len(alive_members)}] Node {node_id} ({addr})") + print(f"{'=' * 80}") print(metrics) print() else: diff --git a/python/pulsing/langgraph/executor.py b/python/pulsing/langgraph/executor.py index 591abb54d..5b897f078 100644 --- a/python/pulsing/langgraph/executor.py +++ b/python/pulsing/langgraph/executor.py @@ -299,10 +299,9 @@ async def start_worker( actor = LangGraphNodeActor(node_name, node_func, executor_config) name = actor_name or f"langgraph_node_{node_name}" - await system.spawn(name, actor, public=True) + await system.spawn(actor, name=name, public=True) logger.info( - f"Worker started: {name} @ {addr} " - f"(workers={max_workers}, queue={queue_size})" + f"Worker started: {name} @ {addr} (workers={max_workers}, queue={queue_size})" ) try: diff --git a/python/pulsing/queue/manager.py b/python/pulsing/queue/manager.py index cf53ad151..c6bda87b9 100644 --- a/python/pulsing/queue/manager.py +++ b/python/pulsing/queue/manager.py @@ -153,7 +153,7 @@ async def _get_or_create_bucket( backend_options=backend_options, ) self._buckets[key] = await self.system.spawn( - actor_name, storage, public=True + storage, name=actor_name, public=True ) logger.info(f"Created bucket: {actor_name} at {bucket_storage_path}") @@ -178,7 +178,7 @@ async def _get_or_create_topic_broker(self, topic_name: str) -> ActorRef: broker = TopicBroker(topic_name, self.system) self._topics[topic_name] = await self.system.spawn( - actor_name, broker, public=True + broker, name=actor_name, public=True ) logger.info(f"Created topic broker: {actor_name}") @@ -357,7 +357,7 @@ async def get_storage_manager(system: ActorSystem) -> ActorRef: # Create new StorageManager try: manager = StorageManager(system) - return await system.spawn(STORAGE_MANAGER_NAME, manager, public=True) + return await system.spawn(manager, name=STORAGE_MANAGER_NAME, public=True) except Exception as e: if "already exists" in str(e).lower(): return await system.resolve_named( diff --git a/python/pulsing/topic/topic.py b/python/pulsing/topic/topic.py index 027d32610..fcca66739 100644 --- a/python/pulsing/topic/topic.py +++ b/python/pulsing/topic/topic.py @@ -234,7 +234,7 @@ async def start(self) -> None: actor_name = f"_topic_sub_{self._topic}_{self._reader_id}" subscriber = _SubscriberActor(self._callbacks) self._subscriber_ref = await self._system.spawn( - actor_name, subscriber, public=True + subscriber, name=actor_name, public=True ) # Register with broker diff --git a/tests/python/test_actor_system.py b/tests/python/test_actor_system.py index 5fede441d..a6538d036 100644 --- a/tests/python/test_actor_system.py +++ b/tests/python/test_actor_system.py @@ -213,7 +213,7 @@ async def test_actor_system_creation(actor_system): @pytest.mark.asyncio async def test_spawn_actor(actor_system): """Test spawning an actor.""" - actor_ref = await actor_system.spawn("echo", EchoActor()) + actor_ref = await actor_system.spawn(EchoActor(), name="echo") assert actor_ref is not None assert actor_ref.actor_id is not None assert actor_ref.is_local() @@ -223,7 +223,7 @@ async def test_spawn_actor(actor_system): @pytest.mark.asyncio async def test_ask_single_message(actor_system): """Test ask pattern with single message.""" - actor_ref = await actor_system.spawn("echo", EchoActor()) + actor_ref = await actor_system.spawn(EchoActor(), name="echo") # Send message and get response (using send() which supports Message) request = Message.from_json("greeting", {"text": "hello"}) @@ -237,7 +237,7 @@ async def test_ask_single_message(actor_system): @pytest.mark.asyncio async def test_ask_json(actor_system): """Test ask with JSON message.""" - actor_ref = await actor_system.spawn("counter", CounterActor()) + actor_ref = await actor_system.spawn(CounterActor(), name="counter") # Test increment response = ( @@ -259,7 +259,7 @@ async def test_ask_json(actor_system): @pytest.mark.asyncio async def test_tell_message(actor_system): """Test tell pattern (fire-and-forget).""" - actor_ref = await actor_system.spawn("counter", CounterActor()) + actor_ref = await actor_system.spawn(CounterActor(), name="counter") # Send tell (fire-and-forget) await actor_ref.tell(Message.from_json("increment", {"value": 10})) @@ -276,7 +276,7 @@ async def test_tell_message(actor_system): async def test_actor_lifecycle(actor_system): """Test actor on_start and on_stop callbacks.""" actor = CounterActor() - actor_ref = await actor_system.spawn("lifecycle_test", actor) + actor_ref = await actor_system.spawn(actor, name="lifecycle_test") # Do some work await actor_ref.ask(Message.from_json("increment", {"value": 1})) @@ -292,9 +292,9 @@ async def test_actor_lifecycle(actor_system): @pytest.mark.asyncio async def test_multiple_actors(actor_system): """Test multiple actors in the same system.""" - echo1 = await actor_system.spawn("echo1", EchoActor()) - echo2 = await actor_system.spawn("echo2", EchoActor()) - counter = await actor_system.spawn("counter", CounterActor()) + echo1 = await actor_system.spawn(EchoActor(), name="echo1") + echo2 = await actor_system.spawn(EchoActor(), name="echo2") + counter = await actor_system.spawn(CounterActor(), name="counter") # Verify all actors exist local_actors = actor_system.local_actor_names() @@ -315,7 +315,7 @@ async def test_multiple_actors(actor_system): @pytest.mark.asyncio async def test_actor_metadata(actor_system): """Test actor metadata.""" - actor_ref = await actor_system.spawn("counter_meta", CounterActor()) + actor_ref = await actor_system.spawn(CounterActor(), name="counter_meta") # Increment counter await actor_ref.ask(Message.from_json("increment", {"value": 42})) @@ -334,7 +334,7 @@ async def test_actor_metadata(actor_system): @pytest.mark.asyncio async def test_streaming_response_basic(actor_system): """Test basic streaming response.""" - actor_ref = await actor_system.spawn("generator", StreamingGeneratorActor()) + actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") # Request streaming response request = Message.from_json("generate", {"count": 5, "delay": 0.01}) @@ -359,7 +359,7 @@ async def test_streaming_response_basic(actor_system): @pytest.mark.asyncio async def test_streaming_response_with_stream_reader(actor_system): """Test streaming response with stream_reader method.""" - actor_ref = await actor_system.spawn("generator", StreamingGeneratorActor()) + actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") # Use ask + stream_reader request = Message.from_json("generate", {"count": 3}) @@ -376,7 +376,7 @@ async def test_streaming_response_with_stream_reader(actor_system): @pytest.mark.asyncio async def test_streaming_response_large(actor_system): """Test streaming response with many items.""" - actor_ref = await actor_system.spawn("generator", StreamingGeneratorActor()) + actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") request = Message.from_json("generate", {"count": 100, "delay": 0.001}) response = await actor_ref.ask(request) @@ -392,7 +392,7 @@ async def test_streaming_response_large(actor_system): @pytest.mark.asyncio async def test_streaming_response_with_error(actor_system): """Test streaming response that errors midway.""" - actor_ref = await actor_system.spawn("generator", StreamingGeneratorActor()) + actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") request = Message.from_json("generate_with_error", {}) response = await actor_ref.ask(request) @@ -416,7 +416,7 @@ async def test_streaming_response_with_error(actor_system): @pytest.mark.asyncio async def test_streaming_response_cancel(actor_system): """Test cancelling a streaming response.""" - actor_ref = await actor_system.spawn("generator", StreamingGeneratorActor()) + actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") # Request a long stream request = Message.from_json("generate", {"count": 1000, "delay": 0.1}) @@ -443,7 +443,7 @@ async def test_streaming_response_cancel(actor_system): @pytest.mark.asyncio async def test_streaming_request_basic(actor_system): """Test actor receiving streaming request.""" - actor_ref = await actor_system.spawn("consumer", StreamConsumerActor()) + actor_ref = await actor_system.spawn(StreamConsumerActor(), name="consumer") # For now, test with single message (stream input requires client-side streaming) request = Message.from_json("test", {"data": "hello"}) @@ -488,7 +488,7 @@ async def test_remote_actor_communication(cluster_systems): system1, system2 = cluster_systems # Spawn actor on system1 - actor_ref1 = await system1.spawn("remote_echo", EchoActor(), public=True) + actor_ref1 = await system1.spawn(EchoActor(), name="remote_echo", public=True) # Wait for actor registration to propagate await asyncio.sleep(1.0) @@ -513,7 +513,7 @@ async def test_remote_streaming_response(cluster_systems): # Spawn streaming actor on system1 actor_ref1 = await system1.spawn( - "remote_generator", StreamingGeneratorActor(), public=True + StreamingGeneratorActor(), name="remote_generator", public=True ) # Wait for propagation @@ -554,7 +554,7 @@ async def test_actor_not_found(actor_system): @pytest.mark.asyncio async def test_message_to_stopped_actor(actor_system): """Test sending message to stopped actor.""" - actor_ref = await actor_system.spawn("temp_actor", EchoActor()) + actor_ref = await actor_system.spawn(EchoActor(), name="temp_actor") # Stop the actor await actor_system.stop("temp_actor") @@ -572,7 +572,7 @@ async def test_message_to_stopped_actor(actor_system): @pytest.mark.asyncio async def test_high_throughput_messages(actor_system): """Test sending many messages quickly.""" - actor_ref = await actor_system.spawn("perf_counter", CounterActor()) + actor_ref = await actor_system.spawn(CounterActor(), name="perf_counter") # Send many increments num_messages = 100 @@ -590,7 +590,9 @@ async def test_high_throughput_messages(actor_system): @pytest.mark.asyncio async def test_concurrent_streaming(actor_system): """Test multiple concurrent streaming responses.""" - actor_ref = await actor_system.spawn("concurrent_gen", StreamingGeneratorActor()) + actor_ref = await actor_system.spawn( + StreamingGeneratorActor(), name="concurrent_gen" + ) async def consume_stream(stream_id: int): request = Message.from_json("generate", {"count": 10, "delay": 0.01}) diff --git a/tests/python/test_queue.py b/tests/python/test_queue.py index 76c379137..19bc1736a 100644 --- a/tests/python/test_queue.py +++ b/tests/python/test_queue.py @@ -934,9 +934,9 @@ async def test_data_integrity_under_stress(actor_system, temp_storage_path): actual_checksum = hashlib.md5( f"{record_id}:{record['value']}".encode() ).hexdigest() - assert ( - record["checksum"] == expected_checksum - ), f"Checksum mismatch for {record_id}" + assert record["checksum"] == expected_checksum, ( + f"Checksum mismatch for {record_id}" + ) assert actual_checksum == expected_checksum, f"Value corruption for {record_id}" @@ -956,7 +956,7 @@ async def test_bucket_storage_direct(actor_system, temp_storage_path): ) # Spawn actor - actor_ref = await actor_system.spawn("test_bucket", storage) + actor_ref = await actor_system.spawn(storage, name="test_bucket") from pulsing.actor import Message diff --git a/tests/python/test_queue_backends.py b/tests/python/test_queue_backends.py index 7ce6304e6..5abadc6d9 100644 --- a/tests/python/test_queue_backends.py +++ b/tests/python/test_queue_backends.py @@ -260,7 +260,7 @@ async def test_bucket_storage_with_memory_backend( backend="memory", ) - actor_ref = await actor_system.spawn("bucket_memory_test", storage) + actor_ref = await actor_system.spawn(storage, name="bucket_memory_test") # Put records for i in range(5): @@ -289,7 +289,7 @@ async def test_bucket_storage_put_batch(self, actor_system, temp_storage_path): backend="memory", ) - actor_ref = await actor_system.spawn("bucket_batch_test", storage) + actor_ref = await actor_system.spawn(storage, name="bucket_batch_test") # Put batch records = [{"id": f"batch_{i}", "value": i} for i in range(10)] @@ -538,7 +538,7 @@ def total_count(self) -> int: backend="tracking", ) - actor_ref = await actor_system.spawn("tracking_bucket", storage) + actor_ref = await actor_system.spawn(storage, name="tracking_bucket") # Perform operations await actor_ref.ask(Message.from_json("Put", {"record": {"id": "1"}})) diff --git a/tests/python/test_rendezvous_hash.py b/tests/python/test_rendezvous_hash.py index f5e589a00..489e0fffd 100644 --- a/tests/python/test_rendezvous_hash.py +++ b/tests/python/test_rendezvous_hash.py @@ -88,9 +88,9 @@ def test_load_distribution(self): # Allow 20% deviation for node_id, count in distribution.items(): ratio = count / expected - assert ( - 0.8 <= ratio <= 1.2 - ), f"Node {node_id} has {count} keys, expected ~{expected}" + assert 0.8 <= ratio <= 1.2, ( + f"Node {node_id} has {count} keys, expected ~{expected}" + ) def test_minimal_migration_on_add_node(self): """Migration ratio should be approximately 1/(N+1) when adding a node""" @@ -115,9 +115,9 @@ def test_minimal_migration_on_add_node(self): # Rendezvous hashing: migration ratio should be close to 1/(N+1) # Allow 50% error margin - assert ( - migration_ratio < expected_ratio * 1.5 - ), f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" + assert migration_ratio < expected_ratio * 1.5, ( + f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" + ) print( f"[Rendezvous] Add node: migration ratio = {migration_ratio:.2%} (expected ~{expected_ratio:.2%})" ) @@ -142,9 +142,9 @@ def test_minimal_migration_on_remove_node(self): migration_ratio = migrated / num_keys expected_ratio = 1 / 6 # Approximately 16.7% - assert ( - migration_ratio < expected_ratio * 1.5 - ), f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" + assert migration_ratio < expected_ratio * 1.5, ( + f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" + ) print( f"[Rendezvous] Remove node: migration ratio = {migration_ratio:.2%} (expected ~{expected_ratio:.2%})" ) @@ -182,9 +182,9 @@ def test_compare_with_old_algorithm(self): ) # New algorithm should be significantly better than old - assert ( - new_ratio < old_ratio * 0.5 - ), f"New algorithm ({new_ratio:.2%}) should be much better than old ({old_ratio:.2%})" + assert new_ratio < old_ratio * 0.5, ( + f"New algorithm ({new_ratio:.2%}) should be much better than old ({old_ratio:.2%})" + ) def test_only_alive_nodes(self): """Only select nodes in Alive state""" diff --git a/tests/python/test_sealed_message.py b/tests/python/test_sealed_message.py index 11dd86445..1dd71ecb8 100644 --- a/tests/python/test_sealed_message.py +++ b/tests/python/test_sealed_message.py @@ -236,7 +236,9 @@ def test_sealed_message_repr(): @pytest.mark.asyncio async def test_ask_with_dataclass(actor_system): """Test ask with dataclass message.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # Send IncrementCommand dataclass response = await actor_ref.ask(IncrementCommand(n=5)) @@ -249,7 +251,7 @@ async def test_ask_with_dataclass(actor_system): async def test_ask_multiple_dataclass_messages(actor_system): """Test multiple ask calls with dataclass messages.""" actor_ref = await actor_system.spawn( - "counter", SealedCounterActor(initial_value=10) + SealedCounterActor(initial_value=10), name="counter" ) # Multiple increments @@ -272,7 +274,9 @@ async def test_ask_multiple_dataclass_messages(actor_system): @pytest.mark.asyncio async def test_ask_with_dict(actor_system): """Test ask with dict message.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # Send dict message response = await actor_ref.ask({"action": "increment", "n": 7}) @@ -285,7 +289,7 @@ async def test_ask_with_dict(actor_system): async def test_ask_dict_multiple_operations(actor_system): """Test multiple dict-based operations.""" actor_ref = await actor_system.spawn( - "counter", SealedCounterActor(initial_value=100) + SealedCounterActor(initial_value=100), name="counter" ) # Increment @@ -309,7 +313,7 @@ async def test_ask_dict_multiple_operations(actor_system): @pytest.mark.asyncio async def test_ask_with_list(actor_system): """Test ask with list message.""" - actor_ref = await actor_system.spawn("processor", ListProcessorActor()) + actor_ref = await actor_system.spawn(ListProcessorActor(), name="processor") # Send list of numbers response = await actor_ref.ask([1, 2, 3, 4, 5]) @@ -320,7 +324,7 @@ async def test_ask_with_list(actor_system): @pytest.mark.asyncio async def test_ask_with_mixed_list(actor_system): """Test ask with list containing mixed types.""" - actor_ref = await actor_system.spawn("processor", ListProcessorActor()) + actor_ref = await actor_system.spawn(ListProcessorActor(), name="processor") response = await actor_ref.ask([1, "hello", 3.5, "world"]) @@ -335,7 +339,7 @@ async def test_ask_with_mixed_list(actor_system): @pytest.mark.asyncio async def test_ask_with_nested_dict(actor_system): """Test ask with nested dict structure.""" - actor_ref = await actor_system.spawn("complex", ComplexObjectActor()) + actor_ref = await actor_system.spawn(ComplexObjectActor(), name="complex") msg = { "nested": {"level2": {"level3": "deep_value"}}, @@ -351,7 +355,7 @@ async def test_ask_with_nested_dict(actor_system): @pytest.mark.asyncio async def test_ask_echo_any_object(actor_system): """Test echoing various Python objects.""" - actor_ref = await actor_system.spawn("echo", EchoAnyActor()) + actor_ref = await actor_system.spawn(EchoAnyActor(), name="echo") # Test with different types test_cases = [ @@ -378,7 +382,9 @@ async def test_ask_echo_any_object(actor_system): @pytest.mark.asyncio async def test_tell_with_dataclass(actor_system): """Test tell with dataclass message.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # Send tell (fire-and-forget) await actor_ref.tell(IncrementCommand(n=10)) @@ -394,7 +400,9 @@ async def test_tell_with_dataclass(actor_system): @pytest.mark.asyncio async def test_tell_with_dict(actor_system): """Test tell with dict message.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # Send multiple tells await actor_ref.tell({"action": "increment", "n": 5}) @@ -414,7 +422,9 @@ async def test_tell_with_dict(actor_system): @pytest.mark.asyncio async def test_message_from_json_still_works(actor_system): """Test that Message.from_json still works for backward compatibility.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # Use old Message.from_json style response = await actor_ref.ask(Message.from_json("increment", {"n": 5})) @@ -428,7 +438,9 @@ async def test_message_from_json_still_works(actor_system): @pytest.mark.asyncio async def test_mixed_message_styles(actor_system): """Test mixing old Message style with new Python object style.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # New style r1 = await actor_ref.ask(IncrementCommand(n=10)) @@ -451,7 +463,9 @@ async def test_mixed_message_styles(actor_system): @pytest.mark.asyncio async def test_concurrent_sealed_messages(actor_system): """Test concurrent access with sealed messages.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # Send many concurrent increments tasks = [actor_ref.ask(IncrementCommand(n=1)) for _ in range(50)] @@ -469,7 +483,9 @@ async def test_concurrent_sealed_messages(actor_system): @pytest.mark.asyncio async def test_concurrent_dict_messages(actor_system): """Test concurrent access with dict messages.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) tasks = [actor_ref.ask({"action": "increment", "n": 1}) for _ in range(30)] await asyncio.gather(*tasks) @@ -486,7 +502,9 @@ async def test_concurrent_dict_messages(actor_system): @pytest.mark.asyncio async def test_unknown_message_type_returns_error(actor_system): """Test that unknown message types return error response.""" - actor_ref = await actor_system.spawn("counter", SealedCounterActor(initial_value=0)) + actor_ref = await actor_system.spawn( + SealedCounterActor(initial_value=0), name="counter" + ) # Send an unknown type response = await actor_ref.ask("just a string") @@ -542,7 +560,7 @@ async def receive(self, msg): @pytest.mark.asyncio async def test_custom_request_response_types(actor_system): """Test custom request/response dataclass types.""" - actor_ref = await actor_system.spawn("custom", CustomHandlerActor()) + actor_ref = await actor_system.spawn(CustomHandlerActor(), name="custom") # Sum operation r1 = await actor_ref.ask(CustomRequest(operation="sum", values=[1, 2, 3, 4, 5])) diff --git a/tests/python/test_topic.py b/tests/python/test_topic.py index 4341e7261..0f398c635 100644 --- a/tests/python/test_topic.py +++ b/tests/python/test_topic.py @@ -542,9 +542,9 @@ async def test_concurrent_subscribers(actor_system): # All subscribers should receive all messages for i in range(num_subscribers): - assert ( - len(results[i]) == num_messages - ), f"Subscriber {i} got {len(results[i])} messages, expected {num_messages}" + assert len(results[i]) == num_messages, ( + f"Subscriber {i} got {len(results[i])} messages, expected {num_messages}" + ) for reader in readers: await reader.stop() @@ -893,7 +893,7 @@ async def receive(self, msg): # Manually create slow subscriber slow_actor = SlowSubscriber() actor_name = "_topic_sub_timeout_error_topic_slow_sub" - await actor_system.spawn(actor_name, slow_actor, public=True) + await actor_system.spawn(slow_actor, name=actor_name, public=True) # Register with broker from pulsing.queue.manager import get_topic_broker @@ -936,7 +936,7 @@ async def receive(self, msg): return {"echo": msg} echo = EchoActor() - ref = await actor_system.spawn("echo_timeout_test", echo) + ref = await actor_system.spawn(echo, name="echo_timeout_test") # ask_with_timeout success scenario result = await ask_with_timeout(ref, {"hello": "world"}, timeout=5.0) @@ -960,7 +960,7 @@ async def receive(self, msg): return {"done": True} slow = SlowActor() - ref = await actor_system.spawn("slow_timeout_test", slow) + ref = await actor_system.spawn(slow, name="slow_timeout_test") # ask_with_timeout timeout scenario with pytest.raises(asyncio.TimeoutError): @@ -986,7 +986,7 @@ async def receive(self, msg): return None collector = CollectorActor() - ref = await actor_system.spawn("collector_timeout_test", collector) + ref = await actor_system.spawn(collector, name="collector_timeout_test") # tell_with_timeout success scenario (fire-and-forget doesn't wait for response) await tell_with_timeout(ref, {"hello": "world"}, timeout=5.0) @@ -1047,7 +1047,7 @@ async def receive(self, msg): failing_actor = FailingSubscriber() actor_name = "_topic_sub_eviction_test_topic_failing" - await actor_system.spawn(actor_name, failing_actor, public=True) + await actor_system.spawn(failing_actor, name=actor_name, public=True) # Register failing subscriber with broker broker = await get_topic_broker(actor_system, "eviction_test_topic") @@ -1173,7 +1173,7 @@ async def receive(self, msg): # Spawn a public actor test_actor = TestActor() - await actor_system.spawn("lb_test_actor", test_actor, public=True) + await actor_system.spawn(test_actor, name="lb_test_actor", public=True) # Resolve the named actor ref = await actor_system.resolve_named("lb_test_actor") @@ -1208,7 +1208,7 @@ async def receive(self, msg): return {"count": self.count} counter = CounterActor() - await actor_system.spawn("counter_lb_test", counter, public=True) + await actor_system.spawn(counter, name="counter_lb_test", public=True) # Multiple resolves and calls results = [] From c1f3fceeb6146d1fd3ff3485866a2ee79e0f2595 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 01:26:08 +0800 Subject: [PATCH 04/24] Add LLMs binding documentation and update API references - Introduced a new documentation file `llms.binding.md` detailing the Pulsing API reference for LLMs, including Python interface examples for Actor System and queue operations. - Updated existing API reference documentation (`api_reference.md` and `api_reference.zh.md`) to reflect the new `pul.actor_system` usage and provide clearer examples. - Revised example scripts for distributed counter and ping-pong interactions to align with the new API structure, enhancing clarity and usability. - Refactored queue documentation to standardize the usage of `system.queue.write` and `system.queue.read` methods, improving consistency across examples and guides. - Enhanced the README files to include updated usage instructions and examples for the new queue API, ensuring comprehensive user guidance. --- docs/src/api_reference.md | 32 +++- docs/src/api_reference.zh.md | 32 +++- docs/src/examples/distributed_counter.md | 33 ++-- docs/src/examples/distributed_counter.zh.md | 35 ++-- docs/src/examples/ping_pong.md | 10 +- docs/src/examples/ping_pong.zh.md | 10 +- docs/src/guide/queue.md | 52 +++--- docs/src/guide/queue.zh.md | 50 +++-- examples/inspect/demo_service.py | 22 ++- examples/python/cluster.py | 57 +++--- examples/python/distributed_queue.py | 14 +- examples/python/message_patterns.py | 13 +- examples/python/named_actors.py | 28 +-- examples/python/ping_pong.py | 8 +- examples/python/sync_queue_example.py | 12 +- llms.binding.md | 197 ++++++++++++++++++++ python/pulsing/__init__.py | 37 +++- python/pulsing/actor/__init__.py | 5 +- python/pulsing/actor/remote.py | 37 ++-- python/pulsing/queue/README.md | 43 ++--- python/pulsing/queue/__init__.py | 122 +++++++++++- tests/python/test_remote_decorator.py | 2 +- 22 files changed, 611 insertions(+), 240 deletions(-) create mode 100644 llms.binding.md diff --git a/docs/src/api_reference.md b/docs/src/api_reference.md index c85794749..d7de15833 100644 --- a/docs/src/api_reference.md +++ b/docs/src/api_reference.md @@ -291,9 +291,37 @@ After decoration, the class provides: ## Functions -### create_actor_system +### pul.actor_system (Recommended) -Create a new Actor System instance. +Create a new Actor System instance with simple parameters. + +```python +import pulsing as pul + +system = await pul.actor_system( + addr: str | None = None, # Bind address, None for standalone + *, + seeds: list[str] | None = None, # Seed nodes for cluster + passphrase: str | None = None, # TLS passphrase +) -> ActorSystem +``` + +**Example:** + +```python +# Standalone mode +system = await pul.actor_system() + +# Cluster mode +system = await pul.actor_system(addr="0.0.0.0:8000") + +# Join existing cluster +system = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) +``` + +### create_actor_system (Low-level) + +Create a new Actor System instance with SystemConfig. ```python async def create_actor_system(config: SystemConfig) -> ActorSystem: diff --git a/docs/src/api_reference.zh.md b/docs/src/api_reference.zh.md index 90910cf8b..110ca84f8 100644 --- a/docs/src/api_reference.zh.md +++ b/docs/src/api_reference.zh.md @@ -291,9 +291,37 @@ result = await ask_with_timeout(ref, {"op": "compute"}, timeout=10.0) ## 函数 -### create_actor_system +### pul.actor_system(推荐) -创建新的 Actor System 实例。 +使用简单参数创建新的 Actor System 实例。 + +```python +import pulsing as pul + +system = await pul.actor_system( + addr: str | None = None, # 绑定地址,None 为单机模式 + *, + seeds: list[str] | None = None, # 集群种子节点 + passphrase: str | None = None, # TLS 密码短语 +) -> ActorSystem +``` + +**示例:** + +```python +# 单机模式 +system = await pul.actor_system() + +# 集群模式 +system = await pul.actor_system(addr="0.0.0.0:8000") + +# 加入现有集群 +system = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) +``` + +### create_actor_system(底层) + +使用 SystemConfig 创建新的 Actor System 实例。 ```python async def create_actor_system(config: SystemConfig) -> ActorSystem: diff --git a/docs/src/examples/distributed_counter.md b/docs/src/examples/distributed_counter.md index ae29ef511..b2bf9d014 100644 --- a/docs/src/examples/distributed_counter.md +++ b/docs/src/examples/distributed_counter.md @@ -14,36 +14,37 @@ If you want a runnable baseline, start from `examples/python/named_actors.py` an ```python import asyncio -from pulsing.actor import Actor, Message, SystemConfig, create_actor_system +import pulsing as pul -class Counter(Actor): +class Counter: def __init__(self): self.v = 0 - def receive(self, msg: Message) -> Message: - if msg.msg_type == "Inc": - self.v += int(msg.to_json().get("n", 1)) - return Message.from_json("Value", {"v": self.v}) - if msg.msg_type == "Get": - return Message.from_json("Value", {"v": self.v}) - return Message.empty() + async def receive(self, msg): + if msg.get("action") == "inc": + self.v += msg.get("n", 1) + return {"v": self.v} + if msg.get("action") == "get": + return {"v": self.v} + return {} async def seed(): - system = await create_actor_system(SystemConfig.with_addr("0.0.0.0:8000")) - await system.spawn("global_counter", Counter(), public=True) + system = await pul.actor_system(addr="0.0.0.0:8000") + await system.spawn(Counter(), name="global_counter", public=True) await asyncio.Event().wait() async def worker(): - system = await create_actor_system( - SystemConfig.with_addr("0.0.0.0:8001").with_seeds(["127.0.0.1:8000"]) + system = await pul.actor_system( + addr="0.0.0.0:8001", + seeds=["127.0.0.1:8000"] ) await asyncio.sleep(1.0) - ref = await system.find("global_counter") - resp = await ref.ask(Message.from_json("Inc", {"n": 1})) - print(resp.to_json()) + ref = await system.resolve("global_counter") + resp = await ref.ask({"action": "inc", "n": 1}) + print(resp) asyncio.run(asyncio.gather(seed(), worker())) diff --git a/docs/src/examples/distributed_counter.zh.md b/docs/src/examples/distributed_counter.zh.md index df6b4daed..936148183 100644 --- a/docs/src/examples/distributed_counter.zh.md +++ b/docs/src/examples/distributed_counter.zh.md @@ -7,43 +7,44 @@ ## 模式说明 1. 启动 seed 节点,并创建一个**public 的具名 Actor** -2. 启动其它节点加入集群,通过名称 **resolve/find** +2. 启动其它节点加入集群,通过名称 **resolve** 3. 使用 `ask` 远程更新状态并获取返回值 ## 示例草图 ```python import asyncio -from pulsing.actor import Actor, Message, SystemConfig, create_actor_system +import pulsing as pul -class Counter(Actor): +class Counter: def __init__(self): self.v = 0 - def receive(self, msg: Message) -> Message: - if msg.msg_type == "Inc": - self.v += int(msg.to_json().get("n", 1)) - return Message.from_json("Value", {"v": self.v}) - if msg.msg_type == "Get": - return Message.from_json("Value", {"v": self.v}) - return Message.empty() + async def receive(self, msg): + if msg.get("action") == "inc": + self.v += msg.get("n", 1) + return {"v": self.v} + if msg.get("action") == "get": + return {"v": self.v} + return {} async def seed(): - system = await create_actor_system(SystemConfig.with_addr("0.0.0.0:8000")) - await system.spawn("global_counter", Counter(), public=True) + system = await pul.actor_system(addr="0.0.0.0:8000") + await system.spawn(Counter(), name="global_counter", public=True) await asyncio.Event().wait() async def worker(): - system = await create_actor_system( - SystemConfig.with_addr("0.0.0.0:8001").with_seeds(["127.0.0.1:8000"]) + system = await pul.actor_system( + addr="0.0.0.0:8001", + seeds=["127.0.0.1:8000"] ) await asyncio.sleep(1.0) - ref = await system.find("global_counter") - resp = await ref.ask(Message.from_json("Inc", {"n": 1})) - print(resp.to_json()) + ref = await system.resolve("global_counter") + resp = await ref.ask({"action": "inc", "n": 1}) + print(resp) asyncio.run(asyncio.gather(seed(), worker())) diff --git a/docs/src/examples/ping_pong.md b/docs/src/examples/ping_pong.md index 8eeb82eb7..5d460b758 100644 --- a/docs/src/examples/ping_pong.md +++ b/docs/src/examples/ping_pong.md @@ -6,10 +6,10 @@ The simplest actor communication example. ```python import asyncio -from pulsing.actor import Actor, SystemConfig, create_actor_system +import pulsing as pul -class PingPong(Actor): +class PingPong: async def receive(self, msg): if msg == "ping": return "pong" @@ -17,8 +17,8 @@ class PingPong(Actor): async def main(): - system = await create_actor_system(SystemConfig.standalone()) - actor = await system.spawn("pingpong", PingPong()) + system = await pul.actor_system() + actor = await system.spawn(PingPong()) print(await actor.ask("ping")) # -> pong print(await actor.ask("hello")) # -> echo: hello @@ -37,7 +37,7 @@ python examples/python/ping_pong.py ## Key Points -- `Actor` is the base class - implement `receive()` to handle messages +- Implement `receive()` to handle messages - **Any Python object** can be a message (string, dict, list, etc.) - `actor.ask(msg)` sends a message and waits for response - `system.shutdown()` cleanly stops all actors diff --git a/docs/src/examples/ping_pong.zh.md b/docs/src/examples/ping_pong.zh.md index 4e41ca6a3..1b03a0410 100644 --- a/docs/src/examples/ping_pong.zh.md +++ b/docs/src/examples/ping_pong.zh.md @@ -6,10 +6,10 @@ ```python import asyncio -from pulsing.actor import Actor, SystemConfig, create_actor_system +import pulsing as pul -class PingPong(Actor): +class PingPong: async def receive(self, msg): if msg == "ping": return "pong" @@ -17,8 +17,8 @@ class PingPong(Actor): async def main(): - system = await create_actor_system(SystemConfig.standalone()) - actor = await system.spawn("pingpong", PingPong()) + system = await pul.actor_system() + actor = await system.spawn(PingPong()) print(await actor.ask("ping")) # -> pong print(await actor.ask("hello")) # -> echo: hello @@ -37,7 +37,7 @@ python examples/python/ping_pong.py ## 要点 -- `Actor` 是基类,实现 `receive()` 处理消息 +- 实现 `receive()` 处理消息 - **任意 Python 对象**都可以作为消息(字符串、字典、列表等) - `actor.ask(msg)` 发送消息并等待响应 - `system.shutdown()` 干净地停止所有 Actor diff --git a/docs/src/guide/queue.md b/docs/src/guide/queue.md index fb355e380..ce1019a62 100644 --- a/docs/src/guide/queue.md +++ b/docs/src/guide/queue.md @@ -61,21 +61,19 @@ flowchart TB ```python import asyncio -from pulsing.actor import SystemConfig, create_actor_system -from pulsing.queue import write_queue, read_queue +import pulsing as pul async def main(): - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() try: - writer = await write_queue( - system, - topic="my_queue", + writer = await system.queue.write( + "my_queue", bucket_column="user_id", num_buckets=4, batch_size=10, ) - reader = await read_queue(system, topic="my_queue") + reader = await system.queue.read("my_queue") # write await writer.put({"user_id": "u1", "payload": "hello"}) @@ -98,15 +96,15 @@ asyncio.run(main()) If you need a blocking API (e.g. called from a thread), use `.sync()`: ```python -writer = (await write_queue(system, "my_queue")).sync() -reader = (await read_queue(system, "my_queue")).sync() +writer = (await system.queue.write("my_queue")).sync() +reader = (await system.queue.read("my_queue")).sync() writer.put({"id": "1", "value": 100}) records = reader.get(limit=10) writer.flush() ``` -Note: don’t call the sync wrapper **inside** an async function (it blocks). +Note: don't call the sync wrapper **inside** an async function (it blocks). ## Partitioning & bucketing @@ -116,7 +114,7 @@ Note: don’t call the sync wrapper **inside** an async function (it blocks). ## Reading modes -`read_queue` supports: +`system.queue.read()` supports: - **All buckets** (default): one reader iterates all buckets - **Specific buckets**: `bucket_id=` or `bucket_ids=` @@ -125,8 +123,8 @@ Note: don’t call the sync wrapper **inside** an async function (it blocks). Example: ```python -reader0 = await read_queue(system, "q", rank=0, world_size=2, num_buckets=4) # [0, 2] -reader1 = await read_queue(system, "q", rank=1, world_size=2, num_buckets=4) # [1, 3] +reader0 = await system.queue.read("q", rank=0, world_size=2, num_buckets=4) # [0, 2] +reader1 = await system.queue.read("q", rank=1, world_size=2, num_buckets=4) # [1, 3] ``` ## Streaming & blocking reads @@ -169,9 +167,8 @@ flowchart LR The default `MemoryBackend` stores data in memory without persistence: ```python -writer = await write_queue( - system, - topic="my_queue", +writer = await system.queue.write( + "my_queue", backend="memory", # default, can be omitted ) ``` @@ -182,24 +179,25 @@ For persistent storage, use backends from [Persisting](https://github.com/DeepLi ```python import pulsing as pul +from pulsing.queue import register_backend import persisting as pst # Register backends from Persisting -pul.queue.register_backend("lance", pst.queue.LanceBackend) -pul.queue.register_backend("persisting", pst.queue.PersistingBackend) +register_backend("lance", pst.queue.LanceBackend) +register_backend("persisting", pst.queue.PersistingBackend) + +system = await pul.actor_system() # Use Lance backend for persistence -writer = await pul.queue.write_queue( - system, - topic="my_queue", +writer = await system.queue.write( + "my_queue", backend="lance", storage_path="/data/queues", ) # Or use enhanced Persisting backend with WAL -writer = await pul.queue.write_queue( - system, - topic="my_queue", +writer = await system.queue.write( + "my_queue", backend="persisting", storage_path="/data/queues", backend_options={"enable_wal": True}, @@ -211,7 +209,7 @@ writer = await pul.queue.write_queue( Implement the `StorageBackend` protocol and register: ```python -import pulsing as pul +from pulsing.queue import register_backend class MyBackend: async def put(self, record): ... @@ -219,8 +217,8 @@ class MyBackend: async def flush(self): ... # ... other methods -pul.queue.register_backend("my_backend", MyBackend) -writer = await pul.queue.write_queue(system, "topic", backend="my_backend") +register_backend("my_backend", MyBackend) +writer = await system.queue.write("topic", backend="my_backend") ``` ## Multi-consumer offsets: strategy & limitations diff --git a/docs/src/guide/queue.zh.md b/docs/src/guide/queue.zh.md index b8d318702..1a12cb49e 100644 --- a/docs/src/guide/queue.zh.md +++ b/docs/src/guide/queue.zh.md @@ -61,21 +61,19 @@ flowchart TB ```python import asyncio -from pulsing.actor import SystemConfig, create_actor_system -from pulsing.queue import write_queue, read_queue +import pulsing as pul async def main(): - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() try: - writer = await write_queue( - system, - topic="my_queue", + writer = await system.queue.write( + "my_queue", bucket_column="user_id", num_buckets=4, batch_size=10, ) - reader = await read_queue(system, topic="my_queue") + reader = await system.queue.read("my_queue") # 写入 await writer.put({"user_id": "u1", "payload": "hello"}) @@ -98,8 +96,8 @@ asyncio.run(main()) 如果你需要阻塞式 API(例如在线程里调用),用 `.sync()`: ```python -writer = (await write_queue(system, "my_queue")).sync() -reader = (await read_queue(system, "my_queue")).sync() +writer = (await system.queue.write("my_queue")).sync() +reader = (await system.queue.read("my_queue")).sync() writer.put({"id": "1", "value": 100}) records = reader.get(limit=10) @@ -116,7 +114,7 @@ writer.flush() ## 读取模式 -`read_queue` 支持: +`system.queue.read()` 支持: - **读取所有 bucket**(默认) - **读取指定 bucket**:`bucket_id=` / `bucket_ids=` @@ -125,8 +123,8 @@ writer.flush() 例子: ```python -reader0 = await read_queue(system, "q", rank=0, world_size=2, num_buckets=4) # [0, 2] -reader1 = await read_queue(system, "q", rank=1, world_size=2, num_buckets=4) # [1, 3] +reader0 = await system.queue.read("q", rank=0, world_size=2, num_buckets=4) # [0, 2] +reader1 = await system.queue.read("q", rank=1, world_size=2, num_buckets=4) # [1, 3] ``` ## 流式读取与阻塞等待 @@ -169,9 +167,8 @@ flowchart LR 默认的 `MemoryBackend` 将数据存储在内存中,无持久化: ```python -writer = await write_queue( - system, - topic="my_queue", +writer = await system.queue.write( + "my_queue", backend="memory", # 默认,可省略 ) ``` @@ -182,24 +179,25 @@ writer = await write_queue( ```python import pulsing as pul +from pulsing.queue import register_backend import persisting as pst # 从 Persisting 注册后端 -pul.queue.register_backend("lance", pst.queue.LanceBackend) -pul.queue.register_backend("persisting", pst.queue.PersistingBackend) +register_backend("lance", pst.queue.LanceBackend) +register_backend("persisting", pst.queue.PersistingBackend) + +system = await pul.actor_system() # 使用 Lance 后端实现持久化 -writer = await pul.queue.write_queue( - system, - topic="my_queue", +writer = await system.queue.write( + "my_queue", backend="lance", storage_path="/data/queues", ) # 或使用增强版 Persisting 后端(支持 WAL) -writer = await pul.queue.write_queue( - system, - topic="my_queue", +writer = await system.queue.write( + "my_queue", backend="persisting", storage_path="/data/queues", backend_options={"enable_wal": True}, @@ -211,7 +209,7 @@ writer = await pul.queue.write_queue( 实现 `StorageBackend` 协议并注册: ```python -import pulsing as pul +from pulsing.queue import register_backend class MyBackend: async def put(self, record): ... @@ -219,8 +217,8 @@ class MyBackend: async def flush(self): ... # ... 其他方法 -pul.queue.register_backend("my_backend", MyBackend) -writer = await pul.queue.write_queue(system, "topic", backend="my_backend") +register_backend("my_backend", MyBackend) +writer = await system.queue.write("topic", backend="my_backend") ``` ## 多消费者 offset:策略与局限 diff --git a/examples/inspect/demo_service.py b/examples/inspect/demo_service.py index 894473847..716154471 100644 --- a/examples/inspect/demo_service.py +++ b/examples/inspect/demo_service.py @@ -21,7 +21,8 @@ import random import time -from pulsing.actor import Actor, ActorId, Message, SystemConfig, create_actor_system +import pulsing as pul +from pulsing.actor import Actor, ActorId, Message class WorkerActor(Actor): @@ -34,7 +35,7 @@ def __init__(self, worker_id: str): def on_start(self, actor_id: ActorId): print(f"[Worker {self.worker_id}] Started") - def receive(self, msg: Message) -> Message: + async def receive(self, msg: Message) -> Message: if msg.msg_type == "ProcessTask": task = msg.to_json().get("task", "") self.tasks_processed += 1 @@ -60,7 +61,7 @@ def __init__(self): def on_start(self, actor_id: ActorId): print("[Dispatcher] Started") - def receive(self, msg: Message) -> Message: + async def receive(self, msg: Message) -> Message: if msg.msg_type == "RouteTask": self.tasks_dispatched += 1 task = msg.to_json().get("task", "") @@ -90,7 +91,7 @@ def __init__(self): def on_start(self, actor_id: ActorId): print("[Cache] Started") - def receive(self, msg: Message) -> Message: + async def receive(self, msg: Message) -> Message: if msg.msg_type == "Get": key = msg.to_json().get("key", "") value = self.cache.get(key, None) @@ -114,13 +115,14 @@ async def run_node(port: int, seed: str | None): print(f"Pulsing Demo Service - Node on port {port}") print(f"{'=' * 60}\n") - config = SystemConfig.with_addr(f"127.0.0.1:{port}") - if seed: - config = config.with_seeds([seed]) - print(f"Joining cluster via: {seed}") + addr = f"127.0.0.1:{port}" + seeds = [seed] if seed else None - system = await create_actor_system(config) - print(f"✓ System started: {system.node_id} @ {system.addr}\n") + system = await pul.actor_system(addr, seeds=seeds) + print(f"✓ System started: {system.node_id} @ {system.addr}") + if seed: + print(f" Joined via: {seed}") + print() # Create different actors based on node role if seed is None: diff --git a/examples/python/cluster.py b/examples/python/cluster.py index 52721a8ab..20de0f67a 100644 --- a/examples/python/cluster.py +++ b/examples/python/cluster.py @@ -12,51 +12,48 @@ import argparse import asyncio -from pulsing.actor import Actor, ActorId, Message, SystemConfig, create_actor_system +import pulsing as pul -class SharedCounter(Actor): +class SharedCounter: def __init__(self, node_id: str): self.count = 0 self.node_id = node_id - def on_start(self, actor_id: ActorId): + def on_start(self, actor_id): print(f"[{actor_id}] Started on {self.node_id}") - def receive(self, msg: Message) -> Message: - if msg.msg_type == "GetCount": - return Message.from_json( - "CountResponse", {"count": self.count, "from_node": self.node_id} - ) - elif msg.msg_type == "Increment": - n = msg.to_json().get("n", 1) + async def receive(self, msg): + if msg.get("action") == "get": + return {"count": self.count, "from_node": self.node_id} + elif msg.get("action") == "incr": + n = msg.get("n", 1) self.count += n print(f"[{self.node_id}] +{n} -> {self.count}") - return Message.from_json( - "CountResponse", {"count": self.count, "from_node": self.node_id} - ) - return Message.empty() + return {"count": self.count, "from_node": self.node_id} + return {"error": "unknown action"} async def run_node(port: int, seed: str | None): print(f"=== Pulsing Cluster - Node {port} ===\n") - config = SystemConfig.with_addr(f"127.0.0.1:{port}") - if seed: - config = config.with_seeds([seed]) - print(f"Joining via: {seed}") - - system = await create_actor_system(config) - print(f"✓ Started: {system.node_id} @ {system.addr}\n") + addr = f"127.0.0.1:{port}" + seeds = [seed] if seed else None - path = "services/counter" + system = await pul.actor_system(addr, seeds=seeds) + print(f"✓ Started: {system.node_id} @ {system.addr}") + if seed: + print(f" Joined via: {seed}") + print() if seed is None: # Node 1: Create actor - await system.spawn_named( - path, "counter", SharedCounter(str(system.node_id)), public=True + await system.spawn( + SharedCounter(str(system.node_id)), + name="counter", + public=True, ) - print(f"✓ Created: {path}") + print("✓ Created: counter") print("Start node 2: python cluster.py --port 8001 --seed 127.0.0.1:8000\n") try: @@ -75,7 +72,7 @@ async def run_node(port: int, seed: str | None): actor = None for _ in range(10): try: - actor = await system.resolve_named(path) + actor = await system.resolve("counter") break except Exception: print(".", end="", flush=True) @@ -87,14 +84,12 @@ async def run_node(port: int, seed: str | None): print("✓ Resolved\n") - # Interact - resp = (await actor.ask(Message.from_json("GetCount", {}))).to_json() + # Interact using simple Python dicts + resp = await actor.ask({"action": "get"}) print(f"Initial: {resp['count']} (from {resp['from_node']})") for i in range(1, 4): - resp = ( - await actor.ask(Message.from_json("Increment", {"n": i * 10})) - ).to_json() + resp = await actor.ask({"action": "incr", "n": i * 10}) print(f"After +{i * 10}: {resp['count']} (from {resp['from_node']})") print("\n✓ Done!") diff --git a/examples/python/distributed_queue.py b/examples/python/distributed_queue.py index 3613e1ae4..f666dd6b1 100644 --- a/examples/python/distributed_queue.py +++ b/examples/python/distributed_queue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Distributed memory queue example -Demonstrates how to use write_queue and read_queue for basic data read/write operations. +Demonstrates how to use system.queue.write/read for basic data read/write operations. Architecture features: - Each bucket corresponds to an independent BucketStorage Actor @@ -11,8 +11,7 @@ import asyncio import logging -from pulsing.actor import SystemConfig, create_actor_system -from pulsing.queue import read_queue, write_queue +import pulsing as pul logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" @@ -25,14 +24,13 @@ async def main(): logger.info("=== Distributed Memory Queue Example ===\n") # Create Actor system - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() logger.info("✓ Actor system started\n") try: # Producer: open queue for writing - writer = await write_queue( - system, - topic="my_queue", + writer = await system.queue.write( + "my_queue", bucket_column="user_id", # Bucket by user_id num_buckets=4, batch_size=10, @@ -40,7 +38,7 @@ async def main(): logger.info("✓ Queue created (one Actor per bucket)\n") # Consumer: open queue for reading - reader = await read_queue(system, topic="my_queue") + reader = await system.queue.read("my_queue") logger.info("✓ Queue opened\n") # Write data (data immediately visible to consumers, no need to wait for persistence) diff --git a/examples/python/message_patterns.py b/examples/python/message_patterns.py index c16b55197..8a556a2aa 100644 --- a/examples/python/message_patterns.py +++ b/examples/python/message_patterns.py @@ -7,13 +7,8 @@ import asyncio -from pulsing.actor import ( - Actor, - Message, - StreamMessage, - SystemConfig, - create_actor_system, -) +import pulsing as pul +from pulsing.actor import Actor, Message, StreamMessage class PatternDemo(Actor): @@ -53,8 +48,8 @@ async def produce(): async def main(): - system = await create_actor_system(SystemConfig.standalone()) - actor = await system.spawn(PatternDemo(, name="demo")) + system = await pul.actor_system() + actor = await system.spawn(PatternDemo(), name="demo") # Pattern 1: Dict messages print("--- Dict Messages ---") diff --git a/examples/python/named_actors.py b/examples/python/named_actors.py index 48b27e00d..b1a9a61e4 100644 --- a/examples/python/named_actors.py +++ b/examples/python/named_actors.py @@ -2,22 +2,23 @@ """ Named Actors Example -Named actors can be discovered by service path (e.g., "services/echo") -instead of specific ActorId, enabling service discovery. +Named actors can be discovered by name instead of specific ActorId, +enabling service discovery. Usage: python examples/python/named_actors.py """ import asyncio -from pulsing.actor import Actor, ActorId, Message, SystemConfig, create_actor_system +import pulsing as pul +from pulsing.actor import Actor, ActorId, Message class EchoActor(Actor): def on_start(self, actor_id: ActorId): print(f"[{actor_id}] Started") - def receive(self, msg: Message) -> Message: + async def receive(self, msg: Message) -> Message: message = msg.to_json().get("message", "") print(f"[Echo] {message}") return Message.from_json( @@ -29,23 +30,22 @@ def receive(self, msg: Message) -> Message: async def main(): print("=== Pulsing Named Actors ===\n") - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() print(f"✓ System started: {system.node_id}\n") - # Create named actor - path = "services/echo" - await system.spawn_named(path, "echo", EchoActor(), public=True) - print(f"✓ Created: {path} (local name: echo)\n") + # Create named public actor + await system.spawn(EchoActor(), name="echo", public=True) + print("✓ Created: echo (public=True)\n") - # Resolve by service path - print("--- Resolve by path ---") - actor = await system.resolve_named(path) + # Resolve by name + print("--- Resolve by name ---") + actor = await system.resolve("echo") resp = (await actor.ask(Message.from_json("Echo", {"message": "Hello!"}))).to_json() print(f"Response: {resp['echo']}\n") # List instances - instances = await system.get_named_instances(path) - print(f"Instances of '{path}': {len(instances)}") + instances = await system.get_named_instances("actors/echo") + print(f"Instances of 'actors/echo': {len(instances)}") for i in instances: print(f" {i['node_id']} @ {i['addr']} ({i['status']})") diff --git a/examples/python/ping_pong.py b/examples/python/ping_pong.py index 10c748587..12085be6d 100644 --- a/examples/python/ping_pong.py +++ b/examples/python/ping_pong.py @@ -6,10 +6,10 @@ """ import asyncio -from pulsing.actor import Actor, SystemConfig, create_actor_system +import pulsing as pul -class PingPong(Actor): +class PingPong: async def receive(self, msg): if msg == "ping": return "pong" @@ -17,8 +17,8 @@ async def receive(self, msg): async def main(): - system = await create_actor_system(SystemConfig.standalone()) - actor = await system.spawn(PingPong(, name="pingpong")) + system = await pul.actor_system() + actor = await system.spawn(PingPong()) # Simple string message print(await actor.ask("ping")) # -> pong diff --git a/examples/python/sync_queue_example.py b/examples/python/sync_queue_example.py index 40843e2fc..79d451af8 100644 --- a/examples/python/sync_queue_example.py +++ b/examples/python/sync_queue_example.py @@ -11,8 +11,7 @@ import asyncio import logging -from pulsing.actor import SystemConfig, create_actor_system -from pulsing.queue import read_queue, write_queue +import pulsing as pul logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" @@ -25,15 +24,14 @@ async def main(): logger.info("=== Distributed Memory Queue Example (Synchronous Version) ===\n") # Create Actor system - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() logger.info("✓ Actor system started\n") try: # Producer: open queue for writing, get synchronous wrapper writer = ( - await write_queue( - system, - topic="my_queue", + await system.queue.write( + "my_queue", bucket_column="user_id", # Bucket by user_id num_buckets=4, batch_size=10, @@ -42,7 +40,7 @@ async def main(): logger.info("✓ Queue created (synchronous writer)\n") # Consumer: open queue for reading, get synchronous wrapper - reader = (await read_queue(system, topic="my_queue")).sync() + reader = (await system.queue.read("my_queue")).sync() logger.info("✓ Queue opened (synchronous reader)\n") # Synchronously write data diff --git a/llms.binding.md b/llms.binding.md new file mode 100644 index 000000000..224c3e0c1 --- /dev/null +++ b/llms.binding.md @@ -0,0 +1,197 @@ +# Pulsing API Reference for LLMs + +## Overview + +`Pulsing`是一款分布式系统通信框架,可以作为任意分布式系统的通信骨架,以方便快速搭建分布式系统和应用。 + +## Python 接口 + +### Actor System风格接口 + +```Python +import pulsing as pul + +system = await pul.actor_system( + addr: str | None = None, + *, + seeds: list[str] | None = None, + passphrase: str | None = None +) -> ActorSystem + +await system.shutdown() + +class MyActor: + async def receive(self, msg: Any) -> Any: + ... + +actorref = await system.spawn( + actor: Actor, # MyActor() + *, + name: str | None = None, + public: bool = False, + restart_policy: str = "never", + max_restarts: int = 3, + min_backoff: float = 0.1, + max_backoff: float = 30.0 +) -> ActorRef + +actorref = await system.refer(actorid: ActorId | str) -> ActorRef + +actorref = await system.resolve( + name: str, + *, + node_id: int | None = None +) -> ActorRef + +response = await actorref.ask(request: Any) -> Any + +await actorref.tell(msg: Any) -> None + + +@pul.remote +class Counter: + # 同步处理函数 + def incr(self): + ... + + # 异步处理函数 + async def desc(self): + ... + +# 使用 +counter = await Counter.spawn(name="counter") +result = await counter.incr() # 返回 ActorProxy,直接调用方法 + +# 队列接口 +writer = await system.queue.write( + topic: str, + *, + bucket_column: str = "id", + num_buckets: int = 4, + batch_size: int = 100, + storage_path: str | None = None, + backend: str = "memory", +) -> QueueWriter + +await writer.put(record: dict | list[dict]) -> None +await writer.flush() -> None + +reader = await system.queue.read( + topic: str, + *, + bucket_id: int | None = None, + bucket_ids: list[int] | None = None, + rank: int | None = None, + world_size: int | None = None, + num_buckets: int = 4, +) -> QueueReader + +records = await reader.get(limit: int = 100, wait: bool = False) -> list[dict] + +# 队列使用示例 +writer = await system.queue.write("my_queue", bucket_column="user_id") +await writer.put({"user_id": "u1", "data": "hello"}) + +reader = await system.queue.read("my_queue") +records = await reader.get(limit=10) +``` + +### Ray风格异步接口 + +```python +import pulsing as pul + +# 初始化全局系统 +await pul.init( + addr: str | None = None, + *, + seeds: list[str] | None = None, + passphrase: str | None = None +) -> ActorSystem + +await pul.shutdown() + +# 生成 Actor(使用全局系统) +actorref = await pul.spawn( + actor: Actor, + *, + name: str | None = None, + public: bool = False, + restart_policy: str = "never", + max_restarts: int = 3, + min_backoff: float = 0.1, + max_backoff: float = 30.0 +) -> ActorRef + +# 通过 ActorId 获取引用(使用全局系统) +actorref = await pul.refer(actorid: ActorId | str) -> ActorRef + +# 通过名称解析 Actor(使用全局系统) +actorref = await pul.resolve( + name: str, + *, + node_id: int | None = None +) -> ActorRef + +# 发送消息并等待响应 +response = await actorref.ask(request: Any) -> Any + +# 发送消息(不等待响应) +await actorref.tell(msg: Any) -> None + +# 将 ActorRef 绑定到类型,生成 ActorProxy +proxy = Counter.resolve(name) + +@pul.remote +class Counter: + def __init__(self, init=0): self.value = init + + # 同步处理函数 + def incr(self): + ... + + # 异步处理函数 + async def desc(self): + ... + +# 使用方式1:通过 spawn 创建 +counter = await Counter.spawn(name="counter") +result = await counter.incr() # 返回 ActorProxy,直接调用方法 + +# 使用方式2:通过 resolve 解析已有 actor +proxy = await Counter.resolve("counter") +result = await proxy.incr() + +``` + +### Ray风格兼容接口 + +```python +from pulsing.compat import ray + +# 初始化(同步接口,内部使用异步) +ray.init( + address: str | None = None, + *, + ignore_reinit_error: bool = False, + **kwargs +) -> None + +# 装饰器:将类转换为 Actor +@ray.remote +class MyActor: + def __init__(self, ...): ... + def method(self, ...): ... + +# 创建 Actor(同步接口) +actor_handle = MyActor.remote(...) -> _ActorHandle + +# 调用方法(返回 ObjectRef) +result_ref = actor_handle.method.remote(...) -> ObjectRef + +# 获取结果(同步接口) +result = ray.get(result_ref, timeout: float | None = None) -> Any + +# 关闭系统 +ray.shutdown() -> None +``` diff --git a/python/pulsing/__init__.py b/python/pulsing/__init__.py index 2c87dab43..d5114efaf 100644 --- a/python/pulsing/__init__.py +++ b/python/pulsing/__init__.py @@ -71,7 +71,7 @@ def incr(self): self.value += 1; return self.value resolve, # Types Actor, - ActorSystem, + ActorSystem as _ActorSystem, ActorRef, ActorId, ActorProxy, @@ -84,6 +84,26 @@ def incr(self): self.value += 1; return self.value ) +class ActorSystem: + """ActorSystem wrapper with queue API + + This wraps the Rust ActorSystem and adds Python-level extensions + like the queue API. + """ + + def __init__(self, inner: _ActorSystem): + self._inner = inner + from pulsing.queue import QueueAPI + self.queue = QueueAPI(inner) + + def __getattr__(self, name): + # Delegate all other attributes to the inner ActorSystem + return getattr(self._inner, name) + + def __repr__(self): + return f"ActorSystem(node_id={self._inner.node_id}, addr={self._inner.addr})" + + async def actor_system( addr: str | None = None, *, @@ -101,7 +121,7 @@ async def actor_system( passphrase: Enable TLS with this passphrase Returns: - ActorSystem instance + ActorSystem instance with .queue API Example: import pulsing as pul @@ -123,6 +143,10 @@ async def actor_system( addr="0.0.0.0:8000", passphrase="my-secret" ) + + # Queue API + writer = await system.queue.write("my_topic") + reader = await system.queue.read("my_topic") """ # Build config if addr: @@ -137,11 +161,14 @@ async def actor_system( config = config.with_passphrase(passphrase) loop = asyncio.get_running_loop() - system = await ActorSystem.create(config, loop) + inner = await _ActorSystem.create(config, loop) + + # Wrap with Python ActorSystem + system = ActorSystem(inner) # Automatically register PythonActorService (for remote actor creation) - service = PythonActorService(system) - await system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) + service = PythonActorService(inner) + await inner.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) return system diff --git a/python/pulsing/actor/__init__.py b/python/pulsing/actor/__init__.py index 009a052ab..767dfe4c7 100644 --- a/python/pulsing/actor/__init__.py +++ b/python/pulsing/actor/__init__.py @@ -227,11 +227,14 @@ async def create_actor_system(config: SystemConfig) -> ActorSystem: The function also automatically registers PythonActorService for remote actor creation. + Note: For queue API, use `pul.actor_system()` instead which returns a wrapped + ActorSystem with `.queue` attribute. + Args: config: SystemConfig instance (use SystemConfig.standalone() or SystemConfig.with_addr()) Returns: - ActorSystem instance + ActorSystem instance (raw, without .queue API) Example: config = SystemConfig.with_addr("0.0.0.0:8000") diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index 63d599e48..892071082 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -888,20 +888,18 @@ async def ping(system: ActorSystem, node_id: int | None = None) -> dict: async def resolve( name: str, *, - system: ActorSystem | None = None, node_id: int | None = None, - methods: list[str] | None = None, -) -> ActorProxy: - """Resolve a named actor by name, return callable ActorProxy +) -> ActorRef: + """Resolve a named actor by name, return ActorRef + + For typed ActorProxy with method calls, use Counter.resolve(name) instead. Args: name: Actor name - system: ActorSystem instance, uses global system if not provided node_id: Target node ID, searches in cluster if not provided - methods: Optional list of method names. If not provided, allows calling any method (dynamic mode) Returns: - ActorProxy: Proxy object that can directly call methods + ActorRef: Low-level actor reference for ask/tell operations. Example: from pulsing.actor import init, remote, resolve @@ -913,24 +911,25 @@ class Counter: def __init__(self, init=0): self.value = init def increment(self): self.value += 1; return self.value - # Node A creates actor + # Create actor counter = await Counter.spawn(name="my_counter") - # Node B resolves and calls - proxy = await resolve("my_counter") - result = await proxy.increment() # Remote call + # Method 1: Use typed resolve (recommended) + proxy = await Counter.resolve("my_counter") + result = await proxy.increment() + + # Method 2: Use low-level resolve + ask + ref = await resolve("my_counter") + result = await ref.ask({"method": "increment", "args": [], "kwargs": {}}) """ from . import _global_system - if system is None: - if _global_system is None: - raise RuntimeError( - "Actor system not initialized. Call 'await init()' first." - ) - system = _global_system + if _global_system is None: + raise RuntimeError( + "Actor system not initialized. Call 'await init()' first." + ) - actor_ref = await system.resolve_named(name, node_id=node_id) - return ActorProxy(actor_ref, methods) + return await _global_system.resolve(name, node_id=node_id) RemoteClass = ActorClass diff --git a/python/pulsing/queue/README.md b/python/pulsing/queue/README.md index c2301d038..02b64007f 100644 --- a/python/pulsing/queue/README.md +++ b/python/pulsing/queue/README.md @@ -147,16 +147,14 @@ get_bucket_ref(system, topic, bucket_id) ```python import asyncio -from pulsing.actor import SystemConfig, create_actor_system -from pulsing.queue import read_queue, write_queue +import pulsing as pul async def main(): - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() # 生产者 - writer = await write_queue( - system, - topic="my_queue", + writer = await system.queue.write( + "my_queue", bucket_column="user_id", num_buckets=4, ) @@ -165,7 +163,7 @@ async def main(): await writer.put({"user_id": "u1", "message": "Hello"}) # 消费者 - reader = await read_queue(system, topic="my_queue") + reader = await system.queue.read("my_queue") # 读取数据(内存 + 持久化同时可见) records = await reader.get(limit=100) @@ -199,17 +197,16 @@ records = sync_reader.get(limit=100) # 同步读取 ## API -### `write_queue(system, topic, ...)` +### `system.queue.write(topic, ...)` 打开队列用于写入。 ```python -writer = await write_queue( - system, - topic="my_queue", +writer = await system.queue.write( + "my_queue", bucket_column="user_id", # 分桶列 - num_buckets=4, # 桶数量 - batch_size=100, # 批处理大小 + num_buckets=4, # 桶数量 + batch_size=100, # 批处理大小 ) await writer.put({"user_id": "u1", "msg": "hello"}) @@ -217,21 +214,21 @@ await writer.put([record1, record2, ...]) # 批量写入 await writer.flush() # 强制持久化 ``` -### `read_queue(system, topic, ...)` +### `system.queue.read(topic, ...)` 打开队列用于读取。支持三种模式: ```python # 1. 读取所有 bucket -reader = await read_queue(system, topic="my_queue") +reader = await system.queue.read("my_queue") # 2. 读取指定 bucket -reader = await read_queue(system, topic="my_queue", bucket_id=0) -reader = await read_queue(system, topic="my_queue", bucket_ids=[0, 2]) +reader = await system.queue.read("my_queue", bucket_id=0) +reader = await system.queue.read("my_queue", bucket_ids=[0, 2]) # 3. 分布式消费:通过 rank/world_size 自动分配 bucket -reader0 = await read_queue(system, "q", rank=0, world_size=2, num_buckets=4) # bucket 0, 2 -reader1 = await read_queue(system, "q", rank=1, world_size=2, num_buckets=4) # bucket 1, 3 +reader0 = await system.queue.read("q", rank=0, world_size=2, num_buckets=4) # bucket 0, 2 +reader1 = await system.queue.read("q", rank=1, world_size=2, num_buckets=4) # bucket 1, 3 # 读取数据 records = await reader.get(limit=100) @@ -284,20 +281,20 @@ pip install persisting[lance] ```python # 使用默认内存后端 -writer = await write_queue(system, "my_queue") +writer = await system.queue.write("my_queue") # 使用 persisting 的 Lance 持久化后端 from persisting.queue import LanceBackend from pulsing.queue import register_backend register_backend("lance", LanceBackend) -writer = await write_queue(system, "my_queue", backend="lance") +writer = await system.queue.write("my_queue", backend="lance") # 使用增强版后端 from persisting.queue import PersistingBackend register_backend("persisting", PersistingBackend) -writer = await write_queue( - system, "my_queue", +writer = await system.queue.write( + "my_queue", backend="persisting", backend_options={"enable_wal": True, "enable_metrics": True} ) diff --git a/python/pulsing/queue/__init__.py b/python/pulsing/queue/__init__.py index c1352a202..e955836a1 100644 --- a/python/pulsing/queue/__init__.py +++ b/python/pulsing/queue/__init__.py @@ -11,16 +11,19 @@ - Persistent backends require installing the persisting package Example: - # Use default in-memory backend - writer = await write_queue(system, "my_queue") - - # Use Lance persistent backend provided by persisting - from persisting.queue import LanceBackend - from pulsing.queue import register_backend - register_backend("lance", LanceBackend) - writer = await write_queue(system, "my_queue", backend="lance") + system = await pul.actor_system() + + # Write to queue + writer = await system.queue.write("my_queue") + await writer.put({"id": "1", "data": "hello"}) + + # Read from queue + reader = await system.queue.read("my_queue") + records = await reader.get(limit=10) """ +from typing import TYPE_CHECKING, Any + from .backend import ( MemoryBackend, StorageBackend, @@ -38,7 +41,110 @@ from .storage import BucketStorage from .sync_queue import SyncQueue, SyncQueueReader, SyncQueueWriter +if TYPE_CHECKING: + from pulsing._core import ActorSystem + + +class QueueAPI: + """Queue API entry point via system.queue + + Example: + system = await pul.actor_system() + + # Write + writer = await system.queue.write("my_queue") + await writer.put({"id": "1", "data": "hello"}) + + # Read + reader = await system.queue.read("my_queue") + records = await reader.get(limit=10) + """ + + def __init__(self, system: "ActorSystem"): + self._system = system + + async def write( + self, + topic: str, + *, + bucket_column: str = "id", + num_buckets: int = 4, + batch_size: int = 100, + storage_path: str | None = None, + backend: str | type = "memory", + backend_options: dict[str, Any] | None = None, + ) -> QueueWriter: + """Open queue for writing + + Args: + topic: Queue topic name + bucket_column: Column used for bucketing (default: "id") + num_buckets: Number of buckets (default: 4) + batch_size: Batch size for writes (default: 100) + storage_path: Storage path (default: ./queue_storage/{topic}) + backend: Storage backend ("memory" or custom) + backend_options: Additional backend options + + Returns: + QueueWriter for put/flush operations + """ + return await write_queue( + self._system, + topic, + bucket_column=bucket_column, + num_buckets=num_buckets, + batch_size=batch_size, + storage_path=storage_path, + backend=backend, + backend_options=backend_options, + ) + + async def read( + self, + topic: str, + *, + bucket_id: int | None = None, + bucket_ids: list[int] | None = None, + rank: int | None = None, + world_size: int | None = None, + num_buckets: int = 4, + storage_path: str | None = None, + backend: str | type = "memory", + backend_options: dict[str, Any] | None = None, + ) -> QueueReader: + """Open queue for reading + + Args: + topic: Queue topic name + bucket_id: Single bucket to read from + bucket_ids: List of buckets to read from + rank: Consumer rank for distributed consumption + world_size: Total consumers for distributed consumption + num_buckets: Number of buckets (default: 4) + storage_path: Storage path + backend: Storage backend (must match writer) + backend_options: Additional backend options + + Returns: + QueueReader for get operations + """ + return await read_queue( + self._system, + topic, + bucket_id=bucket_id, + bucket_ids=bucket_ids, + rank=rank, + world_size=world_size, + num_buckets=num_buckets, + storage_path=storage_path, + backend=backend, + backend_options=backend_options, + ) + + __all__ = [ + # High-level API + "QueueAPI", # Async API "Queue", "QueueWriter", diff --git a/tests/python/test_remote_decorator.py b/tests/python/test_remote_decorator.py index 023841689..eee08bc98 100644 --- a/tests/python/test_remote_decorator.py +++ b/tests/python/test_remote_decorator.py @@ -457,7 +457,7 @@ def ping(self): await SimpleService.spawn(name="simple_svc") # Use module-level resolve (dynamic mode) - proxy = await resolve("simple_svc") + proxy = await SimpleService.resolve("simple_svc") assert await proxy.ping() == "pong" finally: From 9ee5789bc80defce8da62e2ce1542448f7730137 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 09:01:26 +0800 Subject: [PATCH 05/24] Refactor actor system initialization to use pul.actor_system - Replaced instances of create_actor_system with pul.actor_system across multiple benchmark and test files for consistency. - Updated actor system configuration to utilize named parameters for address and seed nodes, enhancing clarity in system setup. - Improved documentation and test cases to reflect the new actor system initialization method, ensuring comprehensive coverage and usability. --- benchmarks/large_scale_stress_test.py | 12 +++--- .../large_scale_stress_test_pulsing_single.py | 5 ++- benchmarks/queue_benchmark.py | 12 +++--- python/pulsing/__init__.py | 9 ++-- python/pulsing/actor/__init__.py | 42 ++++--------------- python/pulsing/actor/remote.py | 10 ++--- python/pulsing/autogen/runtime.py | 8 +++- python/pulsing/compat/ray.py | 12 ++++-- python/pulsing/langgraph/executor.py | 9 +++- python/pulsing/langgraph/wrapper.py | 9 +++- tests/python/test_actor_system.py | 11 ++--- tests/python/test_chaos.py | 10 ++--- tests/python/test_queue.py | 17 ++++---- tests/python/test_queue_backends.py | 5 +-- tests/python/test_sealed_message.py | 6 +-- tests/python/test_system_actor.py | 6 +-- tests/python/test_topic.py | 5 +-- 17 files changed, 86 insertions(+), 102 deletions(-) diff --git a/benchmarks/large_scale_stress_test.py b/benchmarks/large_scale_stress_test.py index 876404571..46efb358c 100755 --- a/benchmarks/large_scale_stress_test.py +++ b/benchmarks/large_scale_stress_test.py @@ -17,7 +17,8 @@ from collections import defaultdict from dataclasses import dataclass, field -from pulsing.actor import Actor, StreamMessage, SystemConfig, create_actor_system +import pulsing as pul +from pulsing.actor import Actor, StreamMessage, SystemConfig # ============================================================================ @@ -268,14 +269,15 @@ def flush(self): # Configure system port = (8000 if args.port == 0 else args.port) + rank - config = SystemConfig.with_addr(f"0.0.0.0:{port}") + addr = f"0.0.0.0:{port}" + seeds = None if args.seed_nodes: - config = config.with_seeds(args.seed_nodes) + seeds = args.seed_nodes elif rank > 0: - config = config.with_seeds([f"127.0.0.1:{8000 + rank - 1}"]) + seeds = [f"127.0.0.1:{8000 + rank - 1}"] - system = await create_actor_system(config) + system = await pul.actor_system(addr=addr, seeds=seeds) print(f"System started at {system.addr}") # Wait for cluster to stabilize diff --git a/benchmarks/large_scale_stress_test_pulsing_single.py b/benchmarks/large_scale_stress_test_pulsing_single.py index f982e75a0..1b9f11e28 100644 --- a/benchmarks/large_scale_stress_test_pulsing_single.py +++ b/benchmarks/large_scale_stress_test_pulsing_single.py @@ -16,7 +16,8 @@ from collections import defaultdict from dataclasses import dataclass, field -from pulsing.actor import Actor, StreamMessage, SystemConfig, create_actor_system +import pulsing as pul +from pulsing.actor import Actor, StreamMessage, SystemConfig # ============================================================================ @@ -235,7 +236,7 @@ async def main(): ) print(f"{'=' * 50}\n") - system = await create_actor_system(SystemConfig.with_addr(f"0.0.0.0:{args.port}")) + system = await pul.actor_system(addr=f"0.0.0.0:{args.port}") print(f"System started at {system.addr}") # Create workers diff --git a/benchmarks/queue_benchmark.py b/benchmarks/queue_benchmark.py index 936f58e6d..a09463265 100644 --- a/benchmarks/queue_benchmark.py +++ b/benchmarks/queue_benchmark.py @@ -29,7 +29,8 @@ from collections import defaultdict from dataclasses import dataclass, field -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul +from pulsing.actor import SystemConfig from pulsing.queue import read_queue, write_queue @@ -353,17 +354,18 @@ def close(self): else: port = args.port + rank - config = SystemConfig.with_addr(f"0.0.0.0:{port}") + addr = f"0.0.0.0:{port}" # Add seed nodes + seeds = None if args.seed_nodes: - config = config.with_seeds(args.seed_nodes) + seeds = args.seed_nodes elif rank > 0: prev_port = 9000 + (rank - 1) - config = config.with_seeds([f"127.0.0.1:{prev_port}"]) + seeds = [f"127.0.0.1:{prev_port}"] # Create system - system = await create_actor_system(config) + system = await pul.actor_system(addr=addr, seeds=seeds) print(f"[Process {rank}] ActorSystem started at {system.addr}") # Wait for cluster to stabilize diff --git a/python/pulsing/__init__.py b/python/pulsing/__init__.py index d5114efaf..24d9fc2a3 100644 --- a/python/pulsing/__init__.py +++ b/python/pulsing/__init__.py @@ -86,20 +86,21 @@ def incr(self): self.value += 1; return self.value class ActorSystem: """ActorSystem wrapper with queue API - + This wraps the Rust ActorSystem and adds Python-level extensions like the queue API. """ - + def __init__(self, inner: _ActorSystem): self._inner = inner from pulsing.queue import QueueAPI + self.queue = QueueAPI(inner) - + def __getattr__(self, name): # Delegate all other attributes to the inner ActorSystem return getattr(self._inner, name) - + def __repr__(self): return f"ActorSystem(node_id={self._inner.node_id}, addr={self._inner.addr})" diff --git a/python/pulsing/actor/__init__.py b/python/pulsing/actor/__init__.py index 767dfe4c7..61007d7c2 100644 --- a/python/pulsing/actor/__init__.py +++ b/python/pulsing/actor/__init__.py @@ -88,7 +88,13 @@ async def init( if passphrase: config = config.with_passphrase(passphrase) - _global_system = await create_actor_system(config) + loop = asyncio.get_running_loop() + _global_system = await ActorSystem.create(config, loop) + # Automatically register PythonActorService for remote actor creation + from .remote import PYTHON_ACTOR_SERVICE_NAME, PythonActorService + + service = PythonActorService(_global_system) + await _global_system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) return _global_system @@ -213,43 +219,9 @@ async def tell_with_timeout( # Service (for actor_system function) "PythonActorService", "PYTHON_ACTOR_SERVICE_NAME", - # Advanced constructor (documented) - "create_actor_system", ] -async def create_actor_system(config: SystemConfig) -> ActorSystem: - """ - Create a new ActorSystem with automatic event loop injection. - - This is a convenience function that wraps ActorSystem.create() to automatically - inject the current event loop, making it easier to use. - - The function also automatically registers PythonActorService for remote actor creation. - - Note: For queue API, use `pul.actor_system()` instead which returns a wrapped - ActorSystem with `.queue` attribute. - - Args: - config: SystemConfig instance (use SystemConfig.standalone() or SystemConfig.with_addr()) - - Returns: - ActorSystem instance (raw, without .queue API) - - Example: - config = SystemConfig.with_addr("0.0.0.0:8000") - system = await create_actor_system(config) - """ - loop = asyncio.get_running_loop() - system = await ActorSystem.create(config, loop) - - # Automatically register PythonActorService (for remote actor creation) - service = PythonActorService(system) - await system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) - - return system - - class Actor(ABC): """Base class for Python actors. Implement `receive` to handle messages. diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index 892071082..2971b4a2c 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -541,7 +541,7 @@ class ActorClass: counter = await Counter.spawn(init=10) 2. Explicit system: - system = await create_actor_system(config) + system = await pul.actor_system() counter = await Counter.local(system, init=10) """ @@ -619,7 +619,7 @@ async def local( ) -> ActorProxy: """Create actor locally with explicit system. - Note: Use create_actor_system() to create ActorSystem, + Note: Use pul.actor_system() to create ActorSystem, which automatically registers PythonActorService. """ actor_name = name or f"{self._cls.__name__}_{uuid.uuid4().hex[:8]}" @@ -658,7 +658,7 @@ async def remote( ) -> ActorProxy: """Create actor remotely (randomly selects a remote node). - Note: Use create_actor_system() to create ActorSystem, + Note: Use pul.actor_system() to create ActorSystem, which automatically registers PythonActorService. """ @@ -925,9 +925,7 @@ def increment(self): self.value += 1; return self.value from . import _global_system if _global_system is None: - raise RuntimeError( - "Actor system not initialized. Call 'await init()' first." - ) + raise RuntimeError("Actor system not initialized. Call 'await init()' first.") return await _global_system.resolve(name, node_id=node_id) diff --git a/python/pulsing/autogen/runtime.py b/python/pulsing/autogen/runtime.py index 9fef0e8ff..a7625dad7 100644 --- a/python/pulsing/autogen/runtime.py +++ b/python/pulsing/autogen/runtime.py @@ -36,8 +36,8 @@ ActorSystem, Message, SystemConfig, - create_actor_system, ) +from pulsing.actor.remote import PYTHON_ACTOR_SERVICE_NAME, PythonActorService logger = logging.getLogger("pulsing.autogen") T = TypeVar("T") @@ -117,7 +117,11 @@ async def start(self) -> None: # Standalone mode config = SystemConfig.standalone() - self._system = await create_actor_system(config) + loop = asyncio.get_running_loop() + self._system = await ActorSystem.create(config, loop) + # Register PythonActorService for remote actor creation + service = PythonActorService(self._system) + await self._system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) self._running = True mode = "distributed" if self.is_distributed else "standalone" diff --git a/python/pulsing/compat/ray.py b/python/pulsing/compat/ray.py index a90ed8e87..5f9329915 100644 --- a/python/pulsing/compat/ray.py +++ b/python/pulsing/compat/ray.py @@ -233,7 +233,8 @@ def init( _ensure_not_initialized(ignore_reinit_error) - from pulsing.actor import SystemConfig, create_actor_system + from pulsing.actor import ActorSystem, SystemConfig + from pulsing.actor.remote import PYTHON_ACTOR_SERVICE_NAME, PythonActorService # If we're already inside a running event loop (e.g., Jupyter/pytest-asyncio), # we must not call run_until_complete() on it. Use a dedicated background loop. @@ -249,8 +250,13 @@ def init( _loop = asyncio.new_event_loop() asyncio.set_event_loop(_loop) - config = SystemConfig.standalone() - _system = _run_coro_sync(create_actor_system(config)) + async def _create_system(): + system = await ActorSystem.create(SystemConfig.standalone(), _loop) + service = PythonActorService(system) + await system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) + return system + + _system = _run_coro_sync(_create_system()) def shutdown() -> None: diff --git a/python/pulsing/langgraph/executor.py b/python/pulsing/langgraph/executor.py index 5b897f078..a91df119c 100644 --- a/python/pulsing/langgraph/executor.py +++ b/python/pulsing/langgraph/executor.py @@ -15,7 +15,8 @@ from concurrent.futures import ThreadPoolExecutor from typing import Any, Callable, Dict -from pulsing.actor import Actor, ActorId, ActorRef, SystemConfig, create_actor_system +from pulsing.actor import Actor, ActorId, ActorRef, ActorSystem, SystemConfig +from pulsing.actor.remote import PYTHON_ACTOR_SERVICE_NAME, PythonActorService logger = logging.getLogger("pulsing.langgraph") @@ -288,7 +289,11 @@ async def start_worker( config = SystemConfig.with_addr(addr) if seeds: config = config.with_seeds(seeds) - system = await create_actor_system(config) + loop = asyncio.get_running_loop() + system = await ActorSystem.create(config, loop) + # Register PythonActorService for remote actor creation + service = PythonActorService(system) + await system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) executor_config = ExecutorConfig( max_workers=max_workers, diff --git a/python/pulsing/langgraph/wrapper.py b/python/pulsing/langgraph/wrapper.py index 6984818c9..aac08b991 100644 --- a/python/pulsing/langgraph/wrapper.py +++ b/python/pulsing/langgraph/wrapper.py @@ -8,7 +8,8 @@ import logging from typing import Any, AsyncIterator, Dict, Optional, Union -from pulsing.actor import SystemConfig, create_actor_system +from pulsing.actor import ActorSystem, SystemConfig +from pulsing.actor.remote import PYTHON_ACTOR_SERVICE_NAME, PythonActorService from .executor import NodeExecutorPool logger = logging.getLogger("pulsing.langgraph") @@ -78,7 +79,11 @@ async def _ensure_connected(self): else: config = SystemConfig.standalone() - self._system = await create_actor_system(config) + loop = asyncio.get_running_loop() + self._system = await ActorSystem.create(config, loop) + # Register PythonActorService for remote actor creation + service = PythonActorService(self._system) + await self._system.spawn(service, name=PYTHON_ACTOR_SERVICE_NAME, public=True) self._executor = NodeExecutorPool(self._system, self._node_mapping) self._connected = True diff --git a/tests/python/test_actor_system.py b/tests/python/test_actor_system.py index a6538d036..4621d4cee 100644 --- a/tests/python/test_actor_system.py +++ b/tests/python/test_actor_system.py @@ -18,8 +18,8 @@ Message, StreamMessage, SystemConfig, - create_actor_system, ) +import pulsing as pul # Actor system tests are standalone and don't require NATS/ETCD @@ -171,8 +171,7 @@ async def process(): @pytest.fixture async def actor_system(): """Create a standalone actor system for testing.""" - config = SystemConfig.standalone() - system = await create_actor_system(config) + system = await pul.actor_system() yield system await system.shutdown() @@ -181,12 +180,10 @@ async def actor_system(): async def cluster_systems(): """Create two actor systems that form a cluster.""" # First node - config1 = SystemConfig.with_addr("127.0.0.1:18001") - system1 = await create_actor_system(config1) + system1 = await pul.actor_system(addr="127.0.0.1:18001") # Second node, joins the first - config2 = SystemConfig.with_addr("127.0.0.1:18002").with_seeds(["127.0.0.1:18001"]) - system2 = await create_actor_system(config2) + system2 = await pul.actor_system(addr="127.0.0.1:18002", seeds=["127.0.0.1:18001"]) # Wait for cluster to form await asyncio.sleep(0.5) diff --git a/tests/python/test_chaos.py b/tests/python/test_chaos.py index bab010a25..1be324863 100644 --- a/tests/python/test_chaos.py +++ b/tests/python/test_chaos.py @@ -6,8 +6,8 @@ ActorId, Message, SystemConfig, - create_actor_system, ) +import pulsing as pul class ResilienceWorker(Actor): @@ -29,8 +29,7 @@ async def receive(self, msg: Message) -> Message: @pytest.fixture async def actor_system(): - config = SystemConfig.standalone() - system = await create_actor_system(config) + system = await pul.actor_system() yield system await system.shutdown() @@ -97,14 +96,13 @@ async def test_cluster_node_failure_detection(): systems = [] # Seed node (let OS assign port) - sys1 = await create_actor_system(SystemConfig.with_addr("127.0.0.1:0")) + sys1 = await pul.actor_system(addr="127.0.0.1:0") systems.append(sys1) seed_addr = sys1.addr # Get the actual assigned address # Other nodes for i in range(2): - cfg = SystemConfig.with_addr("127.0.0.1:0").with_seeds([seed_addr]) - sys = await create_actor_system(cfg) + sys = await pul.actor_system(addr="127.0.0.1:0", seeds=[seed_addr]) systems.append(sys) # Wait for cluster formation diff --git a/tests/python/test_queue.py b/tests/python/test_queue.py index 19bc1736a..e9765c5e6 100644 --- a/tests/python/test_queue.py +++ b/tests/python/test_queue.py @@ -23,7 +23,7 @@ import pytest -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul from pulsing.queue import ( BucketStorage, Queue, @@ -42,8 +42,7 @@ @pytest.fixture async def actor_system(): """Create a standalone actor system for testing.""" - config = SystemConfig.standalone() - system = await create_actor_system(config) + system = await pul.actor_system() yield system await system.shutdown() @@ -1011,10 +1010,10 @@ def test_sync_queue_standalone(): try: # Setup in background loop async def setup(): - from pulsing.actor import SystemConfig, create_actor_system + import pulsing as pul from pulsing.queue import write_queue, read_queue - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() writer = await write_queue( system, "sync_test", @@ -1078,10 +1077,10 @@ def test_sync_writer_reader_standalone(): try: async def setup(): - from pulsing.actor import SystemConfig, create_actor_system + import pulsing as pul from pulsing.queue import write_queue, read_queue - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() writer = await write_queue( system, "sync_wr", @@ -1145,10 +1144,10 @@ def test_sync_reader_offset_standalone(): try: async def setup(): - from pulsing.actor import SystemConfig, create_actor_system + import pulsing as pul from pulsing.queue import write_queue, read_queue - system = await create_actor_system(SystemConfig.standalone()) + system = await pul.actor_system() writer = await write_queue( system, "offset_test", diff --git a/tests/python/test_queue_backends.py b/tests/python/test_queue_backends.py index 5abadc6d9..45ab2e72e 100644 --- a/tests/python/test_queue_backends.py +++ b/tests/python/test_queue_backends.py @@ -18,7 +18,7 @@ import pytest -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul from pulsing.queue import ( BucketStorage, MemoryBackend, @@ -40,8 +40,7 @@ @pytest.fixture async def actor_system(): """Create a standalone actor system for testing.""" - config = SystemConfig.standalone() - system = await create_actor_system(config) + system = await pul.actor_system() yield system await system.shutdown() diff --git a/tests/python/test_sealed_message.py b/tests/python/test_sealed_message.py index 1dd71ecb8..f8fbbcdb9 100644 --- a/tests/python/test_sealed_message.py +++ b/tests/python/test_sealed_message.py @@ -17,9 +17,8 @@ Actor, Message, SealedPyMessage, - SystemConfig, - create_actor_system, ) +import pulsing as pul # ============================================================================ @@ -152,8 +151,7 @@ async def receive(self, msg): @pytest.fixture async def actor_system(): """Create a standalone actor system for testing.""" - config = SystemConfig.standalone() - system = await create_actor_system(config) + system = await pul.actor_system() yield system await system.shutdown() diff --git a/tests/python/test_system_actor.py b/tests/python/test_system_actor.py index b077d5bcb..ac983404c 100644 --- a/tests/python/test_system_actor.py +++ b/tests/python/test_system_actor.py @@ -9,12 +9,11 @@ import asyncio import pytest +import pulsing as pul from pulsing.actor import ( Actor, ActorId, Message, - SystemConfig, - create_actor_system, list_actors, get_metrics, get_node_info, @@ -32,8 +31,7 @@ @pytest.fixture async def system(): """Create a test ActorSystem.""" - config = SystemConfig.standalone() - system = await create_actor_system(config) + system = await pul.actor_system() yield system await system.shutdown() diff --git a/tests/python/test_topic.py b/tests/python/test_topic.py index 0f398c635..dab3d83c2 100644 --- a/tests/python/test_topic.py +++ b/tests/python/test_topic.py @@ -15,7 +15,7 @@ import pytest -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul from pulsing.topic import ( PublishMode, PublishResult, @@ -34,8 +34,7 @@ @pytest.fixture async def actor_system(): """Create a standalone actor system for testing.""" - config = SystemConfig.standalone() - system = await create_actor_system(config) + system = await pul.actor_system() yield system await system.shutdown() From 4fac2d5db3a7fb4e8f8d7f8b86fe010d69e19d67 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 09:07:50 +0800 Subject: [PATCH 06/24] Add new API methods and enhance documentation in llms.binding.md - Introduced `ray.shutdown()` to close the system gracefully. - Added `ray.is_initialized()` to check if the system is initialized. - Updated `ray.get()` to support both single and list ObjectRefs for result retrieval. - Introduced `ray.put()` for wrapping values as ObjectRefs for API compatibility. - Added `ray.wait()` to wait for multiple ObjectRefs to complete, enhancing functionality and usability. --- llms.binding.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/llms.binding.md b/llms.binding.md index 224c3e0c1..4f6cbfa9f 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -177,6 +177,12 @@ ray.init( **kwargs ) -> None +# 关闭系统 +ray.shutdown() -> None + +# 检查是否已初始化 +ray.is_initialized() -> bool + # 装饰器:将类转换为 Actor @ray.remote class MyActor: @@ -189,9 +195,21 @@ actor_handle = MyActor.remote(...) -> _ActorHandle # 调用方法(返回 ObjectRef) result_ref = actor_handle.method.remote(...) -> ObjectRef -# 获取结果(同步接口) -result = ray.get(result_ref, timeout: float | None = None) -> Any +# 获取结果(同步接口,支持单个或列表) +result = ray.get( + refs: ObjectRef | list[ObjectRef], + *, + timeout: float | None = None +) -> Any | list[Any] -# 关闭系统 -ray.shutdown() -> None +# 将值包装为 ObjectRef(用于 API 兼容) +ref = ray.put(value: Any) -> ObjectRef + +# 等待多个 ObjectRef 完成 +ready, remaining = ray.wait( + refs: list[ObjectRef], + *, + num_returns: int = 1, + timeout: float | None = None +) -> tuple[list[ObjectRef], list[ObjectRef]] ``` From be6bcdfaec4a4c017f113f3c7c32002deb5d6ccb Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 09:25:23 +0800 Subject: [PATCH 07/24] Enhance ActorClass API and add comprehensive tests - Added a `public` parameter to the `ActorClass` methods for actor visibility control. - Updated method signatures and documentation to reflect the new `public` argument. - Introduced new test files for API coverage, including tests for actor system creation, spawning, and resolving. - Created tests for both Ray-compatible and Ray-like APIs, ensuring robust functionality and compatibility. - Added fixtures and comprehensive test cases to validate actor behavior and interactions. --- python/pulsing/__init__.py | 13 + python/pulsing/actor/remote.py | 44 ++- tests/python/apis/__init__.py | 1 + tests/python/apis/actor_system/__init__.py | 1 + .../actor_system/test_actor_system_api.py | 316 ++++++++++++++++++ tests/python/apis/ray_compat/__init__.py | 1 + .../apis/ray_compat/test_ray_compat_api.py | 284 ++++++++++++++++ tests/python/apis/ray_like/__init__.py | 1 + .../python/apis/ray_like/test_ray_like_api.py | 279 ++++++++++++++++ 9 files changed, 935 insertions(+), 5 deletions(-) create mode 100644 tests/python/apis/__init__.py create mode 100644 tests/python/apis/actor_system/__init__.py create mode 100644 tests/python/apis/actor_system/test_actor_system_api.py create mode 100644 tests/python/apis/ray_compat/__init__.py create mode 100644 tests/python/apis/ray_compat/test_ray_compat_api.py create mode 100644 tests/python/apis/ray_like/__init__.py create mode 100644 tests/python/apis/ray_like/test_ray_like_api.py diff --git a/python/pulsing/__init__.py b/python/pulsing/__init__.py index 24d9fc2a3..fcf482e25 100644 --- a/python/pulsing/__init__.py +++ b/python/pulsing/__init__.py @@ -97,6 +97,19 @@ def __init__(self, inner: _ActorSystem): self.queue = QueueAPI(inner) + async def refer(self, actorid: ActorId | str) -> ActorRef: + """Get actor reference by ID + + Args: + actorid: Actor ID (ActorId instance or string in format "node_id:local_id") + + Returns: + ActorRef to the actor + """ + if isinstance(actorid, str): + actorid = ActorId.from_str(actorid) + return await self._inner.refer(actorid) + def __getattr__(self, name): # Delegate all other attributes to the inner ActorSystem return getattr(self._inner, name) diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index 2971b4a2c..3c3ec1100 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -581,12 +581,19 @@ async def spawn( self, *args, name: str | None = None, + public: bool | None = None, **kwargs, ) -> ActorProxy: """Create actor using global system (simple API) Must call `await init()` before using this method. + Args: + *args: Positional arguments for the class constructor + name: Optional actor name (if provided, defaults to public=True) + public: Whether the actor should be publicly resolvable (default: True if name provided) + **kwargs: Keyword arguments for the class constructor + Example: from pulsing.actor import init, remote @@ -608,20 +615,36 @@ def incr(self): self.value += 1; return self.value "Actor system not initialized. Call 'await init()' first." ) - return await self.local(_global_system, *args, name=name, **kwargs) + # Default public=True if name is provided + if public is None: + public = name is not None + + return await self.local(_global_system, *args, name=name, public=public, **kwargs) async def local( self, system: ActorSystem, *args, name: str | None = None, + public: bool | None = None, **kwargs, ) -> ActorProxy: """Create actor locally with explicit system. + Args: + system: The ActorSystem to spawn the actor in + *args: Positional arguments for the class constructor + name: Optional actor name (if provided, defaults to public=True) + public: Whether the actor should be publicly resolvable (default: True if name provided) + **kwargs: Keyword arguments for the class constructor + Note: Use pul.actor_system() to create ActorSystem, which automatically registers PythonActorService. """ + # Default public=True if name is provided + if public is None: + public = name is not None + actor_name = name or f"{self._cls.__name__}_{uuid.uuid4().hex[:8]}" if self._restart_policy != "never": @@ -633,7 +656,7 @@ def factory(): actor_ref = await system.spawn( factory, name=actor_name, - public=True, + public=public, restart_policy=self._restart_policy, max_restarts=self._max_restarts, min_backoff=self._min_backoff, @@ -642,7 +665,7 @@ def factory(): else: instance = self._cls(*args, **kwargs) actor = _WrappedActor(instance) - actor_ref = await system.spawn(actor, name=actor_name, public=True) + actor_ref = await system.spawn(actor, name=actor_name, public=public) # Register actor metadata _register_actor_metadata(actor_name, self._cls) @@ -654,13 +677,24 @@ async def remote( system: ActorSystem, *args, name: str | None = None, + public: bool | None = None, **kwargs, ) -> ActorProxy: """Create actor remotely (randomly selects a remote node). + Args: + system: The ActorSystem to spawn the actor in + *args: Positional arguments for the class constructor + name: Optional actor name (if provided, defaults to public=True) + public: Whether the actor should be publicly resolvable (default: True if name provided) + **kwargs: Keyword arguments for the class constructor + Note: Use pul.actor_system() to create ActorSystem, which automatically registers PythonActorService. """ + # Default public=True if name is provided + if public is None: + public = name is not None members = await system.members() local_id = system.node_id.id @@ -671,7 +705,7 @@ async def remote( if not remote_nodes: # No remote nodes, fallback to local creation logger.warning("No remote nodes, fallback to local") - return await self.local(system, *args, name=name, **kwargs) + return await self.local(system, *args, name=name, public=public, **kwargs) # Randomly select one target = random.choice(remote_nodes) @@ -693,7 +727,7 @@ async def remote( "actor_name": actor_name, "args": list(args), "kwargs": kwargs, - "public": True, + "public": public, # Supervision config "restart_policy": self._restart_policy, "max_restarts": self._max_restarts, diff --git a/tests/python/apis/__init__.py b/tests/python/apis/__init__.py new file mode 100644 index 000000000..ad87d24ed --- /dev/null +++ b/tests/python/apis/__init__.py @@ -0,0 +1 @@ +# API Tests for Pulsing diff --git a/tests/python/apis/actor_system/__init__.py b/tests/python/apis/actor_system/__init__.py new file mode 100644 index 000000000..cf7e9825d --- /dev/null +++ b/tests/python/apis/actor_system/__init__.py @@ -0,0 +1 @@ +# Actor System Style API Tests diff --git a/tests/python/apis/actor_system/test_actor_system_api.py b/tests/python/apis/actor_system/test_actor_system_api.py new file mode 100644 index 000000000..23c6669f2 --- /dev/null +++ b/tests/python/apis/actor_system/test_actor_system_api.py @@ -0,0 +1,316 @@ +""" +Tests for Actor System Style API (llms.binding.md) + +Covers: +- pul.actor_system() creation and shutdown +- system.spawn() with various parameters +- system.refer() and system.resolve() +- actorref.ask() and actorref.tell() +- @pul.remote decorator with sync/async methods +- system.queue.write() and system.queue.read() +""" + +import asyncio +import tempfile +import shutil +import pytest + +import pulsing as pul +from pulsing.actor import Actor, ActorId + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +async def system(): + """Create a standalone actor system for testing.""" + system = await pul.actor_system() + yield system + await system.shutdown() + + +# ============================================================================ +# Test: pul.actor_system() +# ============================================================================ + + +@pytest.mark.asyncio +async def test_actor_system_standalone(): + """Test creating standalone actor system with no parameters.""" + system = await pul.actor_system() + assert system is not None + await system.shutdown() + + +@pytest.mark.asyncio +async def test_actor_system_with_addr(): + """Test creating actor system with explicit address.""" + system = await pul.actor_system(addr="127.0.0.1:0") + assert system is not None + assert system.addr is not None + await system.shutdown() + + +@pytest.mark.asyncio +async def test_actor_system_shutdown(): + """Test system.shutdown() method.""" + system = await pul.actor_system() + # Should not raise + await system.shutdown() + + +# ============================================================================ +# Test: system.spawn() +# ============================================================================ + + +class EchoActor(Actor): + """Simple echo actor for testing.""" + + async def receive(self, msg): + return msg + + +@pytest.mark.asyncio +async def test_spawn_anonymous_actor(system): + """Test spawning actor without name (anonymous).""" + ref = await system.spawn(EchoActor()) + assert ref is not None + result = await ref.ask("hello") + assert result == "hello" + + +@pytest.mark.asyncio +async def test_spawn_named_actor(system): + """Test spawning actor with name.""" + ref = await system.spawn(EchoActor(), name="echo_test") + assert ref is not None + result = await ref.ask("world") + assert result == "world" + + +@pytest.mark.asyncio +async def test_spawn_public_actor(system): + """Test spawning public actor.""" + ref = await system.spawn(EchoActor(), name="public_echo", public=True) + assert ref is not None + # Public actors should be resolvable by name + resolved = await system.resolve("public_echo") + assert resolved is not None + + +# ============================================================================ +# Test: system.refer() +# ============================================================================ + + +@pytest.mark.asyncio +async def test_refer_by_actorid(system): + """Test getting actor reference by ActorId.""" + ref = await system.spawn(EchoActor(), name="refer_test") + actor_id = ref.actor_id + + # Get reference by ActorId object + ref2 = await system.refer(actor_id) + assert ref2 is not None + result = await ref2.ask("test") + assert result == "test" + + +@pytest.mark.asyncio +async def test_refer_by_string(system): + """Test getting actor reference by string ActorId.""" + ref = await system.spawn(EchoActor(), name="refer_str_test") + actor_id_str = str(ref.actor_id) + + # Get reference by string + ref2 = await system.refer(actor_id_str) + assert ref2 is not None + result = await ref2.ask("string_test") + assert result == "string_test" + + +# ============================================================================ +# Test: system.resolve() +# ============================================================================ + + +@pytest.mark.asyncio +async def test_resolve_named_actor(system): + """Test resolving public actor by name.""" + await system.spawn(EchoActor(), name="resolve_test", public=True) + + ref = await system.resolve("resolve_test") + assert ref is not None + result = await ref.ask("resolved") + assert result == "resolved" + + +# ============================================================================ +# Test: actorref.ask() and actorref.tell() +# ============================================================================ + + +class StatefulActor(Actor): + """Actor with state for testing tell().""" + + def __init__(self): + self.messages = [] + + async def receive(self, msg): + if isinstance(msg, dict): + if msg.get("action") == "store": + self.messages.append(msg.get("data")) + return None + elif msg.get("action") == "get": + return self.messages + return msg + + +@pytest.mark.asyncio +async def test_ask_returns_response(system): + """Test ask() returns response from actor.""" + ref = await system.spawn(EchoActor()) + result = await ref.ask({"key": "value"}) + assert result == {"key": "value"} + + +@pytest.mark.asyncio +async def test_tell_fire_and_forget(system): + """Test tell() sends message without waiting for response.""" + ref = await system.spawn(StatefulActor()) + + # tell() should not wait for response + await ref.tell({"action": "store", "data": "msg1"}) + await ref.tell({"action": "store", "data": "msg2"}) + + # Give some time for messages to be processed + await asyncio.sleep(0.1) + + # Verify messages were received + messages = await ref.ask({"action": "get"}) + assert "msg1" in messages + assert "msg2" in messages + + +# ============================================================================ +# Test: @pul.remote decorator +# ============================================================================ + + +@pul.remote +class Counter: + """Counter actor using @pul.remote decorator.""" + + def __init__(self, init=0): + self.value = init + + def incr(self): + """Sync method.""" + self.value += 1 + return self.value + + async def decr(self): + """Async method.""" + self.value -= 1 + return self.value + + def get(self): + return self.value + + +@pytest.mark.asyncio +async def test_remote_decorator_spawn(system): + """Test @pul.remote class spawn.""" + counter = await Counter.local(system, init=10) + assert counter is not None + result = await counter.get() + assert result == 10 + + +@pytest.mark.asyncio +async def test_remote_decorator_sync_method(system): + """Test calling sync method on @pul.remote class.""" + counter = await Counter.local(system, init=0) + result = await counter.incr() + assert result == 1 + result = await counter.incr() + assert result == 2 + + +@pytest.mark.asyncio +async def test_remote_decorator_async_method(system): + """Test calling async method on @pul.remote class.""" + counter = await Counter.local(system, init=5) + result = await counter.decr() + assert result == 4 + + +# ============================================================================ +# Test: Queue API - system.queue.write() and system.queue.read() +# ============================================================================ + + +@pytest.mark.asyncio +async def test_queue_write_and_read(system): + """Test basic queue write and read.""" + temp_dir = tempfile.mkdtemp(prefix="queue_test_") + try: + writer = await system.queue.write( + "test_topic", + bucket_column="id", + num_buckets=2, + storage_path=temp_dir, + ) + assert writer is not None + + # Write records + await writer.put({"id": "1", "data": "first"}) + await writer.put({"id": "2", "data": "second"}) + await writer.flush() + + # Read records + reader = await system.queue.read( + "test_topic", + num_buckets=2, + storage_path=temp_dir, + ) + records = await reader.get(limit=10) + assert len(records) == 2 + + ids = {r["id"] for r in records} + assert ids == {"1", "2"} + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.mark.asyncio +async def test_queue_batch_write(system): + """Test batch write to queue.""" + temp_dir = tempfile.mkdtemp(prefix="queue_batch_test_") + try: + writer = await system.queue.write( + "batch_topic", + bucket_column="id", + num_buckets=1, + storage_path=temp_dir, + ) + + # Batch write + records = [{"id": str(i), "value": i} for i in range(10)] + await writer.put(records) + await writer.flush() + + # Read all + reader = await system.queue.read( + "batch_topic", + num_buckets=1, + storage_path=temp_dir, + ) + result = await reader.get(limit=20) + assert len(result) == 10 + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/tests/python/apis/ray_compat/__init__.py b/tests/python/apis/ray_compat/__init__.py new file mode 100644 index 000000000..10fe11708 --- /dev/null +++ b/tests/python/apis/ray_compat/__init__.py @@ -0,0 +1 @@ +# Ray Compatible API Tests diff --git a/tests/python/apis/ray_compat/test_ray_compat_api.py b/tests/python/apis/ray_compat/test_ray_compat_api.py new file mode 100644 index 000000000..78986d0d0 --- /dev/null +++ b/tests/python/apis/ray_compat/test_ray_compat_api.py @@ -0,0 +1,284 @@ +""" +Tests for Ray Compatible API (llms.binding.md) + +Covers: +- ray.init() and ray.shutdown() +- ray.is_initialized() +- @ray.remote decorator +- MyActor.remote() -> _ActorHandle +- actor_handle.method.remote() -> ObjectRef +- ray.get() single and list +- ray.put() +- ray.wait() +""" + +import pytest +import time + +from pulsing.compat import ray + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def initialized_ray(): + """Initialize ray-compatible system for testing.""" + ray.init() + yield + ray.shutdown() + + +# ============================================================================ +# Test: ray.init() and ray.shutdown() +# ============================================================================ + + +def test_init_and_shutdown(): + """Test ray.init() and ray.shutdown().""" + ray.init() + assert ray.is_initialized() + ray.shutdown() + assert not ray.is_initialized() + + +def test_init_ignore_reinit_error(): + """Test ray.init(ignore_reinit_error=True).""" + ray.init() + # Should not raise + ray.init(ignore_reinit_error=True) + ray.shutdown() + + +def test_init_raises_on_reinit(): + """Test ray.init() raises if already initialized.""" + ray.init() + try: + with pytest.raises(RuntimeError): + ray.init() + finally: + ray.shutdown() + + +# ============================================================================ +# Test: ray.is_initialized() +# ============================================================================ + + +def test_is_initialized_false(): + """Test ray.is_initialized() returns False when not initialized.""" + assert not ray.is_initialized() + + +def test_is_initialized_true(initialized_ray): + """Test ray.is_initialized() returns True when initialized.""" + assert ray.is_initialized() + + +# ============================================================================ +# Test: @ray.remote decorator +# ============================================================================ + + +@ray.remote +class Counter: + """Counter actor for testing.""" + + def __init__(self, init=0): + self.value = init + + def incr(self): + self.value += 1 + return self.value + + def decr(self): + self.value -= 1 + return self.value + + def get(self): + return self.value + + def add(self, n): + self.value += n + return self.value + + +def test_remote_decorator_class(initialized_ray): + """Test @ray.remote decorator creates actor class wrapper.""" + # Counter should have .remote() method + assert hasattr(Counter, "remote") + + +def test_remote_actor_creation(initialized_ray): + """Test MyActor.remote() creates actor handle.""" + handle = Counter.remote(init=10) + assert handle is not None + + +# ============================================================================ +# Test: actor_handle.method.remote() -> ObjectRef +# ============================================================================ + + +def test_method_remote_returns_objectref(initialized_ray): + """Test actor_handle.method.remote() returns ObjectRef.""" + handle = Counter.remote(init=0) + ref = handle.incr.remote() + assert ref is not None + # ObjectRef should have _get_sync method + assert hasattr(ref, "_get_sync") + + +def test_method_with_args(initialized_ray): + """Test calling method with arguments.""" + handle = Counter.remote(init=0) + ref = handle.add.remote(5) + result = ray.get(ref) + assert result == 5 + + +# ============================================================================ +# Test: ray.get() - single and list +# ============================================================================ + + +def test_get_single(initialized_ray): + """Test ray.get() with single ObjectRef.""" + handle = Counter.remote(init=100) + ref = handle.get.remote() + result = ray.get(ref) + assert result == 100 + + +def test_get_list(initialized_ray): + """Test ray.get() with list of ObjectRefs.""" + handle = Counter.remote(init=0) + refs = [handle.incr.remote() for _ in range(5)] + results = ray.get(refs) + assert len(results) == 5 + # Last result should be 5 (incremented 5 times) + assert results[-1] == 5 + + +def test_get_with_timeout(initialized_ray): + """Test ray.get() with timeout parameter.""" + handle = Counter.remote(init=0) + ref = handle.get.remote() + result = ray.get(ref, timeout=5.0) + assert result == 0 + + +def test_get_multiple_actors(initialized_ray): + """Test ray.get() with refs from multiple actors.""" + h1 = Counter.remote(init=10) + h2 = Counter.remote(init=20) + h3 = Counter.remote(init=30) + + refs = [h1.get.remote(), h2.get.remote(), h3.get.remote()] + results = ray.get(refs) + assert results == [10, 20, 30] + + +# ============================================================================ +# Test: ray.put() +# ============================================================================ + + +def test_put_value(initialized_ray): + """Test ray.put() wraps value as ObjectRef.""" + ref = ray.put(42) + assert ref is not None + result = ray.get(ref) + assert result == 42 + + +def test_put_complex_value(initialized_ray): + """Test ray.put() with complex value.""" + data = {"key": "value", "numbers": [1, 2, 3]} + ref = ray.put(data) + result = ray.get(ref) + assert result == data + + +def test_put_list_of_refs(initialized_ray): + """Test ray.put() and ray.get() with list.""" + refs = [ray.put(i) for i in range(5)] + results = ray.get(refs) + assert results == [0, 1, 2, 3, 4] + + +# ============================================================================ +# Test: ray.wait() +# ============================================================================ + + +def test_wait_basic(initialized_ray): + """Test ray.wait() returns ready and remaining.""" + handle = Counter.remote(init=0) + refs = [handle.incr.remote() for _ in range(3)] + + ready, remaining = ray.wait(refs, num_returns=1) + assert len(ready) >= 1 + assert len(ready) + len(remaining) == 3 + + +def test_wait_num_returns(initialized_ray): + """Test ray.wait() with num_returns parameter.""" + handle = Counter.remote(init=0) + refs = [handle.incr.remote() for _ in range(5)] + + ready, remaining = ray.wait(refs, num_returns=3) + # At most 3 ready (depends on timing) + assert len(ready) <= 3 + + +def test_wait_with_put_refs(initialized_ray): + """Test ray.wait() with ray.put() refs (immediately ready).""" + refs = [ray.put(i) for i in range(5)] + + ready, remaining = ray.wait(refs, num_returns=5) + # put() refs are immediately ready + assert len(ready) == 5 + assert len(remaining) == 0 + + +# ============================================================================ +# Test: Full workflow +# ============================================================================ + + +def test_full_workflow(initialized_ray): + """Test complete Ray-compatible workflow.""" + # Create actors + c1 = Counter.remote(init=0) + c2 = Counter.remote(init=100) + + # Call methods + refs = [ + c1.incr.remote(), + c1.incr.remote(), + c2.decr.remote(), + c2.get.remote(), + ] + + # Get results + results = ray.get(refs) + assert results[0] == 1 # c1.incr() -> 1 + assert results[1] == 2 # c1.incr() -> 2 + assert results[2] == 99 # c2.decr() -> 99 + assert results[3] == 99 # c2.get() -> 99 (after decr) + + +def test_actor_state_persistence(initialized_ray): + """Test actor maintains state across calls.""" + handle = Counter.remote(init=0) + + for i in range(10): + ref = handle.incr.remote() + result = ray.get(ref) + assert result == i + 1 + + final = ray.get(handle.get.remote()) + assert final == 10 diff --git a/tests/python/apis/ray_like/__init__.py b/tests/python/apis/ray_like/__init__.py new file mode 100644 index 000000000..203a2c53a --- /dev/null +++ b/tests/python/apis/ray_like/__init__.py @@ -0,0 +1 @@ +# Ray-like Async API Tests diff --git a/tests/python/apis/ray_like/test_ray_like_api.py b/tests/python/apis/ray_like/test_ray_like_api.py new file mode 100644 index 000000000..d3716fd25 --- /dev/null +++ b/tests/python/apis/ray_like/test_ray_like_api.py @@ -0,0 +1,279 @@ +""" +Tests for Ray-like Async API (llms.binding.md) + +Covers: +- pul.init() and pul.shutdown() +- pul.spawn(), pul.refer(), pul.resolve() +- @pul.remote decorator +- Counter.spawn() and Counter.resolve() +""" + +import asyncio +import pytest + +import pulsing as pul +from pulsing.actor import Actor + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +async def initialized_pul(): + """Initialize global pulsing system for testing.""" + await pul.init() + yield + await pul.shutdown() + + +# ============================================================================ +# Test: pul.init() and pul.shutdown() +# ============================================================================ + + +@pytest.mark.asyncio +async def test_init_standalone(): + """Test pul.init() with no parameters (standalone mode).""" + system = await pul.init() + assert system is not None + await pul.shutdown() + + +@pytest.mark.asyncio +async def test_init_with_addr(): + """Test pul.init() with explicit address.""" + system = await pul.init(addr="127.0.0.1:0") + assert system is not None + await pul.shutdown() + + +@pytest.mark.asyncio +async def test_shutdown(): + """Test pul.shutdown() method.""" + await pul.init() + # Should not raise + await pul.shutdown() + + +# ============================================================================ +# Test: pul.spawn() +# ============================================================================ + + +class SimpleActor(Actor): + """Simple actor for testing spawn.""" + + async def receive(self, msg): + if isinstance(msg, dict) and msg.get("action") == "echo": + return msg.get("data") + return msg + + +@pytest.mark.asyncio +async def test_spawn_anonymous(initialized_pul): + """Test pul.spawn() without name.""" + ref = await pul.spawn(SimpleActor()) + assert ref is not None + result = await ref.ask({"action": "echo", "data": "test"}) + assert result == "test" + + +@pytest.mark.asyncio +async def test_spawn_named(initialized_pul): + """Test pul.spawn() with name.""" + ref = await pul.spawn(SimpleActor(), name="spawn_test_actor") + assert ref is not None + result = await ref.ask("hello") + assert result == "hello" + + +@pytest.mark.asyncio +async def test_spawn_public(initialized_pul): + """Test pul.spawn() with public=True.""" + ref = await pul.spawn(SimpleActor(), name="public_spawn_test", public=True) + assert ref is not None + + # Should be resolvable + resolved = await pul.resolve("public_spawn_test") + assert resolved is not None + + +# ============================================================================ +# Test: pul.refer() +# ============================================================================ + + +@pytest.mark.asyncio +async def test_refer_by_actorid(initialized_pul): + """Test pul.refer() with ActorId.""" + ref = await pul.spawn(SimpleActor(), name="refer_test") + actor_id = ref.actor_id + + ref2 = await pul.refer(actor_id) + assert ref2 is not None + result = await ref2.ask("refer_test_msg") + assert result == "refer_test_msg" + + +@pytest.mark.asyncio +async def test_refer_by_string(initialized_pul): + """Test pul.refer() with string ActorId.""" + ref = await pul.spawn(SimpleActor(), name="refer_str_test") + actor_id_str = str(ref.actor_id) + + ref2 = await pul.refer(actor_id_str) + assert ref2 is not None + + +# ============================================================================ +# Test: pul.resolve() +# ============================================================================ + + +@pytest.mark.asyncio +async def test_resolve_public_actor(initialized_pul): + """Test pul.resolve() for public actor.""" + await pul.spawn(SimpleActor(), name="resolve_public_test", public=True) + + ref = await pul.resolve("resolve_public_test") + assert ref is not None + result = await ref.ask("resolved_msg") + assert result == "resolved_msg" + + +# ============================================================================ +# Test: actorref.ask() and actorref.tell() +# ============================================================================ + + +class CounterActor(Actor): + """Counter actor for testing ask/tell.""" + + def __init__(self): + self.count = 0 + + async def receive(self, msg): + if isinstance(msg, dict): + action = msg.get("action") + if action == "incr": + self.count += 1 + elif action == "decr": + self.count -= 1 + elif action == "get": + return self.count + return self.count + + +@pytest.mark.asyncio +async def test_ask_response(initialized_pul): + """Test actorref.ask() returns response.""" + ref = await pul.spawn(CounterActor()) + result = await ref.ask({"action": "get"}) + assert result == 0 + + +@pytest.mark.asyncio +async def test_tell_no_wait(initialized_pul): + """Test actorref.tell() doesn't wait for response.""" + ref = await pul.spawn(CounterActor()) + + # tell() should return immediately + await ref.tell({"action": "incr"}) + await ref.tell({"action": "incr"}) + await ref.tell({"action": "incr"}) + + # Give time for processing + await asyncio.sleep(0.1) + + result = await ref.ask({"action": "get"}) + assert result == 3 + + +# ============================================================================ +# Test: @pul.remote decorator +# ============================================================================ + + +@pul.remote +class RemoteCounter: + """Counter using @pul.remote decorator.""" + + def __init__(self, init=0): + self.value = init + + def incr(self): + """Sync method.""" + self.value += 1 + return self.value + + async def decr(self): + """Async method.""" + self.value -= 1 + return self.value + + def get(self): + return self.value + + +@pytest.mark.asyncio +async def test_remote_spawn(initialized_pul): + """Test @pul.remote Counter.spawn().""" + counter = await RemoteCounter.spawn(init=10) + assert counter is not None + result = await counter.get() + assert result == 10 + + +@pytest.mark.asyncio +async def test_remote_spawn_with_name(initialized_pul): + """Test @pul.remote Counter.spawn(name=...).""" + counter = await RemoteCounter.spawn(name="named_counter", init=5) + assert counter is not None + result = await counter.get() + assert result == 5 + + +@pytest.mark.asyncio +async def test_remote_sync_method(initialized_pul): + """Test calling sync method on @pul.remote class.""" + counter = await RemoteCounter.spawn(init=0) + result = await counter.incr() + assert result == 1 + result = await counter.incr() + assert result == 2 + + +@pytest.mark.asyncio +async def test_remote_async_method(initialized_pul): + """Test calling async method on @pul.remote class.""" + counter = await RemoteCounter.spawn(init=10) + result = await counter.decr() + assert result == 9 + + +@pytest.mark.asyncio +async def test_remote_resolve(initialized_pul): + """Test Counter.resolve() to get ActorProxy for existing actor.""" + # First spawn a named counter + await RemoteCounter.spawn(name="resolvable_counter", public=True, init=100) + + # Then resolve it + proxy = await RemoteCounter.resolve("resolvable_counter") + assert proxy is not None + result = await proxy.get() + assert result == 100 + + +@pytest.mark.asyncio +async def test_remote_resolve_and_call(initialized_pul): + """Test resolve and call methods on resolved actor.""" + await RemoteCounter.spawn(name="call_counter", public=True, init=50) + + proxy = await RemoteCounter.resolve("call_counter") + # Call methods on resolved proxy + result = await proxy.incr() + assert result == 51 + result = await proxy.decr() + assert result == 50 From d61b186dc21813ca71bc787e082dd4f564b93c3d Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 09:41:04 +0800 Subject: [PATCH 08/24] Add comprehensive Actor behavior documentation in llms.binding.md - Introduced detailed examples for basic and advanced Actor usage, including synchronous and asynchronous methods. - Added explanations for the @pul.remote decorator, message passing patterns, and Actor lifecycle management. - Included guidance on supervision and restart strategies, as well as streaming responses for Actors. - Enhanced clarity on method definitions and usage scenarios to improve user understanding of the Pulsing API. --- llms.binding.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/llms.binding.md b/llms.binding.md index 4f6cbfa9f..a21f9f352 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -213,3 +213,141 @@ ready, remaining = ray.wait( timeout: float | None = None ) -> tuple[list[ObjectRef], list[ObjectRef]] ``` + +### Actor 行为 + +#### 基础 Actor(使用 `receive` 方法) + +```python +from pulsing.actor import Actor + +class EchoActor(Actor): + """receive 方法 - 同步或异步均可,框架自动检测""" + + # 方式1:同步方法 + def receive(self, msg): + return msg + + # 方式2:异步方法(需要 await 时使用) + async def receive(self, msg): + result = await some_async_operation() + return result + +class FireAndForget(Actor): + """无返回值(适合 tell 调用)""" + def receive(self, msg): + print(f"Received: {msg}") + # 无返回值 +``` + +**注意:** `receive` 方法可以是 `def` 或 `async def`,Pulsing 会自动检测并正确处理。 +只有当方法内部需要 `await` 其他协程时,才需要使用 `async def`。 + +#### @pul.remote 装饰器(推荐) + +```python +import pulsing as pul + +@pul.remote +class Counter: + def __init__(self, init=0): + self.value = init + + # 同步方法 - 阻塞处理,请求按顺序执行 + # 适合:快速计算、状态修改 + def incr(self): + self.value += 1 + return self.value + + # 异步方法 - 非阻塞,可并发处理多个请求 + # 适合:IO 密集型操作(网络请求、数据库查询) + async def fetch_and_add(self, url): + data = await http_get(url) # 等待期间可处理其他请求 + self.value += data + return self.value + + # 无返回值方法 - 适合 tell() 调用 + def reset(self): + self.value = 0 + +# 同步 vs 异步方法的并发行为: +# - def method(): 阻塞 Actor,请求排队顺序执行 +# - async def method(): 非阻塞,await 期间可处理其他请求(并发) + +# 使用 +counter = await Counter.spawn(name="counter") +result = await counter.incr() # ask 模式,等待返回 +await counter.reset() # 无返回值,但仍等待完成 +``` + +#### 消息传递模式 + +```python +# ask - 发送消息并等待响应 +response = await actorref.ask({"action": "get"}) + +# tell - 发送消息,不等待响应(fire-and-forget) +await actorref.tell({"action": "log", "data": "hello"}) +``` + +#### Actor 生命周期 + +```python +from pulsing.actor import Actor, ActorId + +class MyActor(Actor): + def on_start(self, actor_id: ActorId): + """Actor 启动时调用""" + print(f"Started: {actor_id}") + + def on_stop(self): + """Actor 停止时调用""" + print("Stopping...") + + def metadata(self) -> dict[str, str]: + """返回 Actor 元数据(用于诊断)""" + return {"type": "worker", "version": "1.0"} + + async def receive(self, msg): + return msg +``` + +#### 监督与重启策略 + +```python +@pul.remote( + restart_policy="on_failure", # "never" | "on_failure" | "always" + max_restarts=3, # 最大重启次数 + min_backoff=0.1, # 最小退避时间(秒) + max_backoff=30.0, # 最大退避时间(秒) +) +class ResilientWorker: + def process(self, data): + # 如果抛出异常,Actor 会自动重启 + return heavy_computation(data) +``` + +#### 流式响应 + +```python +@pul.remote +class StreamingService: + # 直接返回 generator,Pulsing 自动处理为流式响应 + async def generate_stream(self, n): + for i in range(n): + yield f"chunk_{i}" + + # 同步 generator 也支持 + def sync_stream(self, n): + for i in range(n): + yield f"item_{i}" + +# 使用 +service = await StreamingService.spawn() + +# 客户端消费流 +async for chunk in service.generate_stream(10): + print(chunk) # chunk_0, chunk_1, ... +``` + +**注意:** 对于 `@pul.remote` 类,直接返回 generator(同步或异步)即可,Pulsing 会自动检测并按流式响应处理。 \ No newline at end of file From dcc1cf118f4b9ef2d46083ad2b0a00688f42bdda Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 09:52:47 +0800 Subject: [PATCH 09/24] Add generator support to PyActorResponse for streaming values - Introduced a new `Generator` variant in `PyActorResponse` to handle both synchronous and asynchronous Python generators. - Implemented logic to iterate over generators in the `Actor` trait, utilizing channels for streaming values. - Enhanced error handling for generator iteration, including checks for `StopIteration` and `StopAsyncIteration`. - Updated the actor's response handling to accommodate the new generator functionality, improving the API's versatility. --- crates/pulsing-py/src/actor.rs | 99 ++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/crates/pulsing-py/src/actor.rs b/crates/pulsing-py/src/actor.rs index 4847f6a02..fe5bd8d90 100644 --- a/crates/pulsing-py/src/actor.rs +++ b/crates/pulsing-py/src/actor.rs @@ -549,6 +549,8 @@ enum PyActorResponse { StreamChannel(String, mpsc::Receiver>), /// Pickled Python object for Python-to-Python communication Sealed(Vec), + /// Generator (async or sync) to be iterated + Generator(PyObject, PyObject, bool), // (generator, event_loop, is_async) } /// Python wrapper for ActorRef @@ -907,6 +909,23 @@ impl Actor for PythonActorWrapper { return Ok(PyActorResponse::Single(PyMessage::empty())); } + // Check for generator (sync or async) - fast path using type name + let type_name = py_result_bound + .get_type() + .qualname() + .map(|s| s.to_string()) + .unwrap_or_default(); + let is_gen = type_name == "generator"; + let is_async_gen = type_name == "async_generator"; + + if is_async_gen || is_gen { + return Ok(PyActorResponse::Generator( + py_result.clone_ref(py), + event_loop.clone_ref(py), + is_async_gen, + )); + } + // Handle StreamMessage if py_result_bound.is_instance_of::() { let stream_msg_cell = py_result_bound.downcast::()?; @@ -962,6 +981,86 @@ impl Actor for PythonActorWrapper { Ok(Message::from_channel(&default_msg_type, rx)) } PyActorResponse::Sealed(data) => Ok(Message::single(SEALED_PY_MSG_TYPE, data)), + PyActorResponse::Generator(generator, event_loop, is_async) => { + // Create channel for streaming generator values + let (tx, rx) = mpsc::channel(32); + + // Spawn background task to iterate generator + tokio::spawn(async move { + let result = python_executor() + .execute(move || { + Python::with_gil(|py| -> PyResult<()> { + let gen = generator.bind(py); + let asyncio = py.import("asyncio")?; + + if is_async { + // Async generator: iterate using anext() + let run_coroutine_threadsafe = + asyncio.getattr("run_coroutine_threadsafe")?; + loop { + let anext_coro = gen.call_method0("__anext__")?; + let future = run_coroutine_threadsafe + .call1((&anext_coro, &event_loop))?; + match future.call_method0("result") { + Ok(item) => { + let pickled = pickle_object(py, &item.unbind())?; + let msg = + Message::single(SEALED_PY_MSG_TYPE, pickled); + if tx.blocking_send(Ok(msg)).is_err() { + break; + } + } + Err(e) => { + // Check if StopAsyncIteration + if e.is_instance_of::(py) { + break; + } + let _ = tx.blocking_send(Err(anyhow::anyhow!( + "Generator error: {}", + e + ))); + break; + } + } + } + } else { + // Sync generator: iterate using next() + loop { + match gen.call_method0("__next__") { + Ok(item) => { + let pickled = pickle_object(py, &item.unbind())?; + let msg = + Message::single(SEALED_PY_MSG_TYPE, pickled); + if tx.blocking_send(Ok(msg)).is_err() { + break; + } + } + Err(e) => { + // Check if StopIteration + if e.is_instance_of::(py) { + break; + } + let _ = tx.blocking_send(Err(anyhow::anyhow!( + "Generator error: {}", + e + ))); + break; + } + } + } + } + Ok(()) + }) + }) + .await; + + if let Err(e) = result { + tracing::error!("Generator iteration error: {:?}", e); + } + }); + + Ok(Message::from_channel(SEALED_PY_MSG_TYPE, rx)) + } } } } From 4ee2dbbabf01233f8422ba0bed86cea9c61a3028 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 09:57:06 +0800 Subject: [PATCH 10/24] Implement synchronous and asynchronous generator support in Actor API - Added `_SyncGeneratorStreamReader` to handle synchronous generator responses in the Actor framework. - Enhanced `_MethodCaller` to recognize stream messages and return appropriate stream readers. - Introduced `_handle_generator_result` method in `_WrappedActor` to manage generator results and provide streaming responses. - Removed outdated test files and added new tests for actor behavior, including comprehensive coverage for synchronous and asynchronous methods, message passing, and actor lifecycle management. - Updated documentation to reflect changes in actor behavior and streaming capabilities. --- python/pulsing/actor/remote.py | 55 ++ tests/python/apis/actor/__init__.py | 1 + .../python/apis/actor/test_actor_behavior.py | 419 ++++++++++++ .../actor_system/test_actor_system_api.py | 73 +++ tests/python/test_actor_system.py | 613 ------------------ tests/python/test_chaos.py | 5 +- tests/python/test_new_api.py | 336 ---------- tests/python/test_remote_decorator.py | 329 ++-------- 8 files changed, 600 insertions(+), 1231 deletions(-) create mode 100644 tests/python/apis/actor/__init__.py create mode 100644 tests/python/apis/actor/test_actor_behavior.py delete mode 100644 tests/python/test_actor_system.py delete mode 100644 tests/python/test_new_api.py diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index 3c3ec1100..57bfed640 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -190,6 +190,9 @@ async def _sync_call(self, *args, **kwargs) -> Any: raise RuntimeError(resp["__error__"]) return resp.get("__result__") elif isinstance(resp, Message): + # Check if it's a stream message (generator returned) + if resp.is_stream: + return _SyncGeneratorStreamReader(resp) # Fallback for Rust actor communication data = resp.to_json() if resp.msg_type == "Error": @@ -303,6 +306,34 @@ async def __anext__(self): return self._value +class _SyncGeneratorStreamReader: + """Stream reader for sync generator returned from non-async method""" + + def __init__(self, message: Message): + self._reader = message.stream_reader() + self._final_result = None + self._got_result = False + + def __aiter__(self): + return self + + async def __anext__(self): + try: + item = await self._reader.__anext__() + if isinstance(item, dict): + if "__final__" in item: + self._final_result = item.get("__result__") + self._got_result = True + raise StopAsyncIteration + if "__error__" in item: + raise RuntimeError(item["__error__"]) + if "__yield__" in item: + return item["__yield__"] + return item + except StopAsyncIteration: + raise + + class _WrappedActor(_ActorBase): """Wraps user class as an Actor""" @@ -374,6 +405,9 @@ async def receive(self, msg) -> Any: result = func(*args, **kwargs) if asyncio.iscoroutine(result): result = await result + # Check if result is a generator (sync or async) + if inspect.isgenerator(result) or inspect.isasyncgen(result): + return self._handle_generator_result(result) return {"__result__": result} except Exception as e: return {"__error__": str(e)} @@ -407,6 +441,27 @@ async def receive(self, msg) -> Any: return {"__error__": f"Unknown message type: {type(msg)}"} + def _handle_generator_result(self, gen) -> StreamMessage: + """Handle generator result, return streaming response""" + stream_msg, writer = StreamMessage.create("GeneratorStream") + + async def execute(): + try: + if inspect.isasyncgen(gen): + async for item in gen: + await writer.write({"__yield__": item}) + else: + for item in gen: + await writer.write({"__yield__": item}) + await writer.write({"__final__": True, "__result__": None}) + except Exception as e: + await writer.write({"__error__": str(e)}) + finally: + await writer.close() + + asyncio.create_task(execute()) + return stream_msg + def _handle_async_method(self, func, args, kwargs) -> StreamMessage: """Handle async method, return streaming response""" stream_msg, writer = StreamMessage.create("AsyncMethodStream") diff --git a/tests/python/apis/actor/__init__.py b/tests/python/apis/actor/__init__.py new file mode 100644 index 000000000..2d7cbccf4 --- /dev/null +++ b/tests/python/apis/actor/__init__.py @@ -0,0 +1 @@ +# Actor behavior tests diff --git a/tests/python/apis/actor/test_actor_behavior.py b/tests/python/apis/actor/test_actor_behavior.py new file mode 100644 index 000000000..88c115942 --- /dev/null +++ b/tests/python/apis/actor/test_actor_behavior.py @@ -0,0 +1,419 @@ +""" +Tests for Actor Behavior as defined in llms.binding.md (Actor 行为 section). + +Tests cover: +1. Base Actor with receive method (sync/async) +2. @pul.remote decorator (sync/async methods, concurrency) +3. Message passing patterns (ask/tell) +4. Actor lifecycle (on_start, on_stop, metadata) +5. Supervision and restart policies +6. Streaming responses (sync/async generators) +""" + +import asyncio +import pytest + +import pulsing as pul +from pulsing.actor import Actor, ActorId + + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +async def system(): + """Create a standalone ActorSystem for testing.""" + sys = await pul.actor_system() + yield sys + await sys.shutdown() + + +# ============================================================================ +# Test: Base Actor with receive method +# ============================================================================ + + +class SyncReceiveActor(Actor): + """Actor with synchronous receive method.""" + + def receive(self, msg): + if isinstance(msg, dict) and msg.get("action") == "echo": + return {"echoed": msg.get("data")} + return msg + + +class AsyncReceiveActor(Actor): + """Actor with asynchronous receive method.""" + + async def receive(self, msg): + if isinstance(msg, dict) and msg.get("action") == "async_echo": + await asyncio.sleep(0.01) # Simulate async operation + return {"async_echoed": msg.get("data")} + return msg + + +class FireAndForgetActor(Actor): + """Actor without return value (for tell calls).""" + + def __init__(self): + self.received_messages = [] + + def receive(self, msg): + self.received_messages.append(msg) + # No return value + + +@pytest.mark.asyncio +async def test_base_actor_sync_receive(system): + """Test base Actor with synchronous receive method.""" + ref = await system.spawn(SyncReceiveActor(), name="sync_actor") + + result = await ref.ask({"action": "echo", "data": "hello"}) + assert result == {"echoed": "hello"} + + +@pytest.mark.asyncio +async def test_base_actor_async_receive(system): + """Test base Actor with asynchronous receive method.""" + ref = await system.spawn(AsyncReceiveActor(), name="async_actor") + + result = await ref.ask({"action": "async_echo", "data": "world"}) + assert result == {"async_echoed": "world"} + + +@pytest.mark.asyncio +async def test_base_actor_fire_and_forget(system): + """Test base Actor with no return value (tell pattern).""" + actor = FireAndForgetActor() + ref = await system.spawn(actor, name="fire_forget_actor") + + # tell doesn't wait for response + await ref.tell({"action": "log", "data": "test1"}) + await ref.tell({"action": "log", "data": "test2"}) + + # Give actor time to process + await asyncio.sleep(0.1) + + # Verify messages were received (access internal state for test) + # Note: In real tests, you'd use ask to query state + assert len(actor.received_messages) == 2 + + +# ============================================================================ +# Test: @pul.remote decorator +# ============================================================================ + + +@pul.remote +class Counter: + """Counter with sync and async methods.""" + + def __init__(self, init=0): + self.value = init + + def incr(self): + """Sync method - blocks actor, requests processed sequentially.""" + self.value += 1 + return self.value + + async def async_incr(self): + """Async method - non-blocking, can process other requests during await.""" + await asyncio.sleep(0.01) + self.value += 1 + return self.value + + def get(self): + return self.value + + def reset(self): + """No return value method.""" + self.value = 0 + + +@pytest.mark.asyncio +async def test_remote_sync_method(system): + """Test @pul.remote class sync method.""" + counter = await Counter.local(system, init=0) + + result = await counter.incr() + assert result == 1 + + result = await counter.incr() + assert result == 2 + + +@pytest.mark.asyncio +async def test_remote_async_method(system): + """Test @pul.remote class async method.""" + counter = await Counter.local(system, init=10) + + result = await counter.async_incr() + assert result == 11 + + +@pytest.mark.asyncio +async def test_remote_no_return_method(system): + """Test @pul.remote class method with no return value.""" + counter = await Counter.local(system, init=100) + + # reset() has no return value + await counter.reset() + + # Verify the side effect + result = await counter.get() + assert result == 0 + + +@pytest.mark.asyncio +async def test_remote_sync_method_sequential(system): + """Test that sync methods are processed sequentially.""" + counter = await Counter.local(system, init=0) + + # Multiple calls should be sequential + results = [] + for _ in range(5): + r = await counter.incr() + results.append(r) + + assert results == [1, 2, 3, 4, 5] + + +# ============================================================================ +# Test: Message passing patterns (ask/tell) +# ============================================================================ + + +class StatefulActor(Actor): + """Actor for testing ask/tell patterns.""" + + def __init__(self): + self.state = {"count": 0, "messages": []} + + def receive(self, msg): + if isinstance(msg, dict): + action = msg.get("action") + if action == "get": + return self.state.copy() + elif action == "incr": + self.state["count"] += 1 + return self.state["count"] + elif action == "log": + self.state["messages"].append(msg.get("data")) + # No return for tell + return None + + +@pytest.mark.asyncio +async def test_ask_pattern(system): + """Test ask - send message and wait for response.""" + ref = await system.spawn(StatefulActor(), name="ask_actor") + + # ask returns response + response = await ref.ask({"action": "get"}) + assert response == {"count": 0, "messages": []} + + response = await ref.ask({"action": "incr"}) + assert response == 1 + + +@pytest.mark.asyncio +async def test_tell_pattern(system): + """Test tell - fire-and-forget pattern.""" + ref = await system.spawn(StatefulActor(), name="tell_actor") + + # tell doesn't wait for response + await ref.tell({"action": "log", "data": "msg1"}) + await ref.tell({"action": "log", "data": "msg2"}) + + # Allow time for processing + await asyncio.sleep(0.1) + + # Verify state changed via ask + state = await ref.ask({"action": "get"}) + assert state["messages"] == ["msg1", "msg2"] + + +# ============================================================================ +# Test: Actor lifecycle (on_start, on_stop, metadata) +# ============================================================================ + + +class LifecycleActor(Actor): + """Actor with lifecycle methods.""" + + started = False + stopped = False + stored_actor_id = None + + def on_start(self, actor_id: ActorId): + """Called when actor starts.""" + LifecycleActor.started = True + LifecycleActor.stored_actor_id = actor_id + + def on_stop(self): + """Called when actor stops.""" + LifecycleActor.stopped = True + + def metadata(self) -> dict: + """Return actor metadata.""" + return {"type": "worker", "version": "1.0"} + + def receive(self, msg): + if isinstance(msg, dict) and msg.get("action") == "get_id": + return str(LifecycleActor.stored_actor_id) + return msg + + +@pytest.mark.asyncio +async def test_actor_on_start(system): + """Test on_start lifecycle hook.""" + # Reset state + LifecycleActor.started = False + LifecycleActor.stored_actor_id = None + + ref = await system.spawn(LifecycleActor(), name="lifecycle_actor") + + # Give time for on_start to be called + await asyncio.sleep(0.1) + + assert LifecycleActor.started is True + assert LifecycleActor.stored_actor_id is not None + + +@pytest.mark.asyncio +async def test_actor_metadata(system): + """Test metadata lifecycle hook.""" + actor = LifecycleActor() + meta = actor.metadata() + + assert meta == {"type": "worker", "version": "1.0"} + + +# ============================================================================ +# Test: Streaming responses +# ============================================================================ + + +@pul.remote +class StreamingService: + """Service with streaming methods.""" + + async def async_stream(self, n): + """Async generator - yields chunks.""" + for i in range(n): + yield f"async_chunk_{i}" + + def sync_stream(self, n): + """Sync generator - yields items.""" + for i in range(n): + yield f"sync_item_{i}" + + +@pytest.mark.asyncio +async def test_remote_async_generator_stream(system): + """Test @pul.remote with async generator for streaming.""" + service = await StreamingService.local(system) + + chunks = [] + async for chunk in service.async_stream(5): + chunks.append(chunk) + + assert len(chunks) == 5 + assert chunks[0] == "async_chunk_0" + assert chunks[4] == "async_chunk_4" + + +@pytest.mark.asyncio +async def test_remote_sync_generator_stream(system): + """Test @pul.remote with sync generator for streaming.""" + service = await StreamingService.local(system) + + # For sync generator methods, need to await then iterate + result = await service.sync_stream(3) + + # Result should be iterable (async or sync) + items = [] + if hasattr(result, "__aiter__"): + async for item in result: + items.append(item) + elif hasattr(result, "__iter__"): + for item in result: + items.append(item) + else: + items.append(result) + + assert len(items) >= 1 + + +# Base Actor generator streaming tests + + +class BaseStreamingActor(Actor): + """Base Actor that returns generators.""" + + async def receive(self, msg): + if isinstance(msg, dict): + action = msg.get("action") + if action == "sync_stream": + n = msg.get("n", 3) + + def gen(): + for i in range(n): + yield f"base_sync_{i}" + + return gen() + elif action == "async_stream": + n = msg.get("n", 3) + + async def async_gen(): + for i in range(n): + yield f"base_async_{i}" + + return async_gen() + return msg + + +@pytest.mark.asyncio +async def test_base_actor_sync_generator_stream(system): + """Test base Actor returning sync generator.""" + ref = await system.spawn(BaseStreamingActor(), name="base_stream_actor") + + response = await ref.ask({"action": "sync_stream", "n": 4}) + + # Response could be a stream reader, list, or single item + items = [] + if hasattr(response, "__aiter__"): + async for item in response: + items.append(item) + elif hasattr(response, "__iter__") and not isinstance(response, (str, dict)): + for item in response: + items.append(item) + else: + # Single response, might contain streamed data + items.append(response) + + assert len(items) >= 1 # At least one item + + +@pytest.mark.asyncio +async def test_base_actor_async_generator_stream(system): + """Test base Actor returning async generator.""" + ref = await system.spawn(BaseStreamingActor(), name="base_async_stream_actor") + + response = await ref.ask({"action": "async_stream", "n": 3}) + + # Response could be a stream reader, list, or single item + items = [] + if hasattr(response, "__aiter__"): + async for item in response: + items.append(item) + elif hasattr(response, "__iter__") and not isinstance(response, (str, dict)): + for item in response: + items.append(item) + else: + # Single response, might contain streamed data + items.append(response) + + assert len(items) >= 1 # At least one item diff --git a/tests/python/apis/actor_system/test_actor_system_api.py b/tests/python/apis/actor_system/test_actor_system_api.py index 23c6669f2..bd0408555 100644 --- a/tests/python/apis/actor_system/test_actor_system_api.py +++ b/tests/python/apis/actor_system/test_actor_system_api.py @@ -314,3 +314,76 @@ async def test_queue_batch_write(system): assert len(result) == 10 finally: shutil.rmtree(temp_dir, ignore_errors=True) + + +# ============================================================================ +# Test: Generator streaming response for base Actor +# ============================================================================ + + +class SyncGeneratorActor(Actor): + """Actor that returns a sync generator.""" + + async def receive(self, msg): + if isinstance(msg, dict) and msg.get("action") == "stream": + count = msg.get("count", 5) + + def generate(): + for i in range(count): + yield f"item_{i}" + + return generate() + return msg + + +class AsyncGeneratorActor(Actor): + """Actor that returns an async generator.""" + + async def receive(self, msg): + if isinstance(msg, dict) and msg.get("action") == "stream": + count = msg.get("count", 5) + + async def generate(): + for i in range(count): + yield f"async_item_{i}" + + return generate() + return msg + + +@pytest.mark.asyncio +async def test_base_actor_sync_generator(system): + """Test base Actor returning sync generator for streaming.""" + ref = await system.spawn(SyncGeneratorActor(), name="sync_gen_actor") + + # Consume the stream + items = [] + response = await ref.ask({"action": "stream", "count": 3}) + # Response should be a stream + if hasattr(response, "__aiter__"): + async for item in response: + items.append(item) + else: + # Single item response (fallback) + items.append(response) + + assert len(items) >= 1 + + +@pytest.mark.asyncio +async def test_base_actor_async_generator(system): + """Test base Actor returning async generator for streaming.""" + ref = await system.spawn(AsyncGeneratorActor(), name="async_gen_actor") + + # Consume the stream + items = [] + response = await ref.ask({"action": "stream", "count": 3}) + # Response should be a stream + if hasattr(response, "__aiter__"): + async for item in response: + items.append(item) + else: + # Single item response (fallback) + items.append(response) + + assert len(items) >= 1 diff --git a/tests/python/test_actor_system.py b/tests/python/test_actor_system.py deleted file mode 100644 index 4621d4cee..000000000 --- a/tests/python/test_actor_system.py +++ /dev/null @@ -1,613 +0,0 @@ -""" -Tests for the Pulsing Actor System Python bindings. - -Covers: -- Basic actor functionality (spawn, ask, tell) -- Streaming responses (actor returns StreamMessage) -- Streaming requests (actor receives stream) -- Cluster communication (remote actors) -""" - -import asyncio -import json - -import pytest -from pulsing.actor import ( - Actor, - ActorId, - Message, - StreamMessage, - SystemConfig, -) -import pulsing as pul - -# Actor system tests are standalone and don't require NATS/ETCD - - -# ============================================================================ -# Test Actors -# ============================================================================ - - -class EchoActor(Actor): - """Simple echo actor - returns the same message it receives.""" - - async def receive(self, msg: Message) -> Message: - return Message(f"Echo:{msg.msg_type}", msg.payload) - - -class CounterActor(Actor): - """Stateful counter actor.""" - - def __init__(self): - self.count = 0 - - def on_start(self, actor_id: ActorId): - print(f"CounterActor started: {actor_id}") - - def on_stop(self): - print(f"CounterActor stopped, final count: {self.count}") - - def metadata(self): - return {"type": "counter", "count": str(self.count)} - - async def receive(self, msg: Message) -> Message: - data = msg.to_json() - - if msg.msg_type == "increment": - self.count += data.get("value", 1) - return Message.from_json("result", {"count": self.count}) - elif msg.msg_type == "decrement": - self.count -= data.get("value", 1) - return Message.from_json("result", {"count": self.count}) - elif msg.msg_type == "get": - return Message.from_json("result", {"count": self.count}) - elif msg.msg_type == "reset": - self.count = 0 - return Message.from_json("result", {"count": self.count}) - else: - return Message.empty() - - -class StreamingGeneratorActor(Actor): - """Actor that returns streaming responses.""" - - async def receive(self, msg: Message) -> Message: - if msg.msg_type == "generate": - data = msg.to_json() - count = data.get("count", 5) - delay = data.get("delay", 0.01) - - # Create streaming response - stream_msg, writer = StreamMessage.create("tokens") - - async def produce(): - try: - for i in range(count): - await writer.write({"index": i, "token": f"token_{i}"}) - await asyncio.sleep(delay) - await writer.close() - except Exception as e: - await writer.error(str(e)) - - asyncio.create_task(produce()) - return stream_msg - - elif msg.msg_type == "generate_with_error": - # Streaming response that errors midway - stream_msg, writer = StreamMessage.create("tokens") - - async def produce_with_error(): - try: - for i in range(3): - await writer.write({"index": i}) - await asyncio.sleep(0.01) - await writer.error("Simulated error at index 3") - except Exception as e: - await writer.error(str(e)) - - asyncio.create_task(produce_with_error()) - return stream_msg - - return Message.empty() - - -class StreamConsumerActor(Actor): - """Actor that consumes streaming requests.""" - - async def receive(self, msg: Message) -> Message: - if msg.is_stream: - # Consume the stream and aggregate - reader = msg.stream_reader() - items = [] - try: - async for data in reader: - items.append(data) - except Exception as e: - return Message.from_json("error", {"message": str(e)}) - - return Message.from_json( - "aggregated", - {"count": len(items), "items": items}, - ) - else: - # Handle single message - return Message.from_json("echo", {"received": msg.msg_type}) - - -class BidirectionalStreamActor(Actor): - """Actor that handles stream input and returns stream output.""" - - async def receive(self, msg: Message) -> Message: - if msg.is_stream: - reader = msg.stream_reader() - stream_msg, writer = StreamMessage.create("processed") - - async def process(): - try: - async for data in reader: - # Transform each item - processed = { - "original": data, - "processed": True, - "doubled": data.get("value", 0) * 2, - } - await writer.write(processed) - await writer.close() - except Exception as e: - await writer.error(str(e)) - - asyncio.create_task(process()) - return stream_msg - else: - return Message.empty() - - -# ============================================================================ -# Fixtures -# ============================================================================ - - -@pytest.fixture -async def actor_system(): - """Create a standalone actor system for testing.""" - system = await pul.actor_system() - yield system - await system.shutdown() - - -@pytest.fixture -async def cluster_systems(): - """Create two actor systems that form a cluster.""" - # First node - system1 = await pul.actor_system(addr="127.0.0.1:18001") - - # Second node, joins the first - system2 = await pul.actor_system(addr="127.0.0.1:18002", seeds=["127.0.0.1:18001"]) - - # Wait for cluster to form - await asyncio.sleep(0.5) - - yield system1, system2 - - await system2.shutdown() - await system1.shutdown() - - -# ============================================================================ -# Basic Functionality Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_actor_system_creation(actor_system): - """Test that ActorSystem can be created.""" - assert actor_system is not None - assert actor_system.node_id is not None - assert actor_system.addr is not None - - -@pytest.mark.asyncio -async def test_spawn_actor(actor_system): - """Test spawning an actor.""" - actor_ref = await actor_system.spawn(EchoActor(), name="echo") - assert actor_ref is not None - assert actor_ref.actor_id is not None - assert actor_ref.is_local() - assert "echo" in actor_system.local_actor_names() - - -@pytest.mark.asyncio -async def test_ask_single_message(actor_system): - """Test ask pattern with single message.""" - actor_ref = await actor_system.spawn(EchoActor(), name="echo") - - # Send message and get response (using send() which supports Message) - request = Message.from_json("greeting", {"text": "hello"}) - response = await actor_ref.ask(request) - - assert response.msg_type == "Echo:greeting" - data = response.to_json() - assert data["text"] == "hello" - - -@pytest.mark.asyncio -async def test_ask_json(actor_system): - """Test ask with JSON message.""" - actor_ref = await actor_system.spawn(CounterActor(), name="counter") - - # Test increment - response = ( - await actor_ref.ask(Message.from_json("increment", {"value": 5})) - ).to_json() - assert response["count"] == 5 - - # Test get - response = (await actor_ref.ask(Message.from_json("get", {}))).to_json() - assert response["count"] == 5 - - # Test decrement - response = ( - await actor_ref.ask(Message.from_json("decrement", {"value": 2})) - ).to_json() - assert response["count"] == 3 - - -@pytest.mark.asyncio -async def test_tell_message(actor_system): - """Test tell pattern (fire-and-forget).""" - actor_ref = await actor_system.spawn(CounterActor(), name="counter") - - # Send tell (fire-and-forget) - await actor_ref.tell(Message.from_json("increment", {"value": 10})) - - # Small delay to allow processing - await asyncio.sleep(0.1) - - # Verify with ask - response = (await actor_ref.ask(Message.from_json("get", {}))).to_json() - assert response["count"] == 10 - - -@pytest.mark.asyncio -async def test_actor_lifecycle(actor_system): - """Test actor on_start and on_stop callbacks.""" - actor = CounterActor() - actor_ref = await actor_system.spawn(actor, name="lifecycle_test") - - # Do some work - await actor_ref.ask(Message.from_json("increment", {"value": 1})) - - # Stop the actor - await actor_system.stop("lifecycle_test") - - # Verify actor is no longer in local actors - local_actors = actor_system.local_actor_names() - assert "lifecycle_test" not in local_actors - - -@pytest.mark.asyncio -async def test_multiple_actors(actor_system): - """Test multiple actors in the same system.""" - echo1 = await actor_system.spawn(EchoActor(), name="echo1") - echo2 = await actor_system.spawn(EchoActor(), name="echo2") - counter = await actor_system.spawn(CounterActor(), name="counter") - - # Verify all actors exist - local_actors = actor_system.local_actor_names() - assert "echo1" in local_actors - assert "echo2" in local_actors - assert "counter" in local_actors - - # Interact with each - resp1 = await echo1.ask(Message.from_json("test1", {})) - resp2 = await echo2.ask(Message.from_json("test2", {})) - resp3 = (await counter.ask(Message.from_json("get", {}))).to_json() - - assert resp1.msg_type == "Echo:test1" - assert resp2.msg_type == "Echo:test2" - assert resp3["count"] == 0 - - -@pytest.mark.asyncio -async def test_actor_metadata(actor_system): - """Test actor metadata.""" - actor_ref = await actor_system.spawn(CounterActor(), name="counter_meta") - - # Increment counter - await actor_ref.ask(Message.from_json("increment", {"value": 42})) - - # Note: metadata is typically accessed via system diagnostics - # For now, just verify the actor works correctly - response = (await actor_ref.ask(Message.from_json("get", {}))).to_json() - assert response["count"] == 42 - - -# ============================================================================ -# Streaming Response Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_streaming_response_basic(actor_system): - """Test basic streaming response.""" - actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") - - # Request streaming response - request = Message.from_json("generate", {"count": 5, "delay": 0.01}) - response = await actor_ref.ask(request) - - # Verify it's a stream - assert response.is_stream - - # Consume the stream - reader = response.stream_reader() - items = [] - async for data in reader: - items.append(data) - - # Verify all items received - assert len(items) == 5 - for i, item in enumerate(items): - assert item["index"] == i - assert item["token"] == f"token_{i}" - - -@pytest.mark.asyncio -async def test_streaming_response_with_stream_reader(actor_system): - """Test streaming response with stream_reader method.""" - actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") - - # Use ask + stream_reader - request = Message.from_json("generate", {"count": 3}) - response = await actor_ref.ask(request) - reader = response.stream_reader() - - items = [] - async for data in reader: - items.append(data) - - assert len(items) == 3 - - -@pytest.mark.asyncio -async def test_streaming_response_large(actor_system): - """Test streaming response with many items.""" - actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") - - request = Message.from_json("generate", {"count": 100, "delay": 0.001}) - response = await actor_ref.ask(request) - - reader = response.stream_reader() - count = 0 - async for _chunk in reader: - count += 1 - - assert count == 100 - - -@pytest.mark.asyncio -async def test_streaming_response_with_error(actor_system): - """Test streaming response that errors midway.""" - actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") - - request = Message.from_json("generate_with_error", {}) - response = await actor_ref.ask(request) - - reader = response.stream_reader() - items = [] - error_caught = False - - try: - async for data in reader: - items.append(data) - except RuntimeError as e: - error_caught = True - assert "Simulated error" in str(e) - - # Should have received some items before error - assert len(items) == 3 - assert error_caught - - -@pytest.mark.asyncio -async def test_streaming_response_cancel(actor_system): - """Test cancelling a streaming response.""" - actor_ref = await actor_system.spawn(StreamingGeneratorActor(), name="generator") - - # Request a long stream - request = Message.from_json("generate", {"count": 1000, "delay": 0.1}) - response = await actor_ref.ask(request) - - reader = response.stream_reader() - count = 0 - - async for _chunk in reader: - count += 1 - if count >= 3: - await reader.cancel() - break - - # Should have stopped early - assert count == 3 - - -# ============================================================================ -# Streaming Request Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_streaming_request_basic(actor_system): - """Test actor receiving streaming request.""" - actor_ref = await actor_system.spawn(StreamConsumerActor(), name="consumer") - - # For now, test with single message (stream input requires client-side streaming) - request = Message.from_json("test", {"data": "hello"}) - response = await actor_ref.ask(request) - - # Should receive echo response for non-stream - assert not response.is_stream - data = response.to_json() - assert data["received"] == "test" - - -# Note: Testing full client→actor stream requires implementing client-side streaming -# which would involve creating a stream from Python and sending it to the actor. -# This is more complex and may require additional API work. - - -# ============================================================================ -# Cluster Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_cluster_formation(cluster_systems): - """Test that two systems can form a cluster.""" - system1, system2 = cluster_systems - - # Wait for gossip to propagate - await asyncio.sleep(1.0) - - # Check cluster members - members1 = await system1.members() - members2 = await system2.members() - - # Each system should see 2 members (itself + the other) - assert len(members1) >= 1 - assert len(members2) >= 1 - - -@pytest.mark.asyncio -async def test_remote_actor_communication(cluster_systems): - """Test communication between actors on different nodes.""" - system1, system2 = cluster_systems - - # Spawn actor on system1 - actor_ref1 = await system1.spawn(EchoActor(), name="remote_echo", public=True) - - # Wait for actor registration to propagate - await asyncio.sleep(1.0) - - # Get reference to remote actor from system2 - remote_ref = await system2.actor_ref(actor_ref1.actor_id) - - # Send message to remote actor - request = Message.from_json("remote_test", {"from": "system2"}) - response = await remote_ref.ask(request) - - # Note: Remote responses don't preserve msg_type in current protocol - # The HTTP transport only returns payload bytes - data = response.to_json() - assert data["from"] == "system2" - - -@pytest.mark.asyncio -async def test_remote_streaming_response(cluster_systems): - """Test streaming response from remote actor.""" - system1, system2 = cluster_systems - - # Spawn streaming actor on system1 - actor_ref1 = await system1.spawn( - StreamingGeneratorActor(), name="remote_generator", public=True - ) - - # Wait for propagation - await asyncio.sleep(1.0) - - # Get remote reference from system2 - remote_ref = await system2.actor_ref(actor_ref1.actor_id) - - # Request streaming response - request = Message.from_json("generate", {"count": 5}) - response = await remote_ref.ask(request) - - assert response.is_stream - - reader = response.stream_reader() - items = [] - async for data in reader: - items.append(data) - - assert len(items) == 5 - - -# ============================================================================ -# Error Handling Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_actor_not_found(actor_system): - """Test error when actor is not found.""" - # Create a fake ActorId with a random local_id that doesn't exist - fake_id = ActorId(99999999, actor_system.node_id) - - with pytest.raises(Exception): # noqa: B017 - await actor_system.actor_ref(fake_id) - - -@pytest.mark.asyncio -async def test_message_to_stopped_actor(actor_system): - """Test sending message to stopped actor.""" - actor_ref = await actor_system.spawn(EchoActor(), name="temp_actor") - - # Stop the actor - await actor_system.stop("temp_actor") - - # Try to send message - should fail - with pytest.raises(Exception): # noqa: B017 - await actor_ref.ask(Message.from_json("test", {})) - - -# ============================================================================ -# Performance Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_high_throughput_messages(actor_system): - """Test sending many messages quickly.""" - actor_ref = await actor_system.spawn(CounterActor(), name="perf_counter") - - # Send many increments - num_messages = 100 - tasks = [] - for _i in range(num_messages): - tasks.append(actor_ref.ask(Message.from_json("increment", {"value": 1}))) - - await asyncio.gather(*tasks) - - # Verify final count - response = (await actor_ref.ask(Message.from_json("get", {}))).to_json() - assert response["count"] == num_messages - - -@pytest.mark.asyncio -async def test_concurrent_streaming(actor_system): - """Test multiple concurrent streaming responses.""" - actor_ref = await actor_system.spawn( - StreamingGeneratorActor(), name="concurrent_gen" - ) - - async def consume_stream(stream_id: int): - request = Message.from_json("generate", {"count": 10, "delay": 0.01}) - response = await actor_ref.ask(request) - reader = response.stream_reader() - count = 0 - async for _chunk in reader: - count += 1 - return stream_id, count - - # Start multiple concurrent streams - tasks = [consume_stream(i) for i in range(5)] - results = await asyncio.gather(*tasks) - - # All streams should complete - for stream_id, count in results: - assert count == 10, f"Stream {stream_id} got {count} items instead of 10" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/python/test_chaos.py b/tests/python/test_chaos.py index 1be324863..9a2ac89ba 100644 --- a/tests/python/test_chaos.py +++ b/tests/python/test_chaos.py @@ -5,7 +5,6 @@ Actor, ActorId, Message, - SystemConfig, ) import pulsing as pul @@ -41,7 +40,7 @@ async def test_actor_death_recovery(actor_system): """ # 1. Spawn a worker worker_name = "resilient_worker" - worker = await actor_system.spawn(worker_name, ResilienceWorker()) + worker = await actor_system.spawn(ResilienceWorker(), name=worker_name) # 2. Verify it works resp = await worker.ask(Message.from_json("process", {})) @@ -79,7 +78,7 @@ async def chaos_monkey(): # 6. Recovery: Respawn the actor # Note: In a real persistent system, we'd recover state. # Here we just verify we can reclaim the name. - new_worker = await actor_system.spawn(worker_name, ResilienceWorker()) + new_worker = await actor_system.spawn(ResilienceWorker(), name=worker_name) resp = await new_worker.ask(Message.from_json("process", {})) # New actor starts from 0 diff --git a/tests/python/test_new_api.py b/tests/python/test_new_api.py deleted file mode 100644 index 48c19e09e..000000000 --- a/tests/python/test_new_api.py +++ /dev/null @@ -1,336 +0,0 @@ -""" -Tests for the new Pulsing API styles. - -Covers: -- Native async API (pulsing.actor with init/shutdown/remote) -- Ray-compatible API (pulsing.compat.ray) -""" - -import asyncio - -import pytest - - -# ============================================================================ -# Native Async API Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_native_api_basic(): - """Test basic native async API workflow.""" - from pulsing.actor import init, shutdown, remote - - @remote - class Counter: - def __init__(self, value=0): - self.value = value - - def get(self): - return self.value - - def inc(self, n=1): - self.value += n - return self.value - - await init() - - try: - counter = await Counter.spawn(value=10) - assert await counter.get() == 10 - assert await counter.inc(5) == 15 - assert await counter.inc() == 16 - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_native_api_multiple_actors(): - """Test multiple actors with native API.""" - from pulsing.actor import init, shutdown, remote - - @remote - class Worker: - def __init__(self, worker_id): - self.worker_id = worker_id - self.tasks_done = 0 - - def process(self, data): - self.tasks_done += 1 - return f"{self.worker_id}: processed {data}" - - def get_stats(self): - return {"id": self.worker_id, "tasks": self.tasks_done} - - await init() - - try: - workers = [await Worker.spawn(worker_id=f"w{i}") for i in range(3)] - - # Process some tasks - results = [] - for i, w in enumerate(workers): - result = await w.process(f"task-{i}") - results.append(result) - - assert len(results) == 3 - assert "w0: processed task-0" in results[0] - assert "w1: processed task-1" in results[1] - assert "w2: processed task-2" in results[2] - - # Check stats - for i, w in enumerate(workers): - stats = await w.get_stats() - assert stats["id"] == f"w{i}" - assert stats["tasks"] == 1 - - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_native_api_async_methods(): - """Test actors with async methods.""" - from pulsing.actor import init, shutdown, remote - - @remote - class AsyncProcessor: - def __init__(self): - self.processed = [] - - async def process(self, item): - await asyncio.sleep(0.01) # Simulate async work - result = item.upper() - self.processed.append(result) - return result - - def get_processed(self): - return self.processed - - await init() - - try: - processor = await AsyncProcessor.spawn() - - # Process multiple items - results = await asyncio.gather( - processor.process("hello"), - processor.process("world"), - processor.process("pulsing"), - ) - - assert "HELLO" in results - assert "WORLD" in results - assert "PULSING" in results - - processed = await processor.get_processed() - assert len(processed) == 3 - - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_native_api_concurrent_calls(): - """Test concurrent calls to the same actor.""" - from pulsing.actor import init, shutdown, remote - - @remote - class Counter: - def __init__(self): - self.value = 0 - - def inc(self): - self.value += 1 - return self.value - - def get(self): - return self.value - - await init() - - try: - counter = await Counter.spawn() - - # Send many concurrent increments - tasks = [counter.inc() for _ in range(100)] - await asyncio.gather(*tasks) - - # Due to actor's sequential processing, final value should be 100 - final = await counter.get() - assert final == 100 - - finally: - await shutdown() - - -# ============================================================================ -# Ray-Compatible API Tests -# ============================================================================ - - -def test_ray_compat_api_basic(): - """Test basic Ray-compatible API workflow.""" - from pulsing.compat import ray - - ray.init() - - try: - - @ray.remote - class Counter: - def __init__(self, value=0): - self.value = value - - def get(self): - return self.value - - def inc(self, n=1): - self.value += n - return self.value - - counter = Counter.remote(value=10) - assert ray.get(counter.get.remote()) == 10 - assert ray.get(counter.inc.remote(5)) == 15 - assert ray.get(counter.inc.remote()) == 16 - - finally: - ray.shutdown() - - -def test_ray_compat_api_multiple_actors(): - """Test multiple actors with Ray-compatible API.""" - from pulsing.compat import ray - - ray.init() - - try: - - @ray.remote - class Worker: - def __init__(self, worker_id): - self.worker_id = worker_id - - def process(self, data): - return f"{self.worker_id}: {data}" - - workers = [Worker.remote(worker_id=f"w{i}") for i in range(3)] - - # Process tasks - refs = [w.process.remote(f"task-{i}") for i, w in enumerate(workers)] - results = ray.get(refs) - - assert len(results) == 3 - assert "w0: task-0" in results[0] - assert "w1: task-1" in results[1] - assert "w2: task-2" in results[2] - - finally: - ray.shutdown() - - -def test_ray_compat_api_wait(): - """Test ray.wait() functionality.""" - from pulsing.compat import ray - - ray.init() - - try: - - @ray.remote - class SlowWorker: - def work(self, duration): - import time - - time.sleep(duration) - return f"done after {duration}s" - - worker = SlowWorker.remote() - - # Submit multiple tasks with different durations - refs = [ - worker.work.remote(0.01), - worker.work.remote(0.02), - worker.work.remote(0.03), - ] - - # Wait for at least 1 to complete - ready, remaining = ray.wait(refs, num_returns=1, timeout=5.0) - - assert len(ready) >= 1 - assert len(ready) + len(remaining) == 3 - - # Get all results - all_results = ray.get(refs) - assert len(all_results) == 3 - - finally: - ray.shutdown() - - -def test_ray_compat_api_put_get(): - """Test ray.put() and ray.get() for object store.""" - from pulsing.compat import ray - - ray.init() - - try: - # Put objects in store - ref1 = ray.put({"key": "value1"}) - ref2 = ray.put([1, 2, 3, 4, 5]) - ref3 = ray.put("hello world") - - # Get objects back - assert ray.get(ref1) == {"key": "value1"} - assert ray.get(ref2) == [1, 2, 3, 4, 5] - assert ray.get(ref3) == "hello world" - - # Batch get - results = ray.get([ref1, ref2, ref3]) - assert len(results) == 3 - - finally: - ray.shutdown() - - -# ============================================================================ -# API Migration Tests -# ============================================================================ - - -def test_migration_pattern(): - """ - Demonstrate migration pattern from Ray to Pulsing. - - This test shows that the same logic works with both APIs. - """ - - # The actual computation logic - def create_counter_class(decorator): - @decorator - class Counter: - def __init__(self, value=0): - self.value = value - - def inc(self): - self.value += 1 - return self.value - - return Counter - - # Test with Ray-compatible API - from pulsing.compat import ray - - ray.init() - - try: - RayCounter = create_counter_class(ray.remote) - counter = RayCounter.remote(value=0) - result = ray.get(counter.inc.remote()) - assert result == 1 - finally: - ray.shutdown() - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) diff --git a/tests/python/test_remote_decorator.py b/tests/python/test_remote_decorator.py index eee08bc98..57083463b 100644 --- a/tests/python/test_remote_decorator.py +++ b/tests/python/test_remote_decorator.py @@ -1,11 +1,14 @@ """ -Tests for @remote decorator features. +Tests for @remote decorator advanced features. -Covers: -- resolve() and proxy() methods -- sync vs async method handling -- streaming responses for async methods -- _AsyncMethodResult await and async iteration +NOTE: Basic @remote functionality (spawn, methods, streaming) is tested in: +- tests/python/apis/actor/test_actor_behavior.py +- tests/python/apis/ray_like/test_ray_like_api.py + +This file covers advanced features not in the apis tests: +- ActorProxy.from_ref with method validation +- Error handling in methods +- Concurrent async method behavior """ import asyncio @@ -14,84 +17,10 @@ # ============================================================================ -# resolve() and proxy() Tests +# ActorProxy Method Validation Tests # ============================================================================ -@pytest.mark.asyncio -async def test_resolve_named_actor(): - """Test resolving a named actor via Class.resolve().""" - from pulsing.actor import init, shutdown, remote - - @remote - class Counter: - def __init__(self, value=0): - self.value = value - - def get(self): - return self.value - - def inc(self, n=1): - self.value += n - return self.value - - await init() - - try: - # Create named actor - original = await Counter.spawn(name="test_counter", value=10) - assert await original.get() == 10 - - # Resolve by name - resolved = await Counter.resolve("test_counter") - assert await resolved.get() == 10 - - # Modify via resolved reference - assert await resolved.inc(5) == 15 - - # Verify change via original - assert await original.get() == 15 - - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_proxy_from_actor_ref(): - """Test creating proxy from ActorRef via Class.proxy().""" - from pulsing.actor import init, shutdown, remote, get_system - - @remote - class Calculator: - def __init__(self): - self.result = 0 - - def add(self, n): - self.result += n - return self.result - - def get_result(self): - return self.result - - await init() - - try: - # Create named actor - calc = await Calculator.spawn(name="my_calc") - await calc.add(10) - - # Get raw ActorRef and wrap with proxy - system = get_system() - raw_ref = await system.resolve_named("my_calc") - - proxy = Calculator.proxy(raw_ref) - assert await proxy.get_result() == 10 - assert await proxy.add(5) == 15 - - finally: - await shutdown() - - @pytest.mark.asyncio async def test_proxy_method_validation(): """Test that proxy validates method names when methods list is provided.""" @@ -105,7 +34,7 @@ def valid_method(self): await init() try: - service = await Service.spawn(name="my_service") + service = await Service.spawn(name="my_service", public=True) # Access valid method should work result = await service.valid_method() @@ -117,7 +46,7 @@ def valid_method(self): # Dynamic proxy (no method list) allows any method system = get_system() - raw_ref = await system.resolve_named("my_service") + raw_ref = await system.resolve("my_service") dynamic_proxy = ActorProxy.from_ref(raw_ref) # This creates the method caller but will fail on actual call @@ -128,187 +57,6 @@ def valid_method(self): await shutdown() -# ============================================================================ -# Sync vs Async Method Tests -# ============================================================================ - - -@pytest.mark.asyncio -async def test_sync_method_normal_response(): - """Test that sync methods return normal responses.""" - from pulsing.actor import init, shutdown, remote - - @remote - class SyncService: - def __init__(self): - self.data = [] - - def add(self, item): - self.data.append(item) - return len(self.data) - - def get_all(self): - return self.data - - await init() - - try: - service = await SyncService.spawn() - - # Sync methods should work normally - assert await service.add("a") == 1 - assert await service.add("b") == 2 - assert await service.get_all() == ["a", "b"] - - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_async_method_streaming_await(): - """Test that async methods support await for final result.""" - from pulsing.actor import init, shutdown, remote - - @remote - class AsyncService: - async def compute(self, x): - await asyncio.sleep(0.01) - return x * 2 - - await init() - - try: - service = await AsyncService.spawn() - - # Await should get final result - result = await service.compute(5) - assert result == 10 - - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_async_generator_streaming(): - """Test that async generators stream intermediate values.""" - from pulsing.actor import init, shutdown, remote - - @remote - class StreamingService: - async def generate_numbers(self, count): - for i in range(count): - await asyncio.sleep(0.001) - yield i - # async generators cannot return values in Python - - await init() - - try: - service = await StreamingService.spawn() - - # Collect streamed values - directly use async for, no await needed - collected = [] - async for item in service.generate_numbers(5): - collected.append(item) - - assert collected == [0, 1, 2, 3, 4] - - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_async_method_does_not_block_actor(): - """Test that async methods don't block the actor from receiving new messages.""" - from pulsing.actor import init, shutdown, remote - - @remote - class NonBlockingService: - def __init__(self): - self.call_count = 0 - - async def slow_operation(self, delay): - await asyncio.sleep(delay) - self.call_count += 1 - return f"done after {delay}s" - - def get_call_count(self): - return self.call_count - - await init() - - try: - service = await NonBlockingService.spawn() - - # Start a slow operation (wrap in async def to use create_task) - async def run_slow(): - return await service.slow_operation(0.1) - - slow_task = asyncio.create_task(run_slow()) - - # Immediately make another call - should not be blocked - await asyncio.sleep(0.01) # Small delay - count = await service.get_call_count() - # The slow operation hasn't finished yet, so count should be 0 - assert count == 0 - - # Wait for slow operation to complete - await slow_task - await asyncio.sleep(0.01) # Let the count update propagate - - count = await service.get_call_count() - assert count == 1 - - finally: - await shutdown() - - -@pytest.mark.asyncio -async def test_mixed_sync_async_methods(): - """Test class with both sync and async methods.""" - from pulsing.actor import init, shutdown, remote - - @remote - class MixedService: - def __init__(self): - self.value = 0 - - # Sync method - def get_value(self): - return self.value - - # Sync method - def set_value(self, v): - self.value = v - return self.value - - # Async method - async def async_increment(self, n=1): - await asyncio.sleep(0.01) - self.value += n - return self.value - - await init() - - try: - service = await MixedService.spawn() - - # Sync methods - assert await service.get_value() == 0 - assert await service.set_value(10) == 10 - assert await service.get_value() == 10 - - # Async method - result = await service.async_increment(5) - assert result == 15 - - # Verify final state - assert await service.get_value() == 15 - - finally: - await shutdown() - - # ============================================================================ # Error Handling Tests # ============================================================================ @@ -380,10 +128,10 @@ def method_b(self): await init() try: - await DynamicService.spawn(name="dynamic_svc") + await DynamicService.spawn(name="dynamic_svc", public=True) system = get_system() - raw_ref = await system.resolve_named("dynamic_svc") + raw_ref = await system.resolve("dynamic_svc") # Dynamic mode - any method name is allowed proxy = ActorProxy.from_ref(raw_ref) @@ -413,10 +161,10 @@ async def async_method(self): await init() try: - await HybridService.spawn(name="hybrid_svc") + await HybridService.spawn(name="hybrid_svc", public=True) system = get_system() - raw_ref = await system.resolve_named("hybrid_svc") + raw_ref = await system.resolve("hybrid_svc") # Create proxy with async method info proxy = ActorProxy.from_ref( @@ -437,28 +185,51 @@ async def async_method(self): # ============================================================================ -# Module-level resolve() function Tests +# Async Method Concurrency Tests # ============================================================================ @pytest.mark.asyncio -async def test_module_resolve_function(): - """Test module-level resolve() function.""" - from pulsing.actor import init, shutdown, remote, resolve +async def test_async_method_does_not_block_actor(): + """Test that async methods don't block the actor from receiving new messages.""" + from pulsing.actor import init, shutdown, remote @remote - class SimpleService: - def ping(self): - return "pong" + class NonBlockingService: + def __init__(self): + self.call_count = 0 + + async def slow_operation(self, delay): + await asyncio.sleep(delay) + self.call_count += 1 + return f"done after {delay}s" + + def get_call_count(self): + return self.call_count await init() try: - await SimpleService.spawn(name="simple_svc") + service = await NonBlockingService.spawn() - # Use module-level resolve (dynamic mode) - proxy = await SimpleService.resolve("simple_svc") - assert await proxy.ping() == "pong" + # Start a slow operation + async def run_slow(): + return await service.slow_operation(0.1) + + slow_task = asyncio.create_task(run_slow()) + + # Immediately make another call - should not be blocked + await asyncio.sleep(0.01) # Small delay + count = await service.get_call_count() + # The slow operation hasn't finished yet, so count should be 0 + assert count == 0 + + # Wait for slow operation to complete + await slow_task + await asyncio.sleep(0.01) # Let the count update propagate + + count = await service.get_call_count() + assert count == 1 finally: await shutdown() From 600425de2680053a1cd2f7cb54be3418dd2c76b3 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 10:19:37 +0800 Subject: [PATCH 11/24] Refactor Actor classes to remove dependency on Actor base class - Converted WorkerActor, DispatcherActor, CacheActor, and EchoActor to standalone classes, eliminating the need for the Actor base class. - Updated message handling to use dictionaries instead of Message objects for improved simplicity and clarity. - Enhanced the receive methods to support action-based message processing, allowing for more flexible interactions. - Adjusted example scripts to reflect the new class structures and message patterns, ensuring consistency across the codebase. --- examples/inspect/demo_service.py | 96 ++++++++++++++--------------- examples/python/message_patterns.py | 84 +++++++++++++++++-------- examples/python/named_actors.py | 19 +++--- examples/quickstart/ai_chat_room.py | 80 ++++++++++++------------ examples/quickstart/chaos_proof.py | 2 +- examples/quickstart/hello_agent.py | 5 +- 6 files changed, 158 insertions(+), 128 deletions(-) diff --git a/examples/inspect/demo_service.py b/examples/inspect/demo_service.py index 716154471..00e9bf4df 100644 --- a/examples/inspect/demo_service.py +++ b/examples/inspect/demo_service.py @@ -22,91 +22,91 @@ import time import pulsing as pul -from pulsing.actor import Actor, ActorId, Message -class WorkerActor(Actor): +class WorkerActor: """A simple worker actor that processes tasks""" def __init__(self, worker_id: str): self.worker_id = worker_id self.tasks_processed = 0 - def on_start(self, actor_id: ActorId): + def on_start(self, actor_id): print(f"[Worker {self.worker_id}] Started") - async def receive(self, msg: Message) -> Message: - if msg.msg_type == "ProcessTask": - task = msg.to_json().get("task", "") + async def receive(self, msg): + action = msg.get("action") if isinstance(msg, dict) else None + + if action == "process": + task = msg.get("task", "") self.tasks_processed += 1 result = f"Processed: {task} (total: {self.tasks_processed})" print(f"[Worker {self.worker_id}] {result}") - return Message.from_json( - "TaskResult", {"result": result, "worker": self.worker_id} - ) - elif msg.msg_type == "GetStats": - return Message.from_json( - "Stats", {"worker_id": self.worker_id, "tasks": self.tasks_processed} - ) - return Message.empty() + return {"result": result, "worker": self.worker_id} + + if action == "stats": + return {"worker_id": self.worker_id, "tasks": self.tasks_processed} + return {"error": "unknown action"} -class DispatcherActor(Actor): + +class DispatcherActor: """A dispatcher actor that distributes tasks to workers (for demo purposes)""" def __init__(self): self.workers = [] self.tasks_dispatched = 0 - def on_start(self, actor_id: ActorId): + def on_start(self, actor_id): print("[Dispatcher] Started") - async def receive(self, msg: Message) -> Message: - if msg.msg_type == "RouteTask": + async def receive(self, msg): + action = msg.get("action") if isinstance(msg, dict) else None + + if action == "route": self.tasks_dispatched += 1 - task = msg.to_json().get("task", "") + task = msg.get("task", "") # Simulate routing logic worker_id = f"worker-{random.randint(1, 3)}" - return Message.from_json( - "Dispatched", - { - "task": task, - "worker": worker_id, - "dispatched": self.tasks_dispatched, - }, - ) - elif msg.msg_type == "GetStats": - return Message.from_json( - "Stats", {"dispatcher": True, "tasks_dispatched": self.tasks_dispatched} - ) - return Message.empty() + return { + "task": task, + "worker": worker_id, + "dispatched": self.tasks_dispatched, + } + if action == "stats": + return {"dispatcher": True, "tasks_dispatched": self.tasks_dispatched} -class CacheActor(Actor): + return {"error": "unknown action"} + + +class CacheActor: """A cache actor that stores key-value pairs""" def __init__(self): self.cache = {} - def on_start(self, actor_id: ActorId): + def on_start(self, actor_id): print("[Cache] Started") - async def receive(self, msg: Message) -> Message: - if msg.msg_type == "Get": - key = msg.to_json().get("key", "") + async def receive(self, msg): + action = msg.get("action") if isinstance(msg, dict) else None + + if action == "get": + key = msg.get("key", "") value = self.cache.get(key, None) - return Message.from_json( - "Value", {"key": key, "value": value, "found": value is not None} - ) - elif msg.msg_type == "Set": - data = msg.to_json() - key = data.get("key", "") - value = data.get("value", "") + return {"key": key, "value": value, "found": value is not None} + + if action == "set": + key = msg.get("key", "") + value = msg.get("value", "") self.cache[key] = value - return Message.from_json("SetResult", {"key": key, "success": True}) - elif msg.msg_type == "GetStats": - return Message.from_json("Stats", {"cache_size": len(self.cache)}) - return Message.empty() + return {"key": key, "success": True} + + if action == "stats": + return {"cache_size": len(self.cache)} + + return {"error": "unknown action"} async def run_node(port: int, seed: str | None): diff --git a/examples/python/message_patterns.py b/examples/python/message_patterns.py index 8a556a2aa..80c6fac16 100644 --- a/examples/python/message_patterns.py +++ b/examples/python/message_patterns.py @@ -8,10 +8,11 @@ import asyncio import pulsing as pul -from pulsing.actor import Actor, Message, StreamMessage -class PatternDemo(Actor): +class PatternDemo: + """Base Actor with various message patterns.""" + def __init__(self): self.value = 0 @@ -24,52 +25,81 @@ async def receive(self, msg): if msg.get("action") == "get": return {"value": self.value} - # Pattern 2: Streaming response (e.g., LLM token generation) + # Pattern 2: Streaming response - just return a generator! if msg == "stream": - stream_msg, writer = StreamMessage.create("tokens") - - async def produce(): - try: - for token in ["Hello", " ", "World", "!"]: - await writer.write({"token": token}) - await asyncio.sleep(0.1) - await writer.close() - except Exception: - pass # Stream closed, ignore - asyncio.create_task(produce()) - return stream_msg + async def generate(): + for token in ["Hello", " ", "World", "!"]: + yield {"token": token} + await asyncio.sleep(0.1) - # Pattern 3: JSON Message (for Rust actor compatibility) - if isinstance(msg, Message): - return Message.from_json("Echo", {"received": msg.msg_type}) + return generate() return f"unknown: {msg}" +@pul.remote +class RemotePatternDemo: + """@pul.remote Actor with cleaner API (recommended).""" + + def __init__(self): + self.value = 0 + + # Sync method - simple request/response + def add(self, n: int = 1) -> dict: + self.value += n + return {"value": self.value} + + def get(self) -> dict: + return {"value": self.value} + + # Async generator - automatic streaming + async def stream(self): + for token in ["Hello", " ", "World", "!"]: + yield {"token": token} + await asyncio.sleep(0.1) + + async def main(): system = await pul.actor_system() + + print("=" * 50) + print("Pattern 1: Base Actor with dict messages") + print("=" * 50) + actor = await system.spawn(PatternDemo(), name="demo") - # Pattern 1: Dict messages - print("--- Dict Messages ---") print(await actor.ask({"action": "add", "n": 10})) # {'value': 10} print(await actor.ask({"action": "add", "n": 5})) # {'value': 15} print(await actor.ask({"action": "get"})) # {'value': 15} - # Pattern 2: Streaming (transparent Python objects) - print("\n--- Streaming ---") + print("\n" + "=" * 50) + print("Pattern 2: Base Actor streaming (return generator)") + print("=" * 50) + response = await actor.ask("stream") async for chunk in response.stream_reader(): - print(chunk["token"], end="", flush=True) # Directly a Python dict + print(chunk["token"], end="") print() - # Pattern 3: JSON Message (backward compatible) - print("\n--- JSON Message ---") - resp = await actor.ask(Message.from_json("Test", {"data": 123})) - print(resp.to_json()) # {'received': 'Test'} + print("\n" + "=" * 50) + print("Pattern 3: @pul.remote (recommended)") + print("=" * 50) + + service = await RemotePatternDemo.local(system) + + # Direct method calls - no need for ask/tell! + print(await service.add(10)) # {'value': 10} + print(await service.add(5)) # {'value': 15} + print(await service.get()) # {'value': 15} + + print("\n--- Async generator streaming ---") + async for chunk in service.stream(): + print(chunk["token"], end="") + print() await system.shutdown() + print("\n✓ Done!") if __name__ == "__main__": diff --git a/examples/python/named_actors.py b/examples/python/named_actors.py index b1a9a61e4..ce9c23728 100644 --- a/examples/python/named_actors.py +++ b/examples/python/named_actors.py @@ -11,20 +11,19 @@ import asyncio import pulsing as pul -from pulsing.actor import Actor, ActorId, Message -class EchoActor(Actor): - def on_start(self, actor_id: ActorId): +class EchoActor: + """Simple echo actor that can be discovered by name.""" + + def on_start(self, actor_id): print(f"[{actor_id}] Started") - async def receive(self, msg: Message) -> Message: - message = msg.to_json().get("message", "") + async def receive(self, msg): + # Accept dict messages + message = msg.get("message", "") if isinstance(msg, dict) else str(msg) print(f"[Echo] {message}") - return Message.from_json( - "EchoResponse", - {"echo": message, "actor": msg.to_json().get("_actor_id", "unknown")}, - ) + return {"echo": message} async def main(): @@ -40,7 +39,7 @@ async def main(): # Resolve by name print("--- Resolve by name ---") actor = await system.resolve("echo") - resp = (await actor.ask(Message.from_json("Echo", {"message": "Hello!"}))).to_json() + resp = await actor.ask({"message": "Hello!"}) print(f"Response: {resp['echo']}\n") # List instances diff --git a/examples/quickstart/ai_chat_room.py b/examples/quickstart/ai_chat_room.py index 06afd523b..003f37f06 100644 --- a/examples/quickstart/ai_chat_room.py +++ b/examples/quickstart/ai_chat_room.py @@ -13,7 +13,7 @@ import argparse import asyncio import random -from pulsing.actor import remote, resolve +from pulsing.actor import remote from pulsing.agent import runtime # AI persona configuration @@ -57,45 +57,6 @@ } -@remote -class ChatAgent: - """AI agent in the chat room""" - - def __init__(self, agent_name: str, persona: str, topic: str): - self.agent_name = agent_name - self.persona = persona - self.topic = topic - self.config = AI_PERSONAS[persona] - self.history: list[str] = [] - - def receive_message(self, from_agent: str, message: str) -> str: - """Receive messages from other agents""" - self.history.append(f"{from_agent}: {message}") - return "Received" - - def generate_response(self) -> str: - """Generate response (mock mode)""" - phrase = random.choice(self.config["phrases"]) - - responses = [ - f"{phrase} Regarding '{self.topic}', I think this is a topic worth exploring in depth.", - f"{phrase} Speaking of this topic, I'd like to add my own perspective.", - f"{phrase} After hearing everyone's discussion, I have some new ideas.", - f"{phrase} This topic is very interesting, it reminds me of some things.", - ] - return random.choice(responses) - - async def speak(self, room_name: str) -> dict: - """Speak in the chat room""" - response = self.generate_response() - - # Notify the chat room - room = await resolve(room_name) - await room.broadcast(self.agent_name, response) - - return {"agent": self.agent_name, "message": response} - - @remote class ChatRoom: """Chat room - coordinates agent conversations""" @@ -133,6 +94,45 @@ def get_history(self) -> list[dict]: return self.messages +@remote +class ChatAgent: + """AI agent in the chat room""" + + def __init__(self, agent_name: str, persona: str, topic: str): + self.agent_name = agent_name + self.persona = persona + self.topic = topic + self.config = AI_PERSONAS[persona] + self.history: list[str] = [] + + def receive_message(self, from_agent: str, message: str) -> str: + """Receive messages from other agents""" + self.history.append(f"{from_agent}: {message}") + return "Received" + + def generate_response(self) -> str: + """Generate response (mock mode)""" + phrase = random.choice(self.config["phrases"]) + + responses = [ + f"{phrase} Regarding '{self.topic}', I think this is a topic worth exploring in depth.", + f"{phrase} Speaking of this topic, I'd like to add my own perspective.", + f"{phrase} After hearing everyone's discussion, I have some new ideas.", + f"{phrase} This topic is very interesting, it reminds me of some things.", + ] + return random.choice(responses) + + async def speak(self, room_name: str) -> dict: + """Speak in the chat room""" + response = self.generate_response() + + # Resolve chat room and broadcast message + room = await ChatRoom.resolve(room_name) + await room.broadcast(self.agent_name, response) + + return {"agent": self.agent_name, "message": response} + + async def main(topic: str, rounds: int): print("=" * 60) print("🗣️ AI Chat Room") diff --git a/examples/quickstart/chaos_proof.py b/examples/quickstart/chaos_proof.py index f6a90239e..8f62d9801 100644 --- a/examples/quickstart/chaos_proof.py +++ b/examples/quickstart/chaos_proof.py @@ -7,7 +7,7 @@ from pulsing.agent import runtime -@remote(restart_policy="on-failure", max_restarts=50) +@remote(restart_policy="on_failure", max_restarts=50) class FlakyWorker: def __init__(self): self.call_count = 0 diff --git a/examples/quickstart/hello_agent.py b/examples/quickstart/hello_agent.py index 02e0dfe3f..e3e1098ab 100644 --- a/examples/quickstart/hello_agent.py +++ b/examples/quickstart/hello_agent.py @@ -7,7 +7,7 @@ """ import asyncio -from pulsing.actor import remote, resolve +from pulsing.actor import remote from pulsing.agent import runtime @@ -26,7 +26,8 @@ def greet(self, message: str) -> str: async def say_hello_to(self, peer_name: str) -> str: """Greet another agent""" - peer = await resolve(peer_name) + # Use Class.resolve() to get ActorProxy (with method type info) + peer = await Greeter.resolve(peer_name) print(f"👋 [{self.display_name}] is greeting [{peer_name}]...") reply = await peer.greet(f"Hi, I'm {self.display_name}!") print(f"💬 [{self.display_name}] received reply: {reply}") From 28783351f2d209c0fc7311587b36275ba2738fb5 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 10:38:08 +0800 Subject: [PATCH 12/24] Update documentation and examples to reflect changes in actor resolution and API usage - Removed references to `resolve()` in favor of `ClassName.resolve()` for clarity and consistency across documentation. - Updated example scripts to demonstrate the new actor resolution method, ensuring users can easily find and interact with existing actors. - Enhanced README and guide sections to provide clearer instructions on using the `@remote` decorator and actor spawning. - Adjusted code snippets to reflect the latest API changes, improving overall usability and understanding of the Pulsing framework. --- README.md | 9 +- README.zh.md | 9 +- docs/overrides/home.html | 8 +- docs/src/agent/index.md | 1 - docs/src/agent/index.zh.md | 1 - docs/src/agent/native.md | 26 +- docs/src/agent/native.zh.md | 26 +- docs/src/api_reference.md | 361 ++++++++------------- docs/src/api_reference.zh.md | 361 ++++++++------------- docs/src/design/as-actor-decorator.md | 6 +- docs/src/design/as-actor-decorator.zh.md | 6 +- docs/src/examples/index.md | 16 +- docs/src/examples/index.zh.md | 16 +- docs/src/guide/actors.md | 61 ++-- docs/src/guide/actors.zh.md | 61 ++-- docs/src/guide/reliability.md | 6 +- docs/src/guide/reliability.zh.md | 6 +- docs/src/guide/remote_actors.md | 79 +++-- docs/src/guide/remote_actors.zh.md | 81 +++-- docs/src/guide/security.md | 62 ++-- docs/src/guide/security.zh.md | 64 ++-- docs/src/index.md | 8 +- docs/src/index.zh.md | 8 +- docs/src/quickstart/index.md | 10 +- docs/src/quickstart/index.zh.md | 10 +- docs/src/quickstart/migrate_from_ray.md | 8 +- docs/src/quickstart/migrate_from_ray.zh.md | 8 +- examples/agent/pulsing/README.md | 28 +- examples/python/README.md | 12 +- examples/quickstart/README.md | 14 +- 30 files changed, 611 insertions(+), 761 deletions(-) diff --git a/README.md b/README.md index 12367a7cb..3dc5d6637 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ pip install pulsing ```python import asyncio -from pulsing.actor import remote, resolve +import pulsing as pul from pulsing.agent import runtime -@remote +@pul.remote class Greeter: def __init__(self, display_name: str): self.display_name = display_name @@ -43,7 +43,8 @@ class Greeter: return f"[{self.display_name}] Received: {message}" async def chat_with(self, peer_name: str, message: str) -> str: - peer = await resolve(peer_name) + # Use Greeter.resolve() to get a typed proxy + peer = await Greeter.resolve(peer_name) return await peer.greet(f"From {self.display_name}: {message}") async def main(): @@ -59,7 +60,7 @@ async def main(): asyncio.run(main()) ``` -**That's it!** `@remote` turns a regular class into a distributed Actor, and `resolve()` enables agents to discover and communicate with each other. +**That's it!** `@pul.remote` turns a regular class into a distributed Actor, and `Greeter.resolve()` enables agents to discover and communicate with each other. ## 💡 I want to... diff --git a/README.zh.md b/README.zh.md index 634427498..0155f1f98 100644 --- a/README.zh.md +++ b/README.zh.md @@ -31,10 +31,10 @@ pip install pulsing ```python import asyncio -from pulsing.actor import remote, resolve +import pulsing as pul from pulsing.agent import runtime -@remote +@pul.remote class Greeter: def __init__(self, display_name: str): self.display_name = display_name @@ -43,7 +43,8 @@ class Greeter: return f"[{self.display_name}] 收到: {message}" async def chat_with(self, peer_name: str, message: str) -> str: - peer = await resolve(peer_name) + # 使用 Greeter.resolve() 获取有类型的代理 + peer = await Greeter.resolve(peer_name) return await peer.greet(f"来自 {self.display_name}: {message}") async def main(): @@ -59,7 +60,7 @@ async def main(): asyncio.run(main()) ``` -**就这么简单!** `@remote` 让普通类变成可分布式部署的 Actor,`resolve()` 让 Agent 互相发现和通信。 +**就这么简单!** `@pul.remote` 让普通类变成可分布式部署的 Actor,`Greeter.resolve()` 让 Agent 互相发现和通信。 ## 💡 我想做... diff --git a/docs/overrides/home.html b/docs/overrides/home.html index bf5d6d3fe..65beacd5c 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -607,9 +607,9 @@

{% if config.theme.language == "zh" %}安装 Pulsing{% else %}Install Pulsin

{% if config.theme.language == "zh" %}创建你的第一个 Actor{% else %}Create Your First Actor{% endif %}

-
from pulsing.actor import init, shutdown, remote
+              
import pulsing as pul
 
-@remote
+@pul.remote
 class Calculator:
     def __init__(self, initial: int = 0):
         self.value = initial
@@ -631,7 +631,7 @@ 

{% if config.theme.language == "zh" %}运行它{% else %}Run It{% endif %}import asyncio async def main(): - await init() + await pul.init() calc = await Calculator.spawn(initial=100) @@ -640,7 +640,7 @@

{% if config.theme.language == "zh" %}运行它{% else %}Run It{% endif %}

diff --git a/docs/src/agent/index.md b/docs/src/agent/index.md index 2cff8dcc3..70384f545 100644 --- a/docs/src/agent/index.md +++ b/docs/src/agent/index.md @@ -23,7 +23,6 @@ Pulsing provides native support for popular agent frameworks, enabling seamless For building multi-agent applications from scratch, use Pulsing's native `@agent` decorator: ```python -from pulsing.actor import resolve from pulsing.agent import agent, runtime, llm, list_agents @agent(role="Researcher", goal="Deep analysis") diff --git a/docs/src/agent/index.zh.md b/docs/src/agent/index.zh.md index 79382dffe..8f5fb27c7 100644 --- a/docs/src/agent/index.zh.md +++ b/docs/src/agent/index.zh.md @@ -23,7 +23,6 @@ Pulsing 原生支持主流 Agent 框架,让您的应用轻松从单进程扩 从零构建多智能体应用时,使用 Pulsing 原生的 `@agent` 装饰器: ```python -from pulsing.actor import resolve from pulsing.agent import agent, runtime, llm, list_agents @agent(role="研究员", goal="深入分析") diff --git a/docs/src/agent/native.md b/docs/src/agent/native.md index d79de9b32..c37cb9c8b 100644 --- a/docs/src/agent/native.md +++ b/docs/src/agent/native.md @@ -17,11 +17,11 @@ Pulsing provides a lightweight native agent toolkit for building multi-agent app The `@agent` decorator is equivalent to `@remote`, but attaches metadata for visualization and debugging: ```python -from pulsing.actor import remote, resolve +import pulsing as pul from pulsing.agent import agent, runtime, llm, get_agent_meta, list_agents -# @remote: Basic Actor -@remote +# @pul.remote: Basic Actor +@pul.remote class Worker: async def work(self): return "done" @@ -52,10 +52,10 @@ async with runtime(): print(f"{name}: {meta.role}") ``` -### `@remote` vs `@agent` +### `@pul.remote` vs `@agent` -| Feature | `@remote` | `@agent` | -|---------|-----------|----------| +| Feature | `@pul.remote` | `@agent` | +|---------|---------------|----------| | Function | Actor wrapper | Actor wrapper + metadata | | Use case | General purpose | Visualization / debugging | | Metadata | None | `role`, `goal`, `backstory`, `tags` | @@ -84,7 +84,7 @@ async with runtime(addr="0.0.0.0:8001"): # Node B (auto-discovers Node A) async with runtime(addr="0.0.0.0:8002", seeds=["node_a:8001"]): - judge = await resolve("judge") # Cross-node transparent call + judge = await JudgeActor.resolve("judge") # Cross-node transparent call await judge.submit(idea) ``` @@ -124,12 +124,12 @@ value = extract_field(response, "answer", default="unknown") ```python import asyncio -from pulsing.actor import remote, resolve -from pulsing.agent import agent, runtime, llm, parse_json, get_agent_meta +import pulsing as pul +from pulsing.agent import agent, runtime, llm, parse_json, get_agent_meta, list_agents -@remote +@pul.remote class Moderator: - """Coordinator using @remote (basic Actor)""" + """Coordinator using @pul.remote (basic Actor)""" def __init__(self, topic: str): self.topic = topic @@ -160,7 +160,7 @@ class Analyst: opinion = resp.content # Submit to moderator - moderator = await resolve(self.moderator_name) + moderator = await Moderator.resolve(self.moderator_name) await moderator.collect_opinion(self.name, opinion) return opinion @@ -186,7 +186,7 @@ async def main(): # Run analysis for i in range(3): - analyst = await resolve(f"analyst_{i}") + analyst = await Analyst.resolve(f"analyst_{i}") await analyst.analyze("AI Trends") # Get summary diff --git a/docs/src/agent/native.zh.md b/docs/src/agent/native.zh.md index b301d29cc..1f7ecee77 100644 --- a/docs/src/agent/native.zh.md +++ b/docs/src/agent/native.zh.md @@ -14,14 +14,14 @@ Pulsing 提供轻量的原生 Agent 工具箱,用于构建多智能体应用 ### `@agent` 装饰器 -`@agent` 装饰器等同于 `@remote`,但附加元信息用于可视化和调试: +`@agent` 装饰器等同于 `@pul.remote`,但附加元信息用于可视化和调试: ```python -from pulsing.actor import remote, resolve +import pulsing as pul from pulsing.agent import agent, runtime, llm, get_agent_meta, list_agents -# @remote: 基础 Actor -@remote +# @pul.remote: 基础 Actor +@pul.remote class Worker: async def work(self): return "done" @@ -52,10 +52,10 @@ async with runtime(): print(f"{name}: {meta.role}") ``` -### `@remote` vs `@agent` +### `@pul.remote` vs `@agent` -| 特性 | `@remote` | `@agent` | -|------|-----------|----------| +| 特性 | `@pul.remote` | `@agent` | +|------|---------------|----------| | 功能 | Actor 化 | Actor 化 + 元信息 | | 用途 | 通用 | 可视化/调试 | | 元信息 | 无 | `role`, `goal`, `backstory`, `tags` | @@ -84,7 +84,7 @@ async with runtime(addr="0.0.0.0:8001"): # 节点 B(自动发现节点 A) async with runtime(addr="0.0.0.0:8002", seeds=["node_a:8001"]): - judge = await resolve("judge") # 跨节点透明调用 + judge = await JudgeActor.resolve("judge") # 跨节点透明调用 await judge.submit(idea) ``` @@ -124,12 +124,12 @@ value = extract_field(response, "answer", default="unknown") ```python import asyncio -from pulsing.actor import remote, resolve +import pulsing as pul from pulsing.agent import agent, runtime, llm, parse_json, list_agents -@remote +@pul.remote class Moderator: - """使用 @remote 的协调者(基础 Actor)""" + """使用 @pul.remote 的协调者(基础 Actor)""" def __init__(self, topic: str): self.topic = topic @@ -160,7 +160,7 @@ class Analyst: opinion = resp.content # 提交给协调者 - moderator = await resolve(self.moderator_name) + moderator = await Moderator.resolve(self.moderator_name) await moderator.collect_opinion(self.name, opinion) return opinion @@ -186,7 +186,7 @@ async def main(): # 运行分析 for i in range(3): - analyst = await resolve(f"analyst_{i}") + analyst = await Analyst.resolve(f"analyst_{i}") await analyst.analyze("AI 趋势") # 获取总结 diff --git a/docs/src/api_reference.md b/docs/src/api_reference.md index d7de15833..de41cdffb 100644 --- a/docs/src/api_reference.md +++ b/docs/src/api_reference.md @@ -2,150 +2,59 @@ Complete API documentation for Pulsing Actor Framework. -## Core Classes +## Core Functions -### Actor +### pul.actor_system -Base class for all actors. +Create a new Actor System instance. ```python -class Actor: - async def receive(self, msg: Message) -> Message: - """Handle incoming messages.""" - pass -``` - -### Message - -Message wrapper for actor communication. - -```python -class Message: - @property - def msg_type(self) -> str: - """Get the message type.""" - pass - - @property - def payload(self) -> bytes: - """Get the raw payload bytes.""" - pass - - @property - def is_stream(self) -> bool: - """Check if this is a streaming message.""" - pass - - @staticmethod - def single(msg_type: str, payload: bytes) -> Message: - """Create a single message with raw bytes.""" - pass - - def to_json(self) -> Any: - """Deserialize payload as JSON.""" - pass - - def to_object(self) -> Any: - """Deserialize payload as Python object (pickle).""" - pass - - def stream_reader(self) -> StreamReader: - """Get stream reader for streaming messages.""" - pass -``` - -### StreamMessage - -Factory for creating streaming responses. +import pulsing as pul -```python -class StreamMessage: - @staticmethod - def create( - msg_type: str = "", - buffer_size: int = 32 - ) -> tuple[Message, StreamWriter]: - """ - Create a streaming message and its writer. - - Args: - msg_type: Default message type for stream chunks - buffer_size: Bounded channel buffer size (backpressure) - - Returns: - tuple of (Message, StreamWriter) - """ - pass +system = await pul.actor_system( + addr: str | None = None, # Bind address, None for standalone + *, + seeds: list[str] | None = None, # Seed nodes for cluster + passphrase: str | None = None, # TLS passphrase +) -> ActorSystem ``` -### StreamWriter - -Writer for streaming responses. Supports automatic Python object serialization. +**Example:** ```python -class StreamWriter: - async def write(self, obj: Any) -> None: - """ - Write a Python object to the stream. - - The object is automatically serialized using pickle, - making Python-to-Python streaming transparent. +# Standalone mode +system = await pul.actor_system() - Args: - obj: Any picklable Python object (dict, list, str, etc.) - """ - pass +# Cluster mode +system = await pul.actor_system(addr="0.0.0.0:8000") - async def close(self) -> None: - """Close the stream normally.""" - pass +# Join existing cluster +system = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) - async def error(self, message: str) -> None: - """Close the stream with an error.""" - pass +# Shutdown +await system.shutdown() ``` -### StreamReader +### pul.init / pul.shutdown -Reader for streaming responses. Automatically deserializes Python objects. +Global system initialization (Ray-style async API). ```python -class StreamReader: - async def __anext__(self) -> Any: - """ - Get the next item from the stream. - - Returns Python objects directly (automatically unpickled). - Raises StopAsyncIteration when stream ends. - """ - pass - - def __aiter__(self) -> StreamReader: - """Return self as async iterator.""" - pass -``` - -### SystemConfig - -Configuration for Actor System. +import pulsing as pul -```python -class SystemConfig: - @staticmethod - def standalone() -> SystemConfig: - """Create standalone (non-cluster) configuration.""" - pass +# Initialize global system +await pul.init(addr=None, seeds=None, passphrase=None) - @staticmethod - def with_addr(addr: str) -> SystemConfig: - """Create configuration with address.""" - pass +# Use global system +actor = await pul.spawn(MyActor()) +ref = await pul.resolve("actor_name") - def with_seeds(self, seeds: List[str]) -> SystemConfig: - """Add seed nodes for cluster discovery.""" - pass +# Shutdown +await pul.shutdown() ``` +## Core Classes + ### ActorSystem Main entry point for the actor system. @@ -155,18 +64,23 @@ class ActorSystem: async def spawn( self, actor: Actor, - name: str, - public: bool = False + *, + name: str | None = None, + public: bool = False, + restart_policy: str = "never", + max_restarts: int = 3, + min_backoff: float = 0.1, + max_backoff: float = 30.0 ) -> ActorRef: """Spawn a new actor.""" pass - async def find(self, name: str) -> Optional[ActorRef]: - """Find an actor by name in the cluster.""" + async def refer(self, actorid: ActorId | str) -> ActorRef: + """Get ActorRef by ActorId.""" pass - async def has_actor(self, name: str) -> bool: - """Check if an actor exists.""" + async def resolve(self, name: str, *, node_id: int | None = None) -> ActorRef: + """Resolve actor by name.""" pass async def shutdown(self) -> None: @@ -176,157 +90,164 @@ class ActorSystem: ### ActorRef -Low-level reference to an actor (local or remote). Usually not used directly; prefer `ActorProxy`. +Low-level reference to an actor. Use `ask()` and `tell()` to communicate. ```python class ActorRef: - async def ask(self, msg: Message) -> Message: - """Send a message and wait for response.""" + @property + def actor_id(self) -> ActorId: + """Get the actor's ID.""" pass - async def tell(self, msg: Message) -> None: - """Send a message without waiting for response.""" + async def ask(self, msg: Any) -> Any: + """Send a message and wait for response.""" pass - async def ask_stream(self, msg: Message) -> AsyncIterator[Message]: - """Send a streaming message.""" + async def tell(self, msg: Any) -> None: + """Send a message without waiting for response (fire-and-forget).""" pass ``` ### ActorProxy -High-level proxy wrapper for actors, returned by `@remote` decorator's `spawn()` and `resolve()`. -**Recommended: use ActorProxy to call methods directly**, no need to manually construct `Message`. +High-level proxy for `@remote` classes. Call methods directly. ```python class ActorProxy: @property def ref(self) -> ActorRef: - """Get underlying ActorRef (for low-level ask/tell)""" + """Get underlying ActorRef.""" pass - # Call actor methods directly, e.g.: + # Call methods directly: # result = await proxy.my_method(arg1, arg2) ``` -**ActorProxy vs ActorRef comparison**: - -| Scenario | Recommendation | -|----------|----------------| -| Call `@remote` class methods | `ActorProxy`: `await proxy.method()` | -| Need low-level ask/tell | `ActorRef`: `await proxy.ref.ask(msg)` | -| Need actor_id | `ActorRef`: `proxy.ref.actor_id` | - ## Decorators -### @remote +### @remote / @pul.remote -Convert a class into an Actor automatically. +Convert a class into a distributed Actor. ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote -class MyActor: - def __init__(self, value: int): - self.value = value +@pul.remote +class Counter: + def __init__(self, init_value: int = 0): + self.value = init_value - def get(self) -> int: + # Sync method - sequential execution + def incr(self) -> int: + self.value += 1 return self.value - async def process(self, data: str) -> dict: - return {"result": data.upper()} - -async def main(): - await init() - actor = await MyActor.spawn(value=10) - print(await actor.get()) # 10 - await shutdown() -``` - -#### Supervision (actor-level restarts) + # Async method - concurrent execution during await + async def fetch_and_add(self, url: str) -> int: + data = await http_get(url) + self.value += data + return self.value -`@remote` supports **actor-level restarts** via optional parameters: + # Generator - automatic streaming + async def stream(self): + for i in range(10): + yield {"count": i} -- `restart_policy`: `"never"` (default), `"always"`, `"on-failure"` -- `max_restarts`: maximum number of restarts (default: `3`) -- `min_backoff` / `max_backoff`: backoff bounds in seconds +# Create actor +counter = await Counter.spawn(name="counter") -Example: +# Call methods directly +result = await counter.incr() -```python -from pulsing.actor import remote +# Streaming +async for chunk in counter.stream(): + print(chunk) -@remote(restart_policy="on-failure", max_restarts=5, min_backoff=0.2, max_backoff=10.0) -class Worker: - def work(self, x: int) -> int: - return 100 // x +# Resolve existing actor +proxy = await Counter.resolve("counter") ``` -Notes: +**Supervision parameters:** -- This is **not** a supervision tree. -- Restarts do **not** imply exactly-once semantics; design idempotent handlers. - -## Helpers +```python +@pul.remote( + restart_policy="on_failure", # "never" | "on_failure" | "always" + max_restarts=3, + min_backoff=0.1, + max_backoff=30.0, +) +class ResilientWorker: + def work(self, data): ... +``` -### ask_with_timeout +## Base Actor -Convenience wrapper around `ActorRef.ask()` with timeout support: +For low-level control, inherit from Actor base class. ```python -from pulsing.actor import ask_with_timeout - -result = await ask_with_timeout(ref, {"op": "compute"}, timeout=10.0) -``` - +class MyActor: + def __init__(self): + self.value = 0 -After decoration, the class provides: + def on_start(self, actor_id): + """Called when actor starts.""" + print(f"Started: {actor_id}") -- `spawn(**kwargs) -> ActorProxy`: Create actor and return proxy (uses global system from `init()`) -- `local(system, **kwargs) -> ActorProxy`: Create actor on specified system -- `resolve(name) -> ActorProxy`: Resolve an existing actor by name + async def receive(self, msg): + """Handle incoming messages.""" + if msg.get("action") == "add": + self.value += msg.get("n", 1) + return {"value": self.value} + return {"error": "unknown action"} -**Recommended**: Use the returned `ActorProxy` to call methods directly; use `proxy.ref` for low-level `ask/tell` +# Spawn +system = await pul.actor_system() +actor = await system.spawn(MyActor(), name="my_actor") -## Functions +# Communicate via ask/tell +response = await actor.ask({"action": "add", "n": 10}) +``` -### pul.actor_system (Recommended) +## Queue API -Create a new Actor System instance with simple parameters. +Distributed queue for data pipelines. ```python -import pulsing as pul - -system = await pul.actor_system( - addr: str | None = None, # Bind address, None for standalone - *, - seeds: list[str] | None = None, # Seed nodes for cluster - passphrase: str | None = None, # TLS passphrase -) -> ActorSystem +# Write +writer = await system.queue.write( + topic="my_queue", + bucket_column="user_id", + num_buckets=4, +) +await writer.put({"user_id": "u1", "data": "hello"}) +await writer.flush() + +# Read +reader = await system.queue.read("my_queue") +records = await reader.get(limit=100) ``` -**Example:** +## Ray Compatibility -```python -# Standalone mode -system = await pul.actor_system() +Drop-in replacement for Ray. -# Cluster mode -system = await pul.actor_system(addr="0.0.0.0:8000") +```python +from pulsing.compat import ray -# Join existing cluster -system = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) -``` +ray.init() -### create_actor_system (Low-level) +@ray.remote +class Counter: + def __init__(self): + self.value = 0 + def incr(self): + self.value += 1 + return self.value -Create a new Actor System instance with SystemConfig. +counter = Counter.remote() +result = ray.get(counter.incr.remote()) -```python -async def create_actor_system(config: SystemConfig) -> ActorSystem: - """Create and start an actor system.""" - pass +ray.shutdown() ``` ## Examples diff --git a/docs/src/api_reference.zh.md b/docs/src/api_reference.zh.md index 110ca84f8..0d009d210 100644 --- a/docs/src/api_reference.zh.md +++ b/docs/src/api_reference.zh.md @@ -2,150 +2,59 @@ Pulsing Actor 框架的完整 API 文档。 -## 核心类 +## 核心函数 -### Actor +### pul.actor_system -所有 actor 的基类。 +创建新的 Actor System 实例。 ```python -class Actor: - async def receive(self, msg: Message) -> Message: - """处理传入消息。""" - pass -``` - -### Message - -Actor 通信的消息包装器。 - -```python -class Message: - @property - def msg_type(self) -> str: - """获取消息类型。""" - pass - - @property - def payload(self) -> bytes: - """获取原始负载字节。""" - pass - - @property - def is_stream(self) -> bool: - """检查是否为流式消息。""" - pass - - @staticmethod - def single(msg_type: str, payload: bytes) -> Message: - """创建带原始字节的单条消息。""" - pass - - def to_json(self) -> Any: - """将负载反序列化为 JSON。""" - pass - - def to_object(self) -> Any: - """将负载反序列化为 Python 对象(pickle)。""" - pass - - def stream_reader(self) -> StreamReader: - """获取流式消息的 StreamReader。""" - pass -``` - -### StreamMessage - -创建流式响应的工厂类。 +import pulsing as pul -```python -class StreamMessage: - @staticmethod - def create( - msg_type: str = "", - buffer_size: int = 32 - ) -> tuple[Message, StreamWriter]: - """ - 创建流式消息及其写入器。 - - 参数: - msg_type: 流块的默认消息类型 - buffer_size: 有界通道缓冲区大小(背压控制) - - 返回: - (Message, StreamWriter) 元组 - """ - pass +system = await pul.actor_system( + addr: str | None = None, # 绑定地址,None 为单机模式 + *, + seeds: list[str] | None = None, # 集群种子节点 + passphrase: str | None = None, # TLS 密码短语 +) -> ActorSystem ``` -### StreamWriter - -流式响应的写入器。支持自动 Python 对象序列化。 +**示例:** ```python -class StreamWriter: - async def write(self, obj: Any) -> None: - """ - 将 Python 对象写入流。 - - 对象会自动使用 pickle 序列化, - 使 Python 到 Python 的流式传输完全透明。 +# 单机模式 +system = await pul.actor_system() - 参数: - obj: 任何可 pickle 的 Python 对象(dict、list、str 等) - """ - pass +# 集群模式 +system = await pul.actor_system(addr="0.0.0.0:8000") - async def close(self) -> None: - """正常关闭流。""" - pass +# 加入现有集群 +system = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) - async def error(self, message: str) -> None: - """带错误关闭流。""" - pass +# 关闭 +await system.shutdown() ``` -### StreamReader +### pul.init / pul.shutdown -流式响应的读取器。自动反序列化 Python 对象。 +全局系统初始化(Ray 风格异步 API)。 ```python -class StreamReader: - async def __anext__(self) -> Any: - """ - 从流中获取下一个元素。 - - 直接返回 Python 对象(自动反序列化)。 - 流结束时抛出 StopAsyncIteration。 - """ - pass - - def __aiter__(self) -> StreamReader: - """返回自身作为异步迭代器。""" - pass -``` - -### SystemConfig - -Actor System 的配置。 +import pulsing as pul -```python -class SystemConfig: - @staticmethod - def standalone() -> SystemConfig: - """创建单机(非集群)配置。""" - pass +# 初始化全局系统 +await pul.init(addr=None, seeds=None, passphrase=None) - @staticmethod - def with_addr(addr: str) -> SystemConfig: - """创建带地址的配置。""" - pass +# 使用全局系统 +actor = await pul.spawn(MyActor()) +ref = await pul.resolve("actor_name") - def with_seeds(self, seeds: List[str]) -> SystemConfig: - """添加用于集群发现的种子节点。""" - pass +# 关闭 +await pul.shutdown() ``` +## 核心类 + ### ActorSystem Actor 系统的主入口点。 @@ -155,18 +64,23 @@ class ActorSystem: async def spawn( self, actor: Actor, - name: str, - public: bool = False + *, + name: str | None = None, + public: bool = False, + restart_policy: str = "never", + max_restarts: int = 3, + min_backoff: float = 0.1, + max_backoff: float = 30.0 ) -> ActorRef: """生成新的 actor。""" pass - async def find(self, name: str) -> Optional[ActorRef]: - """在集群中按名称查找 actor。""" + async def refer(self, actorid: ActorId | str) -> ActorRef: + """通过 ActorId 获取 ActorRef。""" pass - async def has_actor(self, name: str) -> bool: - """检查 actor 是否存在。""" + async def resolve(self, name: str, *, node_id: int | None = None) -> ActorRef: + """通过名称解析 actor。""" pass async def shutdown(self) -> None: @@ -176,157 +90,164 @@ class ActorSystem: ### ActorRef -Actor 的底层引用(本地或远程)。通常不需要直接使用,推荐使用 `ActorProxy`。 +Actor 的底层引用。使用 `ask()` 和 `tell()` 进行通信。 ```python class ActorRef: - async def ask(self, msg: Message) -> Message: - """发送消息并等待响应。""" + @property + def actor_id(self) -> ActorId: + """获取 actor 的 ID。""" pass - async def tell(self, msg: Message) -> None: - """发送消息但不等待响应。""" + async def ask(self, msg: Any) -> Any: + """发送消息并等待响应。""" pass - async def ask_stream(self, msg: Message) -> AsyncIterator[Message]: - """发送流式消息。""" + async def tell(self, msg: Any) -> None: + """发送消息但不等待响应(fire-and-forget)。""" pass ``` ### ActorProxy -对 Actor 的高级代理封装,由 `@remote` 装饰器的 `spawn()` 和 `resolve()` 返回。 -**推荐直接使用 ActorProxy 调用方法**,无需手动构造 `Message`。 +`@remote` 类的高级代理。可直接调用方法。 ```python class ActorProxy: @property def ref(self) -> ActorRef: - """获取底层 ActorRef(需要低级 ask/tell 时使用)""" + """获取底层 ActorRef。""" pass - # 可直接调用 actor 上的方法,例如: + # 直接调用方法: # result = await proxy.my_method(arg1, arg2) ``` -**ActorProxy vs ActorRef 对比**: - -| 场景 | 推荐 | -|------|------| -| 调用 `@remote` 类的方法 | `ActorProxy`:`await proxy.method()` | -| 需要底层 ask/tell | `ActorRef`:`await proxy.ref.ask(msg)` | -| 需要 actor_id | `ActorRef`:`proxy.ref.actor_id` | - ## 装饰器 -### @remote +### @remote / @pul.remote -自动将类转换为 Actor。 +将类转换为分布式 Actor。 ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote -class MyActor: - def __init__(self, value: int): - self.value = value +@pul.remote +class Counter: + def __init__(self, init_value: int = 0): + self.value = init_value - def get(self) -> int: + # 同步方法 - 顺序执行 + def incr(self) -> int: + self.value += 1 return self.value - async def process(self, data: str) -> dict: - return {"result": data.upper()} - -async def main(): - await init() - actor = await MyActor.spawn(value=10) - print(await actor.get()) # 10 - await shutdown() -``` - -#### 监督(actor 级别重启) + # 异步方法 - await 期间可并发执行 + async def fetch_and_add(self, url: str) -> int: + data = await http_get(url) + self.value += data + return self.value -`@remote` 支持通过可选参数配置 **actor 级别重启**: + # Generator - 自动流式传输 + async def stream(self): + for i in range(10): + yield {"count": i} -- `restart_policy`:`"never"`(默认)、`"always"`、`"on-failure"` -- `max_restarts`:最大重启次数(默认 `3`) -- `min_backoff` / `max_backoff`:退避时间下限/上限(单位秒) +# 创建 actor +counter = await Counter.spawn(name="counter") -示例: +# 直接调用方法 +result = await counter.incr() -```python -from pulsing.actor import remote +# 流式传输 +async for chunk in counter.stream(): + print(chunk) -@remote(restart_policy="on-failure", max_restarts=5, min_backoff=0.2, max_backoff=10.0) -class Worker: - def work(self, x: int) -> int: - return 100 // x +# 解析已有 actor +proxy = await Counter.resolve("counter") ``` -说明: +**监督参数:** -- 这**不是** supervision tree。 -- 重启也**不等于** exactly-once;业务逻辑需要幂等与去重。 - -## 辅助函数 +```python +@pul.remote( + restart_policy="on_failure", # "never" | "on_failure" | "always" + max_restarts=3, + min_backoff=0.1, + max_backoff=30.0, +) +class ResilientWorker: + def work(self, data): ... +``` -### ask_with_timeout +## 基础 Actor -为 `ActorRef.ask()` 提供一个带超时的便捷封装: +需要底层控制时,可使用基础 Actor 类。 ```python -from pulsing.actor import ask_with_timeout - -result = await ask_with_timeout(ref, {"op": "compute"}, timeout=10.0) -``` - +class MyActor: + def __init__(self): + self.value = 0 -装饰后,类提供: + def on_start(self, actor_id): + """Actor 启动时调用。""" + print(f"Started: {actor_id}") -- `spawn(**kwargs) -> ActorProxy`: 创建 actor 并返回代理(使用 `init()` 初始化的全局系统) -- `local(system, **kwargs) -> ActorProxy`: 在指定 system 上创建 actor -- `resolve(name) -> ActorProxy`: 按名称解析已存在的 actor + async def receive(self, msg): + """处理传入消息。""" + if msg.get("action") == "add": + self.value += msg.get("n", 1) + return {"value": self.value} + return {"error": "unknown action"} -**推荐**:直接使用返回的 `ActorProxy` 调用方法;如需底层 `ask/tell`,使用 `proxy.ref` +# 生成 +system = await pul.actor_system() +actor = await system.spawn(MyActor(), name="my_actor") -## 函数 +# 通过 ask/tell 通信 +response = await actor.ask({"action": "add", "n": 10}) +``` -### pul.actor_system(推荐) +## 队列 API -使用简单参数创建新的 Actor System 实例。 +用于数据管道的分布式队列。 ```python -import pulsing as pul - -system = await pul.actor_system( - addr: str | None = None, # 绑定地址,None 为单机模式 - *, - seeds: list[str] | None = None, # 集群种子节点 - passphrase: str | None = None, # TLS 密码短语 -) -> ActorSystem +# 写入 +writer = await system.queue.write( + topic="my_queue", + bucket_column="user_id", + num_buckets=4, +) +await writer.put({"user_id": "u1", "data": "hello"}) +await writer.flush() + +# 读取 +reader = await system.queue.read("my_queue") +records = await reader.get(limit=100) ``` -**示例:** +## Ray 兼容 -```python -# 单机模式 -system = await pul.actor_system() +Ray 的直接替换。 -# 集群模式 -system = await pul.actor_system(addr="0.0.0.0:8000") +```python +from pulsing.compat import ray -# 加入现有集群 -system = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) -``` +ray.init() -### create_actor_system(底层) +@ray.remote +class Counter: + def __init__(self): + self.value = 0 + def incr(self): + self.value += 1 + return self.value -使用 SystemConfig 创建新的 Actor System 实例。 +counter = Counter.remote() +result = ray.get(counter.incr.remote()) -```python -async def create_actor_system(config: SystemConfig) -> ActorSystem: - """创建并启动 actor 系统。""" - pass +ray.shutdown() ``` ## 示例 diff --git a/docs/src/design/as-actor-decorator.md b/docs/src/design/as-actor-decorator.md index 4911c0a2f..35fed3a10 100644 --- a/docs/src/design/as-actor-decorator.md +++ b/docs/src/design/as-actor-decorator.md @@ -103,9 +103,9 @@ class _WrappedActor: ### 基本用法 ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self, init_value=0): self.value = init_value @@ -118,7 +118,7 @@ class Counter: return self.value async def main(): - await init() + await pul.init() # 创建 Actor counter = await Counter.spawn(init_value=10) diff --git a/docs/src/design/as-actor-decorator.zh.md b/docs/src/design/as-actor-decorator.zh.md index dbd9cba87..7efbe0ed2 100644 --- a/docs/src/design/as-actor-decorator.zh.md +++ b/docs/src/design/as-actor-decorator.zh.md @@ -103,9 +103,9 @@ class _WrappedActor: ### 基本用法 ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self, init_value=0): self.value = init_value @@ -118,7 +118,7 @@ class Counter: return self.value async def main(): - await init() + await pul.init() # 创建 Actor counter = await Counter.spawn(init_value=10) diff --git a/docs/src/examples/index.md b/docs/src/examples/index.md index ce57f0791..987170b81 100644 --- a/docs/src/examples/index.md +++ b/docs/src/examples/index.md @@ -10,18 +10,18 @@ The simplest possible Pulsing application: ```python import asyncio -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class HelloActor: def greet(self, name: str) -> str: return f"Hello, {name}!" async def main(): - await init() + await pul.init() hello = await HelloActor.spawn() print(await hello.greet("World")) - await shutdown() + await pul.shutdown() asyncio.run(main()) ``` @@ -31,7 +31,7 @@ asyncio.run(main()) A stateful actor that maintains a counter: ```python -@remote +@pul.remote class Counter: def __init__(self, initial: int = 0): self.value = initial @@ -59,9 +59,9 @@ class Counter: Two actors communicating across nodes: ```python -from pulsing.actor import init, shutdown, remote, get_system +import pulsing as pul -@remote +@pul.remote class PingActor: def __init__(self, pong_ref=None): self.pong = pong_ref @@ -74,7 +74,7 @@ class PingActor: print(f"Received: {response}") return self.count -@remote +@pul.remote class PongActor: def pong(self, n: int) -> str: return f"pong-{n}" diff --git a/docs/src/examples/index.zh.md b/docs/src/examples/index.zh.md index 7b703e940..f1c776f12 100644 --- a/docs/src/examples/index.zh.md +++ b/docs/src/examples/index.zh.md @@ -10,18 +10,18 @@ ```python import asyncio -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class HelloActor: def greet(self, name: str) -> str: return f"Hello, {name}!" async def main(): - await init() + await pul.init() hello = await HelloActor.spawn() print(await hello.greet("World")) - await shutdown() + await pul.shutdown() asyncio.run(main()) ``` @@ -31,7 +31,7 @@ asyncio.run(main()) 维护计数器的有状态 Actor: ```python -@remote +@pul.remote class Counter: def __init__(self, initial: int = 0): self.value = initial @@ -59,9 +59,9 @@ class Counter: 两个 Actor 跨节点通信: ```python -from pulsing.actor import init, shutdown, remote, get_system +import pulsing as pul -@remote +@pul.remote class PingActor: def __init__(self, pong_ref=None): self.pong = pong_ref @@ -74,7 +74,7 @@ class PingActor: print(f"收到: {response}") return self.count -@remote +@pul.remote class PongActor: def pong(self, n: int) -> str: return f"pong-{n}" diff --git a/docs/src/guide/actors.md b/docs/src/guide/actors.md index dc36346be..292a88c12 100644 --- a/docs/src/guide/actors.md +++ b/docs/src/guide/actors.md @@ -58,15 +58,15 @@ Pulsing follows the **classical Actor model** (like Erlang/Akka): | API | Import | Style | Best For | |-----|--------|-------|----------| -| **Native Async** | `from pulsing.actor import ...` | `async/await` | New projects, maximum performance | +| **Native Async** | `import pulsing as pul` | `async/await` | New projects, maximum performance | | **Ray-Compatible** | `from pulsing.compat import ray` | Synchronous | Migrating from Ray, quick prototyping | ### Native Async API (Recommended) ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Calculator: def __init__(self, initial_value: int = 0): self.value = initial_value @@ -76,10 +76,10 @@ class Calculator: return self.value async def main(): - await init() + await pul.init() calc = await Calculator.spawn(initial_value=100) result = await calc.add(50) # 150 - await shutdown() + await pul.shutdown() ``` ### Ray-Compatible API @@ -123,32 +123,23 @@ result = await calc.add(10) ### Tell (Fire-and-Forget) ```python -await actor_ref.tell(Message.single("notify", b"event_data")) +await actor_ref.tell({"event": "notify", "data": "event_data"}) ``` ### Streaming Messages -For continuous data flow (e.g., LLM token generation): +For continuous data flow (e.g., LLM token generation), just return a generator: ```python -from pulsing.actor import StreamMessage - @remote class TokenGenerator: - async def generate(self, prompt: str) -> Message: - stream_msg, writer = StreamMessage.create("tokens") - - async def produce(): - for token in self.generate_tokens(prompt): - await writer.write({"token": token}) - await writer.close() - - asyncio.create_task(produce()) - return stream_msg + async def generate(self, prompt: str): + # Just return an async generator - Pulsing handles streaming automatically + for token in self.generate_tokens(prompt): + yield {"token": token} # Consume the stream -response = await generator.generate("Hello") -async for chunk in response.stream_reader(): +async for chunk in generator.generate("Hello"): print(chunk["token"], end="", flush=True) ``` @@ -279,17 +270,29 @@ class ResilientActor: ### Common Operations ```python -# Spawn -actor = await MyActor.spawn(param=10) +import pulsing as pul + +# Create system +system = await pul.actor_system() + +# Spawn actor +actor = await system.spawn(MyActor(), name="my_actor", public=True) # Call method -result = await actor.method(arg) +result = await actor.ask({"action": "do_something"}) + +# Using @remote decorator (recommended) +@pul.remote +class MyService: + def process(self, data): return data + +service = await MyService.spawn(name="service") +result = await service.process("hello") + +# Resolve existing actor +proxy = await MyService.resolve("service") -# With system handle -system = await create_actor_system(config) -actor = await system.spawn(MyActor(), "name", public=True) -remote_actor = await system.find("remote-name") -await system.stop("name") +# Shutdown await system.shutdown() ``` diff --git a/docs/src/guide/actors.zh.md b/docs/src/guide/actors.zh.md index 0929e4db8..732d26fc3 100644 --- a/docs/src/guide/actors.zh.md +++ b/docs/src/guide/actors.zh.md @@ -58,15 +58,15 @@ Pulsing 遵循**经典 Actor 模型**(类似 Erlang/Akka): | API | 导入方式 | 风格 | 适用场景 | |-----|---------|------|----------| -| **原生异步** | `from pulsing.actor import ...` | `async/await` | 新项目,追求极致性能 | +| **原生异步** | `import pulsing as pul` | `async/await` | 新项目,追求极致性能 | | **Ray 兼容** | `from pulsing.compat import ray` | 同步调用 | 从 Ray 迁移,快速原型 | ### 原生异步 API(推荐) ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Calculator: def __init__(self, initial_value: int = 0): self.value = initial_value @@ -76,10 +76,10 @@ class Calculator: return self.value async def main(): - await init() + await pul.init() calc = await Calculator.spawn(initial_value=100) result = await calc.add(50) # 150 - await shutdown() + await pul.shutdown() ``` ### Ray 兼容 API @@ -123,32 +123,23 @@ result = await calc.add(10) ### Tell(即发即忘) ```python -await actor_ref.tell(Message.single("notify", b"event_data")) +await actor_ref.tell({"event": "notify", "data": "event_data"}) ``` ### 流式消息 -用于持续数据流(如 LLM token 生成): +用于持续数据流(如 LLM token 生成),只需返回 generator: ```python -from pulsing.actor import StreamMessage - @remote class TokenGenerator: - async def generate(self, prompt: str) -> Message: - stream_msg, writer = StreamMessage.create("tokens") - - async def produce(): - for token in self.generate_tokens(prompt): - await writer.write({"token": token}) - await writer.close() - - asyncio.create_task(produce()) - return stream_msg + async def generate(self, prompt: str): + # 直接返回 async generator - Pulsing 自动处理流式传输 + for token in self.generate_tokens(prompt): + yield {"token": token} # 消费流 -response = await generator.generate("Hello") -async for chunk in response.stream_reader(): +async for chunk in generator.generate("Hello"): print(chunk["token"], end="", flush=True) ``` @@ -279,17 +270,29 @@ class ResilientActor: ### 常用操作 ```python -# 创建 -actor = await MyActor.spawn(param=10) +import pulsing as pul + +# 创建系统 +system = await pul.actor_system() + +# 生成 actor +actor = await system.spawn(MyActor(), name="my_actor", public=True) # 调用方法 -result = await actor.method(arg) +result = await actor.ask({"action": "do_something"}) + +# 使用 @remote 装饰器(推荐) +@pul.remote +class MyService: + def process(self, data): return data + +service = await MyService.spawn(name="service") +result = await service.process("hello") + +# 解析已有 actor +proxy = await MyService.resolve("service") -# 使用 system handle -system = await create_actor_system(config) -actor = await system.spawn(MyActor(), "name", public=True) -remote_actor = await system.find("remote-name") -await system.stop("name") +# 关闭 await system.shutdown() ``` diff --git a/docs/src/guide/reliability.md b/docs/src/guide/reliability.md index 6815c6f62..ef10f584b 100644 --- a/docs/src/guide/reliability.md +++ b/docs/src/guide/reliability.md @@ -29,12 +29,12 @@ Recommended pattern: ## Actor-level restart (supervision) -You can configure restart policy on Python actors created via `@remote`: +You can configure restart policy on Python actors created via `@pul.remote`: ```python -from pulsing.actor import remote +import pulsing as pul -@remote(restart_policy="on-failure", max_restarts=5, min_backoff=0.2, max_backoff=10.0) +@pul.remote(restart_policy="on_failure", max_restarts=5, min_backoff=0.2, max_backoff=10.0) class Worker: def work(self, x: int) -> int: return 100 // x diff --git a/docs/src/guide/reliability.zh.md b/docs/src/guide/reliability.zh.md index e0f321111..c70dd4bd1 100644 --- a/docs/src/guide/reliability.zh.md +++ b/docs/src/guide/reliability.zh.md @@ -29,12 +29,12 @@ Pulsing 不会替你“隐式重试”。一旦你做重试,就要默认可能 ## actor 级别重启(supervision) -你可以在 Python 的 `@remote` 上配置重启策略: +你可以在 Python 的 `@pul.remote` 上配置重启策略: ```python -from pulsing.actor import remote +import pulsing as pul -@remote(restart_policy="on-failure", max_restarts=5, min_backoff=0.2, max_backoff=10.0) +@pul.remote(restart_policy="on_failure", max_restarts=5, min_backoff=0.2, max_backoff=10.0) class Worker: def work(self, x: int) -> int: return 100 // x diff --git a/docs/src/guide/remote_actors.md b/docs/src/guide/remote_actors.md index 84c0ed011..6bfdbe4ec 100644 --- a/docs/src/guide/remote_actors.md +++ b/docs/src/guide/remote_actors.md @@ -7,22 +7,23 @@ Guide to using actors across a cluster with location transparency. ### Starting a Seed Node ```python -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul # Node 1: Start seed node -config = SystemConfig.with_addr("0.0.0.0:8000") -system = await create_actor_system(config) +system = await pul.actor_system(addr="0.0.0.0:8000") # Spawn a public actor -await system.spawn(WorkerActor(), "worker", public=True) +await system.spawn(WorkerActor(), name="worker", public=True) ``` ### Joining a Cluster ```python # Node 2: Join cluster -config = SystemConfig.with_addr("0.0.0.0:8001").with_seeds(["192.168.1.1:8000"]) -system = await create_actor_system(config) +system = await pul.actor_system( + addr="0.0.0.0:8001", + seeds=["192.168.1.1:8000"] +) # Wait for cluster sync await asyncio.sleep(1.0) @@ -30,23 +31,24 @@ await asyncio.sleep(1.0) ## Finding Remote Actors -### Using system.find() +### Using system.resolve() ```python # Find actor by name (searches entire cluster) -remote_ref = await system.find("worker") - -if remote_ref: - response = await remote_ref.ask(Message.single("request", b"data")) +remote_ref = await system.resolve("worker") +response = await remote_ref.ask({"action": "process", "data": "hello"}) ``` -### Checking Actor Existence +### Using @remote Class.resolve() ```python -# Check if actor exists in cluster -if await system.has_actor("worker"): - ref = await system.find("worker") - # Use ref... +@pul.remote +class Worker: + def process(self, data): return f"processed: {data}" + +# Resolve with type info - returns ActorProxy with methods +worker = await Worker.resolve("worker") +result = await worker.process("hello") # Direct method call ``` ## Public vs Private Actors @@ -57,7 +59,7 @@ Public actors are visible to all nodes in the cluster: ```python # Public actor - can be found by other nodes -await system.spawn(WorkerActor(), "worker", public=True) +await system.spawn(WorkerActor(), name="worker", public=True) ``` ### Private Actors @@ -66,7 +68,7 @@ Private actors are only accessible locally: ```python # Private actor - local only -await system.spawn(WorkerActor(), "local-worker", public=False) +await system.spawn(WorkerActor(), name="local-worker", public=False) ``` ## Location Transparency @@ -75,10 +77,10 @@ The same API works for both local and remote actors: ```python # Local actor -local_ref = await system.spawn(MyActor(), "local") +local_ref = await system.spawn(MyActor(), name="local") # Remote actor (found via cluster) -remote_ref = await system.find("remote-worker") +remote_ref = await system.resolve("remote-worker") # Same API for both response1 = await local_ref.ask(msg) @@ -91,11 +93,8 @@ Remote actor calls can fail due to network issues: ```python try: - remote_ref = await system.find("worker") - if remote_ref: - response = await remote_ref.ask(msg) - else: - print("Actor not found") + remote_ref = await system.resolve("worker") + response = await remote_ref.ask(msg) except Exception as e: print(f"Remote call failed: {e}") ``` @@ -103,15 +102,17 @@ except Exception as e: ## Best Practices 1. **Wait for cluster sync**: Add a small delay after joining a cluster -2. **Handle missing actors**: Always check if `find()` returns None +2. **Handle errors gracefully**: Wrap remote calls in try-except blocks 3. **Use public actors for cluster communication**: Set `public=True` for actors that need remote access -4. **Handle network errors**: Wrap remote calls in try-except blocks +4. **Use @remote with resolve()**: Get typed proxies for better API experience 5. **Use timeouts**: Consider adding timeouts for remote calls ## Example: Distributed Counter ```python -@remote +import pulsing as pul + +@pul.remote class DistributedCounter: def __init__(self, init_value: int = 0): self.value = init_value @@ -123,22 +124,18 @@ class DistributedCounter: self.value += n return self.value -# Node 1 -system1 = await create_actor_system(SystemConfig.with_addr("0.0.0.0:8000")) -counter1 = await DistributedCounter.local(system1, init_value=0) -await system1.spawn(counter1, "counter", public=True) +# Node 1: Create counter +system1 = await pul.actor_system(addr="0.0.0.0:8000") +counter = await DistributedCounter.local(system1, init_value=0) -# Node 2 -system2 = await create_actor_system( - SystemConfig.with_addr("0.0.0.0:8001").with_seeds(["127.0.0.1:8000"]) -) +# Node 2: Access remote counter +system2 = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) await asyncio.sleep(1.0) -# Access remote counter from Node 2 -remote_counter = await system2.find("counter") -if remote_counter: - value = await remote_counter.get() # 0 - value = await remote_counter.increment(5) # 5 +# Resolve and use the remote counter +remote_counter = await DistributedCounter.resolve("counter") +value = await remote_counter.get() # 0 +value = await remote_counter.increment(5) # 5 ``` ## Next Steps diff --git a/docs/src/guide/remote_actors.zh.md b/docs/src/guide/remote_actors.zh.md index 8474cc872..c4ab480d6 100644 --- a/docs/src/guide/remote_actors.zh.md +++ b/docs/src/guide/remote_actors.zh.md @@ -7,22 +7,23 @@ ### 启动种子节点 ```python -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul # Node 1: 启动种子节点 -config = SystemConfig.with_addr("0.0.0.0:8000") -system = await create_actor_system(config) +system = await pul.actor_system(addr="0.0.0.0:8000") # 生成公共 actor -await system.spawn(WorkerActor(), "worker", public=True) +await system.spawn(WorkerActor(), name="worker", public=True) ``` ### 加入集群 ```python # Node 2: 加入集群 -config = SystemConfig.with_addr("0.0.0.0:8001").with_seeds(["192.168.1.1:8000"]) -system = await create_actor_system(config) +system = await pul.actor_system( + addr="0.0.0.0:8001", + seeds=["192.168.1.1:8000"] +) # 等待集群同步 await asyncio.sleep(1.0) @@ -30,23 +31,24 @@ await asyncio.sleep(1.0) ## 查找远程 Actor -### 使用 system.find() +### 使用 system.resolve() ```python # 按名称查找 actor(搜索整个集群) -remote_ref = await system.find("worker") - -if remote_ref: - response = await remote_ref.ask(Message.single("request", b"data")) +remote_ref = await system.resolve("worker") +response = await remote_ref.ask({"action": "process", "data": "hello"}) ``` -### 检查 Actor 是否存在 +### 使用 @remote 类的 resolve() ```python -# 检查 actor 是否存在于集群中 -if await system.has_actor("worker"): - ref = await system.find("worker") - # 使用 ref... +@pul.remote +class Worker: + def process(self, data): return f"processed: {data}" + +# 带类型信息解析 - 返回带方法的 ActorProxy +worker = await Worker.resolve("worker") +result = await worker.process("hello") # 直接调用方法 ``` ## 公共 vs 私有 Actor @@ -57,7 +59,7 @@ if await system.has_actor("worker"): ```python # 公共 actor - 可被其他节点找到 -await system.spawn(WorkerActor(), "worker", public=True) +await system.spawn(WorkerActor(), name="worker", public=True) ``` ### 私有 Actor @@ -66,7 +68,7 @@ await system.spawn(WorkerActor(), "worker", public=True) ```python # 私有 actor - 仅本地 -await system.spawn(WorkerActor(), "local-worker", public=False) +await system.spawn(WorkerActor(), name="local-worker", public=False) ``` ## 位置透明性 @@ -75,10 +77,10 @@ await system.spawn(WorkerActor(), "local-worker", public=False) ```python # 本地 actor -local_ref = await system.spawn(MyActor(), "local") +local_ref = await system.spawn(MyActor(), name="local") # 远程 actor(通过集群找到) -remote_ref = await system.find("remote-worker") +remote_ref = await system.resolve("remote-worker") # 两者使用相同的 API response1 = await local_ref.ask(msg) @@ -91,11 +93,8 @@ response2 = await remote_ref.ask(msg) ```python try: - remote_ref = await system.find("worker") - if remote_ref: - response = await remote_ref.ask(msg) - else: - print("Actor 未找到") + remote_ref = await system.resolve("worker") + response = await remote_ref.ask(msg) except Exception as e: print(f"远程调用失败: {e}") ``` @@ -103,15 +102,17 @@ except Exception as e: ## 最佳实践 1. **等待集群同步**:加入集群后添加短暂延迟 -2. **处理缺失的 actor**:始终检查 `find()` 是否返回 None +2. **优雅处理错误**:在 try-except 块中包装远程调用 3. **集群通信使用公共 actor**:需要远程访问的 actor 设置 `public=True` -4. **处理网络错误**:在 try-except 块中包装远程调用 +4. **使用 @remote 与 resolve()**:获取有类型的代理以获得更好的 API 体验 5. **使用超时**:考虑为远程调用添加超时 ## 示例:分布式计数器 ```python -@remote +import pulsing as pul + +@pul.remote class DistributedCounter: def __init__(self, init_value: int = 0): self.value = init_value @@ -123,25 +124,21 @@ class DistributedCounter: self.value += n return self.value -# Node 1 -system1 = await create_actor_system(SystemConfig.with_addr("0.0.0.0:8000")) -counter1 = await DistributedCounter.local(system1, init_value=0) -await system1.spawn(counter1, "counter", public=True) +# Node 1: 创建计数器 +system1 = await pul.actor_system(addr="0.0.0.0:8000") +counter = await DistributedCounter.local(system1, init_value=0) -# Node 2 -system2 = await create_actor_system( - SystemConfig.with_addr("0.0.0.0:8001").with_seeds(["127.0.0.1:8000"]) -) +# Node 2: 访问远程计数器 +system2 = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) await asyncio.sleep(1.0) -# 从 Node 2 访问远程计数器 -remote_counter = await system2.find("counter") -if remote_counter: - value = await remote_counter.get() # 0 - value = await remote_counter.increment(5) # 5 +# 解析并使用远程计数器 +remote_counter = await DistributedCounter.resolve("counter") +value = await remote_counter.get() # 0 +value = await remote_counter.increment(5) # 5 ``` ## 下一步 - 了解 [Actor 系统](actor_system.zh.md) 基础知识 -- 查看[节点发现](../design/node-discovery.md)了解集群详情 +- 查看[节点发现](../design/node-discovery.zh.md)了解集群详情 diff --git a/docs/src/guide/security.md b/docs/src/guide/security.md index b4b20f740..f2192bc82 100644 --- a/docs/src/guide/security.md +++ b/docs/src/guide/security.md @@ -28,11 +28,10 @@ Pulsing supports **passphrase-based mTLS (Mutual TLS)** for secure cluster commu By default, Pulsing uses cleartext HTTP/2 (h2c) for easy debugging: ```python -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul # No passphrase - uses cleartext HTTP/2 -config = SystemConfig.with_addr("0.0.0.0:8000") -system = await create_actor_system(config) +system = await pul.actor_system(addr="0.0.0.0:8000") ``` ### Production Mode (mTLS) @@ -41,8 +40,10 @@ To enable TLS encryption, simply set a passphrase: ```python # Set passphrase - automatically enables mTLS -config = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase("my-cluster-secret") -system = await create_actor_system(config) +system = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase="my-cluster-secret" +) ``` ## Multi-Node Cluster with TLS @@ -51,16 +52,17 @@ All nodes in a cluster must use the **same passphrase** to communicate: ```python # Node 1: Seed node with TLS -config1 = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase("shared-secret") -system1 = await create_actor_system(config1) +system1 = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase="shared-secret" +) # Node 2: Join cluster with same passphrase -config2 = ( - SystemConfig.with_addr("0.0.0.0:8001") - .with_seeds(["192.168.1.1:8000"]) - .with_passphrase("shared-secret") # Must match! +system2 = await pul.actor_system( + addr="0.0.0.0:8001", + seeds=["192.168.1.1:8000"], + passphrase="shared-secret" # Must match! ) -system2 = await create_actor_system(config2) ``` !!! warning "Passphrase Mismatch" @@ -72,12 +74,12 @@ Different passphrases create completely isolated clusters: ```python # Cluster A -cluster_a = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase("secret-a") +system_a = await pul.actor_system(addr="0.0.0.0:8000", passphrase="secret-a") # Cluster B (different passphrase) -cluster_b = SystemConfig.with_addr("0.0.0.0:9000").with_passphrase("secret-b") +system_b = await pul.actor_system(addr="0.0.0.0:9000", passphrase="secret-b") -# cluster_a and cluster_b cannot communicate +# system_a and system_b cannot communicate ``` ## How It Works @@ -135,13 +137,15 @@ Store passphrases in environment variables, not code: ```python import os +import pulsing as pul passphrase = os.environ.get("PULSING_PASSPHRASE") -if passphrase: - config = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase(passphrase) -else: - # Development mode - no TLS - config = SystemConfig.with_addr("0.0.0.0:8000") + +# Create system with optional TLS +system = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase=passphrase # None = no TLS (dev mode) +) ``` ### 3. Rotate Passphrases @@ -184,12 +188,12 @@ Even with TLS, use network-level security: ```python import os -from pulsing.actor import init, shutdown, remote +import pulsing as pul # Get passphrase from environment PASSPHRASE = os.environ.get("PULSING_SECRET", None) -@remote +@pul.remote class SecureCounter: def __init__(self, init_value: int = 0): self.value = init_value @@ -202,19 +206,19 @@ class SecureCounter: return self.value async def main(): - # Create config with optional TLS - config = SystemConfig.with_addr("0.0.0.0:8000") - if PASSPHRASE: - config = config.with_passphrase(PASSPHRASE) - - system = await create_actor_system(config) + # Create system with optional TLS + system = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase=PASSPHRASE + ) # Spawn secure counter counter = await SecureCounter.local(system, init_value=0) - await system.spawn(counter, "secure-counter", public=True) print("Secure counter running...") print(f"TLS enabled: {PASSPHRASE is not None}") + + await system.shutdown() ``` ## Next Steps diff --git a/docs/src/guide/security.zh.md b/docs/src/guide/security.zh.md index 8eb91ff71..93d03bef4 100644 --- a/docs/src/guide/security.zh.md +++ b/docs/src/guide/security.zh.md @@ -28,11 +28,10 @@ Pulsing 支持**基于口令的 mTLS(双向 TLS)**实现安全的集群通 默认情况下,Pulsing 使用明文 HTTP/2 (h2c) 便于调试: ```python -from pulsing.actor import SystemConfig, create_actor_system +import pulsing as pul # 不设置口令 - 使用明文 HTTP/2 -config = SystemConfig.with_addr("0.0.0.0:8000") -system = await create_actor_system(config) +system = await pul.actor_system(addr="0.0.0.0:8000") ``` ### 生产模式(mTLS) @@ -41,8 +40,10 @@ system = await create_actor_system(config) ```python # 设置口令 - 自动启用 mTLS -config = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase("my-cluster-secret") -system = await create_actor_system(config) +system = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase="my-cluster-secret" +) ``` ## 多节点 TLS 集群 @@ -51,16 +52,17 @@ system = await create_actor_system(config) ```python # Node 1: 带 TLS 的种子节点 -config1 = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase("shared-secret") -system1 = await create_actor_system(config1) +system1 = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase="shared-secret" +) # Node 2: 使用相同口令加入集群 -config2 = ( - SystemConfig.with_addr("0.0.0.0:8001") - .with_seeds(["192.168.1.1:8000"]) - .with_passphrase("shared-secret") # 必须匹配! +system2 = await pul.actor_system( + addr="0.0.0.0:8001", + seeds=["192.168.1.1:8000"], + passphrase="shared-secret" # 必须匹配! ) -system2 = await create_actor_system(config2) ``` !!! warning "口令不匹配" @@ -72,12 +74,12 @@ system2 = await create_actor_system(config2) ```python # 集群 A -cluster_a = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase("secret-a") +system_a = await pul.actor_system(addr="0.0.0.0:8000", passphrase="secret-a") # 集群 B(不同口令) -cluster_b = SystemConfig.with_addr("0.0.0.0:9000").with_passphrase("secret-b") +system_b = await pul.actor_system(addr="0.0.0.0:9000", passphrase="secret-b") -# cluster_a 和 cluster_b 无法通信 +# system_a 和 system_b 无法通信 ``` ## 工作原理 @@ -135,13 +137,15 @@ Pulsing 使用**确定性 CA 派生**方法: ```python import os +import pulsing as pul passphrase = os.environ.get("PULSING_PASSPHRASE") -if passphrase: - config = SystemConfig.with_addr("0.0.0.0:8000").with_passphrase(passphrase) -else: - # 开发模式 - 无 TLS - config = SystemConfig.with_addr("0.0.0.0:8000") + +# 创建系统,可选启用 TLS +system = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase=passphrase # None = 无 TLS(开发模式) +) ``` ### 3. 口令轮换 @@ -184,12 +188,12 @@ else: ```python import os -from pulsing.actor import init, shutdown, remote +import pulsing as pul # 从环境变量获取口令 PASSPHRASE = os.environ.get("PULSING_SECRET", None) -@remote +@pul.remote class SecureCounter: def __init__(self, init_value: int = 0): self.value = init_value @@ -202,23 +206,23 @@ class SecureCounter: return self.value async def main(): - # 创建配置,可选启用 TLS - config = SystemConfig.with_addr("0.0.0.0:8000") - if PASSPHRASE: - config = config.with_passphrase(PASSPHRASE) - - system = await create_actor_system(config) + # 创建系统,可选启用 TLS + system = await pul.actor_system( + addr="0.0.0.0:8000", + passphrase=PASSPHRASE + ) # 生成安全计数器 counter = await SecureCounter.local(system, init_value=0) - await system.spawn(counter, "secure-counter", public=True) print("安全计数器运行中...") print(f"TLS 已启用: {PASSPHRASE is not None}") + + await system.shutdown() ``` ## 下一步 - 了解[远程 Actor](remote_actors.zh.md) 的集群通信 -- 查看 [HTTP2 传输](../design/http2-transport.md) 了解传输细节 +- 查看 [HTTP2 传输](../design/http2-transport.zh.md) 了解传输细节 - 阅读[语义与保证](semantics.zh.md) 了解消息传递保证 diff --git a/docs/src/index.md b/docs/src/index.md index 41409a1d6..8a879c8b5 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -83,9 +83,9 @@ pip install pulsing ```python import asyncio -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self, value=0): self.value = value @@ -95,11 +95,11 @@ class Counter: return self.value async def main(): - await init() + await pul.init() counter = await Counter.spawn(value=0) print(await counter.inc()) # 1 print(await counter.inc()) # 2 - await shutdown() + await pul.shutdown() asyncio.run(main()) ``` diff --git a/docs/src/index.zh.md b/docs/src/index.zh.md index 261c87f2d..f22f1145a 100644 --- a/docs/src/index.zh.md +++ b/docs/src/index.zh.md @@ -83,9 +83,9 @@ pip install pulsing ```python import asyncio -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self, value=0): self.value = value @@ -95,11 +95,11 @@ class Counter: return self.value async def main(): - await init() + await pul.init() counter = await Counter.spawn(value=0) print(await counter.inc()) # 1 print(await counter.inc()) # 2 - await shutdown() + await pul.shutdown() asyncio.run(main()) ``` diff --git a/docs/src/quickstart/index.md b/docs/src/quickstart/index.md index bea5d7a5e..fb8c02316 100644 --- a/docs/src/quickstart/index.md +++ b/docs/src/quickstart/index.md @@ -14,9 +14,9 @@ pip install pulsing ```python import asyncio -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self, value=0): self.value = value @@ -26,16 +26,16 @@ class Counter: return self.value async def main(): - await init() + await pul.init() counter = await Counter.spawn(value=0) print(await counter.inc()) # 1 print(await counter.inc()) # 2 - await shutdown() + await pul.shutdown() asyncio.run(main()) ``` -The `@remote` decorator turns any Python class into a distributed Actor. +The `@pul.remote` decorator turns any Python class into a distributed Actor. --- diff --git a/docs/src/quickstart/index.zh.md b/docs/src/quickstart/index.zh.md index ea03f8fe7..503e970bd 100644 --- a/docs/src/quickstart/index.zh.md +++ b/docs/src/quickstart/index.zh.md @@ -14,9 +14,9 @@ pip install pulsing ```python import asyncio -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self, value=0): self.value = value @@ -26,16 +26,16 @@ class Counter: return self.value async def main(): - await init() + await pul.init() counter = await Counter.spawn(value=0) print(await counter.inc()) # 1 print(await counter.inc()) # 2 - await shutdown() + await pul.shutdown() asyncio.run(main()) ``` -`@remote` 装饰器将任意 Python 类变成分布式 Actor。 +`@pul.remote` 装饰器将任意 Python 类变成分布式 Actor。 --- diff --git a/docs/src/quickstart/migrate_from_ray.md b/docs/src/quickstart/migrate_from_ray.md index d9c759990..df909833b 100644 --- a/docs/src/quickstart/migrate_from_ray.md +++ b/docs/src/quickstart/migrate_from_ray.md @@ -135,9 +135,9 @@ result = ray.get(worker.process.remote("hello")) For new code, consider the native async API: ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self): self.value = 0 @@ -147,10 +147,10 @@ class Counter: return self.value async def main(): - await init() + await pul.init() counter = await Counter.spawn() print(await counter.inc()) # 1 - await shutdown() + await pul.shutdown() ``` **Benefits:** diff --git a/docs/src/quickstart/migrate_from_ray.zh.md b/docs/src/quickstart/migrate_from_ray.zh.md index 9ef9efb99..c6083b156 100644 --- a/docs/src/quickstart/migrate_from_ray.zh.md +++ b/docs/src/quickstart/migrate_from_ray.zh.md @@ -135,9 +135,9 @@ result = ray.get(worker.process.remote("hello")) 新代码建议使用原生异步 API: ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self): self.value = 0 @@ -147,10 +147,10 @@ class Counter: return self.value async def main(): - await init() + await pul.init() counter = await Counter.spawn() print(await counter.inc()) # 1 - await shutdown() + await pul.shutdown() ``` **优势:** diff --git a/examples/agent/pulsing/README.md b/examples/agent/pulsing/README.md index 492273e40..7da223786 100644 --- a/examples/agent/pulsing/README.md +++ b/examples/agent/pulsing/README.md @@ -5,11 +5,11 @@ ## 核心 API ```python -from pulsing.actor import remote, resolve +import pulsing as pul from pulsing.agent import agent, runtime, llm, parse_json, get_agent_meta -# @remote: 基础 Actor 装饰器 -@remote +# @pul.remote: 基础 Actor 装饰器 +@pul.remote class MyActor: async def work(self): ... @@ -24,19 +24,19 @@ async with runtime(): result = await actor.work() # 通过名称获取其他 Actor - peer = await resolve("actor") + peer = await MyActor.resolve("actor") ``` ## 示例列表 ### 1. MBTI 人格讨论 (`mbti_discussion.py`) -基于 MBTI 人格类型的多智能体讨论与投票,演示 `@remote` 与 `@agent` 的区别。 +基于 MBTI 人格类型的多智能体讨论与投票,演示 `@pul.remote` 与 `@agent` 的区别。 ``` ┌─────────────────────────────────────┐ │ ModeratorActor │ - │ (使用 @remote,协调流程) │ + │ (使用 @pul.remote,协调流程) │ └──────────────┬──────────────────────┘ │ resolve() ┌───────────────────┼───────────────────────┐ @@ -49,7 +49,7 @@ async with runtime(): ``` **特点:** -- `ModeratorActor` 使用 `@remote`(普通 Actor) +- `ModeratorActor` 使用 `@pul.remote`(普通 Actor) - `MBTIAgent` 使用 `@agent`(附带元信息) - 可通过 `get_agent_meta()` 和 `list_agents()` 获取元信息 @@ -82,7 +82,7 @@ python mbti_discussion.py --topic "AI是否应该有情感" **特点:** - 完全异步并行执行 -- Agent 间可通过 `resolve()` 协作 +- Agent 间可通过 `ClassName.resolve()` 协作 - 竞争性提交 + 截止时间机制 ```bash @@ -104,18 +104,18 @@ pip install -e . pip install langchain-openai ``` -## `@remote` vs `@agent` +## `@pul.remote` vs `@agent` -| 特性 | `@remote` | `@agent` | -|------|-----------|----------| +| 特性 | `@pul.remote` | `@agent` | +|------|---------------|----------| | 功能 | Actor 化 | Actor 化 + 元信息 | | 用途 | 通用 | 需要可视化/调试时 | | 元信息 | 无 | `role`, `goal`, `backstory`, `tags` | | 性能开销 | 无 | 几乎无 | ```python -# @remote: 直接使用 -@remote +# @pul.remote: 直接使用 +@pul.remote class Worker: async def work(self): ... @@ -143,6 +143,6 @@ async with runtime(addr="0.0.0.0:8001"): # 节点 B(自动发现节点 A) async with runtime(addr="0.0.0.0:8002", seeds=["node_a:8001"]): - judge = await resolve("judge") # 跨节点透明调用 + judge = await JudgeActor.resolve("judge") # 跨节点透明调用 await judge.submit(idea) ``` diff --git a/examples/python/README.md b/examples/python/README.md index ebd599962..7b4656ad4 100644 --- a/examples/python/README.md +++ b/examples/python/README.md @@ -42,15 +42,15 @@ python examples/python/cluster.py # Multi-node (see --help) | API | 风格 | 适用场景 | |-----|------|----------| -| `pulsing.actor` | 异步 (`async/await`) | 新项目,高性能需求 | -| `pulsing.compat.ray` | 同步 (Ray 风格) | Ray 迁移,快速上手 | +| `import pulsing as pul` | 异步 (`async/await`) | 新项目,高性能需求 | +| `from pulsing.compat import ray` | 同步 (Ray 风格) | Ray 迁移,快速上手 | ### 原生 API 示例 ```python -from pulsing.actor import init, shutdown, remote +import pulsing as pul -@remote +@pul.remote class Counter: def __init__(self, value=0): self.value = value @@ -59,10 +59,10 @@ class Counter: return self.value async def main(): - await init() + await pul.init() counter = await Counter.spawn(value=0) print(await counter.inc()) # 1 - await shutdown() + await pul.shutdown() ``` ### Ray 兼容 API 示例 diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index 9d0d4f2e4..6e89b0dc4 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -122,7 +122,7 @@ python examples/quickstart/chaos_proof.py **核心代码**: ```python -@remote(restart_policy="on-failure", max_restarts=50) +@remote(restart_policy="on_failure", max_restarts=50) class FlakyWorker: def work(self, x: int) -> int: if random.random() < 0.3: # 30% 概率崩溃 @@ -137,11 +137,11 @@ class FlakyWorker: ## 核心概念(10秒理解) ```python -from pulsing.actor import remote, resolve +import pulsing as pul from pulsing.agent import runtime -# 1. @remote 让类变成可分布式部署的 Agent -@remote +# 1. @pul.remote 让类变成可分布式部署的 Agent +@pul.remote class MyAgent: def hello(self): return "Hello!" @@ -154,8 +154,8 @@ async with runtime(): # 4. 直接调用方法(自动变成远程调用) result = await agent.hello() - # 5. resolve() 通过名字找到其他 Agent - same_agent = await resolve("my_agent") + # 5. MyAgent.resolve() 通过名字找到已有 Agent + same_agent = await MyAgent.resolve("my_agent") ``` ## 下一步 @@ -196,7 +196,7 @@ async with runtime(addr="0.0.0.0:8001"): # 节点 2(自动发现节点 1) async with runtime(addr="0.0.0.0:8002", seeds=["node1:8001"]): - agent = await resolve("agent") # 跨节点调用 + agent = await MyAgent.resolve("agent") # 跨节点调用 ``` ### Q: @remote vs @agent 有什么区别? From 5404820103515d04e894a20167f0721c81ec4984 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 10:54:29 +0800 Subject: [PATCH 13/24] Add Python API Contract section to documentation - Introduced a new section in the API reference documentation detailing the user-facing contract for Pulsing's Python API, derived from `llms.binding.md`. - Added explanations for concurrency models, streaming, error handling, and trust boundaries to enhance user understanding. - Updated the Chinese API reference to include the same contract and semantics information, ensuring consistency across languages. - Made minor adjustments to the queue manager to support backward compatibility with node status checks. --- docs/mkdocs.yml | 1 + docs/src/api_reference.md | 60 +++++++++++++++++++++++++++++++++ docs/src/api_reference.zh.md | 60 +++++++++++++++++++++++++++++++++ python/pulsing/queue/manager.py | 12 +++++-- python/pulsing/queue/queue.py | 8 ++--- 5 files changed, 135 insertions(+), 6 deletions(-) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c701fe72a..f2bf670ff 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -93,6 +93,7 @@ plugins: Reliability: 可靠性 Operations: CLI 运维 Distributed Queue: 分布式内存队列 + Python API Contract: Python API 契约 Semantics: 语义与保证 Style Guide: 术语与风格 Agent: Agent 框架 diff --git a/docs/src/api_reference.md b/docs/src/api_reference.md index de41cdffb..c3b5d7776 100644 --- a/docs/src/api_reference.md +++ b/docs/src/api_reference.md @@ -2,6 +2,66 @@ Complete API documentation for Pulsing Actor Framework. +## Contract & Semantics (Derived from `llms.binding.md`) + +This section is the **user-facing contract** for Pulsing's Python API. It is **derived from** the repository document **`llms.binding.md`**. + +- **Source of truth**: `llms.binding.md` is the canonical contract. +- **This page**: API reference + explicit semantics (concurrency, errors, trust boundaries). +- **If there's a mismatch**: treat `llms.binding.md` as authoritative; please open an issue/PR to sync docs. + +### Concurrency model for `@pulsing.remote` + +For a `@pulsing.remote` class, method calls are translated into actor messages. + +- **Sync method (`def method`)** + - Executed **serially** (one request at a time) in the actor. + - Recommended for fast CPU work and state mutation. +- **Async method (`async def method`)** + - The call uses **stream-backed execution** and is scheduled as a background task on the actor side. + - While the method is awaiting, the actor can continue receiving other messages (**non-blocking** behavior). + - You can either: + - `await proxy.async_method(...)` to get the final value, or + - `async for chunk in proxy.async_method(...): ...` to consume streamed yields. +- **Generators (sync/async)** + - Returning a generator (sync or async) is treated as a **streaming response**. + +### Streaming & cancellation + +- Streaming is implemented via Pulsing stream messages; cancellation is **best-effort**. +- If a caller cancels the local await/iteration, the remote side may or may not stop immediately, depending on transport-level cancellation propagation. + +### `ask` vs `tell` + +- **`ask(msg)`**: request/response. Returns a value (or raises). +- **`tell(msg)`**: fire-and-forget. No response is awaited. + +### Error model (current behavior) + +- Actor-side exceptions are transported back and typically raised as **`RuntimeError(str(e))`** on the caller side. +- Timeout helpers (where used) raise **`asyncio.TimeoutError`**. + +Note: error *type information and remote stack traces* are not guaranteed to be preserved. + +### Trust boundary & security notes + +- **Pickle-based payloads (Python ↔ Python)**: + - Python-to-Python payloads are transported as **pickle** by default for convenience. + - **Risk**: unpickling untrusted data can lead to arbitrary code execution (RCE). + - **Guideline**: only use pickle payloads inside a **trusted network / trusted cluster** boundary. +- **Transport security (TLS)**: + - For production deployments, always enable TLS and treat the cluster as an authenticated trust boundary. + +### Queue semantics (distributed queue) + +- **Bucketing**: + - Writer uses `bucket_column` + `num_buckets` to partition records into buckets. + - Readers must use a consistent `num_buckets` (and backend) with writers. +- **Ownership**: + - Bucket ownership is computed by hashing over live cluster members; requests may be redirected to the owning node. +- **Backends**: + - Default backend is in-memory; persistence depends on the selected backend. + ## Core Functions ### pul.actor_system diff --git a/docs/src/api_reference.zh.md b/docs/src/api_reference.zh.md index 0d009d210..6b2cc72d3 100644 --- a/docs/src/api_reference.zh.md +++ b/docs/src/api_reference.zh.md @@ -2,6 +2,66 @@ Pulsing Actor 框架的完整 API 文档。 +## 契约与语义(由 `llms.binding.md` 派生) + +本节是 Pulsing Python API 的**面向用户契约**,内容**派生自**仓库根目录的 **`llms.binding.md`**。 + +- **权威来源**:`llms.binding.md` 是对外契约源文件。 +- **本文档**:API 参考 + 显式语义声明(并发、错误、信任边界)。 +- **若两者不一致**:以 `llms.binding.md` 为准,并请提 Issue/PR 同步。 + +### `@pulsing.remote` 的并发语义 + +对 `@pulsing.remote` 类,方法调用会被翻译为 actor 消息并在 actor 内执行。 + +- **同步方法(`def method`)** + - 在 actor 内**串行执行**(一次处理一个请求)。 + - 适合:快速计算、状态修改。 +- **异步方法(`async def method`)** + - 调用走**基于流(stream)的执行路径**,在 actor 侧以后台任务调度。 + - 当方法处于 `await` 等待期间,actor 仍可继续处理其他消息(即“非阻塞 actor”语义)。 + - 你既可以: + - `await proxy.async_method(...)` 获取最终返回值;也可以 + - `async for chunk in proxy.async_method(...): ...` 消费中间 `yield`。 +- **生成器(同步/异步)** + - 返回 generator(sync/async)会被当作**流式响应**。 + +### 流式与取消(cancellation) + +- 流式响应通过 Pulsing stream message 实现;取消传播属于 **best-effort**。 +- 调用方取消本地 `await/async for` 时,远端是否立即停止取决于传输层取消传播。 + +### `ask` 与 `tell` + +- **`ask(msg)`**:请求-响应,返回值或抛异常。 +- **`tell(msg)`**:fire-and-forget,不等待返回。 + +### 错误模型(当前行为) + +- actor 内抛出的异常通常会在调用方表现为 **`RuntimeError(str(e))`**。 +- 若使用超时封装(如 `asyncio.wait_for`),超时会抛 **`asyncio.TimeoutError`**。 + +注意:错误类型信息与远端堆栈不保证完整保留。 + +### 信任边界与安全声明 + +- **Python ↔ Python 默认使用 Pickle**: + - 为了易用性,Python↔Python 对象载荷默认使用 **pickle**。 + - **风险**:对不可信数据 unpickle 可能导致任意代码执行(RCE)。 + - **建议**:仅在**可信网络/可信集群边界**内使用。 +- **传输层安全(TLS)**: + - 生产环境建议启用 TLS/mTLS,并把集群内部通信视为经过认证的信任边界。 + +### 队列语义(Distributed Queue) + +- **分桶**: + - 写端使用 `bucket_column` + `num_buckets` 决定 record 落到哪个 bucket。 + - 读端必须与写端保持一致的 `num_buckets`(以及 backend)。 +- **归属(owner)**: + - bucket 的 owner 基于集群成员一致性哈希计算;请求可能被重定向到 owner 节点。 +- **后端**: + - 默认内存后端;是否持久化取决于 backend。 + ## 核心函数 ### pul.actor_system diff --git a/python/pulsing/queue/manager.py b/python/pulsing/queue/manager.py index c6bda87b9..55672b79b 100644 --- a/python/pulsing/queue/manager.py +++ b/python/pulsing/queue/manager.py @@ -28,8 +28,16 @@ def _compute_owner(bucket_key: str, nodes: list[dict]) -> int | None: if not nodes: return None - # Only select nodes in Alive state - alive_nodes = [n for n in nodes if n.get("state") == "Alive"] + # Only select nodes in Alive state. + # + # Note: `ActorSystem.members()` (Rust binding) currently returns `status` + # (e.g. "Alive"/"Suspect"/"Dead") rather than `state`, while some older + # callers used `state`. Support both to stay backward compatible. + alive_nodes = [ + n + for n in nodes + if (n.get("state") or n.get("status")) == "Alive" + ] if not alive_nodes: # If no Alive nodes, fallback to all nodes alive_nodes = nodes diff --git a/python/pulsing/queue/queue.py b/python/pulsing/queue/queue.py index 0b563c698..4026ae911 100644 --- a/python/pulsing/queue/queue.py +++ b/python/pulsing/queue/queue.py @@ -443,13 +443,12 @@ async def read_queue( else: assigned_buckets = None # Read from all buckets - # Create Queue + # Create Queue (reader side doesn't need bucket_column for hashing, but it must + # keep `num_buckets/storage_path/backend` consistent with writer). queue = Queue( system=system, topic=topic, - bucket_column="id", num_buckets=num_buckets, - batch_size=100, storage_path=storage_path, backend=backend, backend_options=backend_options, @@ -458,7 +457,8 @@ async def read_queue( # Try to resolve existing bucket Actors if assigned_buckets: for bid in assigned_buckets: - actor_name = f"queue_{topic}_bucket_{bid}" + # Must match `StorageManager` bucket actor naming: "bucket_{topic}_{bucket_id}" + actor_name = f"bucket_{topic}_{bid}" try: queue._bucket_refs[bid] = await system.resolve_named(actor_name) except Exception: From 9c9840032339f498bf09940a1a8687f958f94cf8 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 12:45:57 +0800 Subject: [PATCH 14/24] Enhance Actor System API and documentation - Introduced new traits for Actor System extensions: `ActorSystemCoreExt`, `ActorSystemAdvancedExt`, and `ActorSystemOpsExt`, providing core operations, factory-based spawning, and lifecycle management. - Updated the Rust API documentation to reflect the new trait structure, enhancing clarity on spawning, resolving, and actor management. - Added comprehensive examples for the new API methods in both English and Chinese documentation, ensuring consistency and usability. - Made minor adjustments to existing code and documentation for improved readability and backward compatibility. --- crates/pulsing-actor/src/lib.rs | 5 +- crates/pulsing-actor/src/system/mod.rs | 5 +- crates/pulsing-actor/src/system/traits.rs | 531 ++++++++++++++++++++++ crates/pulsing-py/src/actor.rs | 6 +- docs/src/api_reference.md | 49 ++ docs/src/api_reference.zh.md | 49 ++ examples/quickstart/chaos_proof.py | 7 +- examples/quickstart/function_to_fleet.py | 4 +- llms.binding.md | 120 ++++- python/pulsing/actor/remote.py | 4 +- python/pulsing/queue/manager.py | 6 +- ruff.toml | 2 +- tests/python/test_queue.py | 6 +- tests/python/test_rendezvous_hash.py | 24 +- tests/python/test_topic.py | 6 +- 15 files changed, 777 insertions(+), 47 deletions(-) create mode 100644 crates/pulsing-actor/src/system/traits.rs diff --git a/crates/pulsing-actor/src/lib.rs b/crates/pulsing-actor/src/lib.rs index e5f55aa4d..de3e17e9b 100644 --- a/crates/pulsing-actor/src/lib.rs +++ b/crates/pulsing-actor/src/lib.rs @@ -133,7 +133,10 @@ pub mod watch; pub mod prelude { pub use crate::actor::{Actor, ActorContext, ActorRef, Message}; pub use crate::supervision::{BackoffStrategy, RestartPolicy, SupervisionSpec}; - pub use crate::system::{ActorSystem, ResolveOptions, SpawnOptions, SystemConfig}; + pub use crate::system::{ + ActorSystem, ActorSystemAdvancedExt, ActorSystemCoreExt, ActorSystemOpsExt, ResolveOptions, + SpawnOptions, SystemConfig, + }; pub use async_trait::async_trait; pub use serde::{Deserialize, Serialize}; } diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index 111d7c654..0f6e0779f 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -10,9 +10,11 @@ mod config; mod handle; mod handler; mod runtime; +mod traits; pub use config::{ActorSystemBuilder, ResolveOptions, SpawnOptions, SystemConfig}; pub use handle::ActorStats; +pub use traits::{ActorSystemAdvancedExt, ActorSystemCoreExt, ActorSystemOpsExt}; use crate::actor::{ Actor, ActorAddress, ActorContext, ActorId, ActorPath, ActorRef, ActorResolver, ActorSystemRef, @@ -530,7 +532,8 @@ impl ActorSystem { let join_handle = tokio::spawn(async move { let mut receiver = receiver; let mut ctx = ctx; - let reason = run_actor_instance(actor, &mut receiver, &mut ctx, cancel, stats_clone).await; + let reason = + run_actor_instance(actor, &mut receiver, &mut ctx, cancel, stats_clone).await; tracing::debug!(actor_id = ?actor_id_for_log, reason = ?reason, "Anonymous actor stopped"); }); diff --git a/crates/pulsing-actor/src/system/traits.rs b/crates/pulsing-actor/src/system/traits.rs new file mode 100644 index 000000000..84677afbc --- /dev/null +++ b/crates/pulsing-actor/src/system/traits.rs @@ -0,0 +1,531 @@ +//! Actor System Extension Traits +//! +//! This module defines the public API surface for ActorSystem through traits: +//! - [`ActorSystemCoreExt`] - Core spawn and resolve operations (primary API) +//! - [`ActorSystemAdvancedExt`] - Factory-based spawning for supervision/restart +//! - [`ActorSystemOpsExt`] - Operations, introspection, and lifecycle management + +use std::net::SocketAddr; +use std::sync::Arc; + +use crate::actor::{Actor, ActorId, ActorPath, ActorRef, IntoActorPath, NodeId}; +use crate::cluster::{MemberInfo, NamedActorInfo}; +use crate::system_actor::BoxedActorFactory; + +use super::config::{ResolveOptions, SpawnOptions}; +use super::NodeLoadTracker; + +use tokio_util::sync::CancellationToken; + +// ============================================================================= +// Core Trait: Spawn + Resolve (Primary API) +// ============================================================================= + +/// Core API for spawning and resolving actors. +/// +/// This trait defines the primary interface for creating and locating actors. +/// It is automatically implemented for `Arc` and re-exported in prelude. +/// +/// # Spawn Methods +/// - [`spawn`](Self::spawn) - Spawn an actor with a local name +/// - [`spawn_with_options`](Self::spawn_with_options) - Spawn with custom options +/// - [`spawn_named`](Self::spawn_named) - Spawn a publicly discoverable named actor +/// - [`spawn_named_with_options`](Self::spawn_named_with_options) - Spawn named with custom options +/// +/// # Resolve Methods +/// - [`actor_ref`](Self::actor_ref) - Get ActorRef by ActorId +/// - [`resolve_named`](Self::resolve_named) - Resolve a named actor by path +/// - [`resolve_named_with_options`](Self::resolve_named_with_options) - Resolve with load balancing/filtering +/// - [`resolve_named_lazy`](Self::resolve_named_lazy) - Lazy resolution with auto-refresh +/// +/// # Example +/// ```rust,ignore +/// use pulsing_actor::prelude::*; +/// +/// let system = ActorSystem::builder().build().await?; +/// +/// // Spawn a local actor +/// let actor = system.spawn("my_actor", MyActor::new()).await?; +/// +/// // Spawn a named actor (discoverable across cluster) +/// let named = system.spawn_named("services/echo", "echo", EchoActor).await?; +/// +/// // Resolve by name +/// let resolved = system.resolve_named("services/echo", None).await?; +/// ``` +#[async_trait::async_trait] +pub trait ActorSystemCoreExt { + /// Spawn an actor with a local name (uses system default mailbox capacity) + async fn spawn
(&self, name: impl AsRef + Send, actor: A) -> anyhow::Result + where + A: Actor; + + /// Spawn an actor with custom options + async fn spawn_with_options( + &self, + name: impl AsRef + Send, + actor: A, + options: SpawnOptions, + ) -> anyhow::Result + where + A: Actor; + + /// Spawn a named actor (publicly accessible via named path) + /// + /// Named actors are discoverable across the cluster by their path. + /// + /// # Arguments + /// - `path` - The public path for discovery (e.g., "services/echo") + /// - `local_name` - The local name for this instance + /// - `actor` - The actor instance + async fn spawn_named( + &self, + path: P, + local_name: impl AsRef + Send, + actor: A, + ) -> anyhow::Result + where + P: IntoActorPath + Send, + A: Actor; + + /// Spawn a named actor with custom options + async fn spawn_named_with_options( + &self, + path: P, + local_name: impl AsRef + Send, + actor: A, + options: SpawnOptions, + ) -> anyhow::Result + where + P: IntoActorPath + Send, + A: Actor; + + /// Get ActorRef for a local or remote actor by ID + async fn actor_ref(&self, id: &ActorId) -> anyhow::Result; + + /// Resolve a named actor by path + /// + /// Returns an ActorRef that points to the current location of the named actor. + /// Note: If the actor migrates, this reference may become stale. + /// For actors that may migrate, consider using [`resolve_named_lazy`](Self::resolve_named_lazy). + async fn resolve_named

(&self, path: P, node_id: Option<&NodeId>) -> anyhow::Result + where + P: IntoActorPath + Send; + + /// Resolve a named actor with custom options (load balancing, health filtering) + async fn resolve_named_with_options( + &self, + path: &ActorPath, + options: ResolveOptions, + ) -> anyhow::Result; + + /// Resolve a named actor with lazy resolution (re-resolves after cache expires) + /// + /// Returns an ActorRef that automatically re-resolves after ~5 seconds. + /// This is useful for named actors that may migrate between nodes. + fn resolve_named_lazy

(&self, path: P) -> anyhow::Result + where + P: IntoActorPath; +} + +// ============================================================================= +// Advanced Trait: Factory-based Spawning (Supervision/Restart) +// ============================================================================= + +/// Advanced API for factory-based actor spawning. +/// +/// Factory-based spawning enables supervision restarts - when an actor fails, +/// the system can recreate it using the factory function. +/// +/// Note: Regular `spawn` methods use a one-shot factory internally, so the actor +/// cannot be restarted. Use `spawn_factory` or `spawn_named_factory` if you need +/// supervision with restart capability. +/// +/// # Example +/// ```rust,ignore +/// use pulsing_actor::prelude::*; +/// +/// let system = ActorSystem::builder().build().await?; +/// +/// // Spawn with factory - enables restart on failure +/// let options = SpawnOptions::new() +/// .supervision(SupervisionSpec::new() +/// .restart_policy(RestartPolicy::OnFailure) +/// .max_restarts(3)); +/// +/// let actor = system.spawn_factory("worker", || Ok(Worker::new()), options).await?; +/// ``` +#[async_trait::async_trait] +pub trait ActorSystemAdvancedExt { + /// Spawn an actor using a factory function (enables supervision restarts) + async fn spawn_factory( + &self, + name: impl AsRef + Send, + factory: F, + options: SpawnOptions, + ) -> anyhow::Result + where + F: FnMut() -> anyhow::Result + Send + 'static, + A: Actor; + + /// Spawn a named actor using a factory function + async fn spawn_named_factory( + &self, + path: P, + local_name: impl AsRef + Send, + factory: F, + options: SpawnOptions, + ) -> anyhow::Result + where + P: IntoActorPath + Send, + F: FnMut() -> anyhow::Result + Send + 'static, + A: Actor; +} + +// ============================================================================= +// Ops Trait: Operations, Introspection, Lifecycle +// ============================================================================= + +/// Operations, introspection, and lifecycle management API. +/// +/// This trait provides: +/// - System information (node_id, addr, etc.) +/// - Actor listing and lookup +/// - Cluster membership information +/// - Actor stop and system shutdown +/// +/// # Example +/// ```rust,ignore +/// use pulsing_actor::prelude::*; +/// +/// let system = ActorSystem::builder().build().await?; +/// +/// // Get system info +/// println!("Node ID: {}", system.node_id()); +/// println!("Address: {}", system.addr()); +/// +/// // List cluster members +/// for member in system.members().await { +/// println!("Member: {} at {}", member.node_id, member.addr); +/// } +/// +/// // Shutdown +/// system.shutdown().await?; +/// ``` +#[async_trait::async_trait] +pub trait ActorSystemOpsExt { + /// Get SystemActor reference + async fn system(&self) -> anyhow::Result; + + /// Start SystemActor with custom factory (for Python extension) + async fn start_system_actor_with_factory( + &self, + factory: BoxedActorFactory, + ) -> anyhow::Result<()>; + + /// Get node ID + fn node_id(&self) -> &NodeId; + + /// Get local address + fn addr(&self) -> SocketAddr; + + /// Get list of local actor names + fn local_actor_names(&self) -> Vec; + + /// Get a local actor reference by name + fn local_actor_ref_by_name(&self, name: &str) -> Option; + + /// Spawn an anonymous actor (no name, only accessible via ActorRef) + async fn spawn_anonymous(&self, actor: A) -> anyhow::Result + where + A: Actor; + + /// Spawn an anonymous actor with custom options + async fn spawn_anonymous_with_options( + &self, + actor: A, + options: SpawnOptions, + ) -> anyhow::Result + where + A: Actor; + + /// Get load tracker for a node address + fn get_node_load_tracker(&self, addr: &SocketAddr) -> Option>; + + /// Decrement load after a request completes + fn decrement_node_load(&self, addr: &SocketAddr); + + /// Resolve an actor address and get an ActorRef + async fn resolve(&self, address: &crate::actor::ActorAddress) -> anyhow::Result; + + /// Get all instances of a named actor across the cluster + async fn get_named_instances(&self, path: &ActorPath) -> Vec; + + /// Get detailed instances with actor_id and metadata + async fn get_named_instances_detailed( + &self, + path: &ActorPath, + ) -> Vec<(MemberInfo, Option)>; + + /// Get all named actors in the cluster + async fn all_named_actors(&self) -> Vec; + + /// Lookup named actor information + async fn lookup_named(&self, path: &ActorPath) -> Option; + + /// Get cluster member information + async fn members(&self) -> Vec; + + /// Stop an actor by local name + async fn stop(&self, name: impl AsRef + Send) -> anyhow::Result<()>; + + /// Stop an actor with a specific reason + async fn stop_with_reason( + &self, + name: impl AsRef + Send, + reason: crate::actor::StopReason, + ) -> anyhow::Result<()>; + + /// Stop a named actor by path + async fn stop_named(&self, path: &ActorPath) -> anyhow::Result<()>; + + /// Stop a named actor by path with a specific reason + async fn stop_named_with_reason( + &self, + path: &ActorPath, + reason: crate::actor::StopReason, + ) -> anyhow::Result<()>; + + /// Shutdown the entire actor system + async fn shutdown(&self) -> anyhow::Result<()>; + + /// Get cancellation token + fn cancel_token(&self) -> CancellationToken; +} + +// ============================================================================= +// Implementations for Arc +// ============================================================================= + +use super::ActorSystem; + +#[async_trait::async_trait] +impl ActorSystemCoreExt for Arc { + async fn spawn(&self, name: impl AsRef + Send, actor: A) -> anyhow::Result + where + A: Actor, + { + ActorSystem::spawn(self, name, actor).await + } + + async fn spawn_with_options( + &self, + name: impl AsRef + Send, + actor: A, + options: SpawnOptions, + ) -> anyhow::Result + where + A: Actor, + { + ActorSystem::spawn_with_options(self, name, actor, options).await + } + + async fn spawn_named( + &self, + path: P, + local_name: impl AsRef + Send, + actor: A, + ) -> anyhow::Result + where + P: IntoActorPath + Send, + A: Actor, + { + ActorSystem::spawn_named(self, path, local_name, actor).await + } + + async fn spawn_named_with_options( + &self, + path: P, + local_name: impl AsRef + Send, + actor: A, + options: SpawnOptions, + ) -> anyhow::Result + where + P: IntoActorPath + Send, + A: Actor, + { + ActorSystem::spawn_named_with_options(self, path, local_name, actor, options).await + } + + async fn actor_ref(&self, id: &ActorId) -> anyhow::Result { + ActorSystem::actor_ref(self.as_ref(), id).await + } + + async fn resolve_named

(&self, path: P, node_id: Option<&NodeId>) -> anyhow::Result + where + P: IntoActorPath + Send, + { + ActorSystem::resolve_named(self.as_ref(), path, node_id).await + } + + async fn resolve_named_with_options( + &self, + path: &ActorPath, + options: ResolveOptions, + ) -> anyhow::Result { + ActorSystem::resolve_named_with_options(self.as_ref(), path, options).await + } + + fn resolve_named_lazy

(&self, path: P) -> anyhow::Result + where + P: IntoActorPath, + { + ActorSystem::resolve_named_lazy(self, path) + } +} + +#[async_trait::async_trait] +impl ActorSystemAdvancedExt for Arc { + async fn spawn_factory( + &self, + name: impl AsRef + Send, + factory: F, + options: SpawnOptions, + ) -> anyhow::Result + where + F: FnMut() -> anyhow::Result + Send + 'static, + A: Actor, + { + ActorSystem::spawn_factory(self, name, factory, options).await + } + + async fn spawn_named_factory( + &self, + path: P, + local_name: impl AsRef + Send, + factory: F, + options: SpawnOptions, + ) -> anyhow::Result + where + P: IntoActorPath + Send, + F: FnMut() -> anyhow::Result + Send + 'static, + A: Actor, + { + ActorSystem::spawn_named_factory(self, path, local_name, factory, options).await + } +} + +#[async_trait::async_trait] +impl ActorSystemOpsExt for Arc { + async fn system(&self) -> anyhow::Result { + ActorSystem::system(self.as_ref()).await + } + + async fn start_system_actor_with_factory( + &self, + factory: BoxedActorFactory, + ) -> anyhow::Result<()> { + ActorSystem::start_system_actor_with_factory(self, factory).await + } + + fn node_id(&self) -> &NodeId { + ActorSystem::node_id(self.as_ref()) + } + + fn addr(&self) -> SocketAddr { + ActorSystem::addr(self.as_ref()) + } + + fn local_actor_names(&self) -> Vec { + ActorSystem::local_actor_names(self.as_ref()) + } + + fn local_actor_ref_by_name(&self, name: &str) -> Option { + ActorSystem::local_actor_ref_by_name(self.as_ref(), name) + } + + async fn spawn_anonymous(&self, actor: A) -> anyhow::Result + where + A: Actor, + { + ActorSystem::spawn_anonymous(self, actor).await + } + + async fn spawn_anonymous_with_options( + &self, + actor: A, + options: SpawnOptions, + ) -> anyhow::Result + where + A: Actor, + { + ActorSystem::spawn_anonymous_with_options(self, actor, options).await + } + + fn get_node_load_tracker(&self, addr: &SocketAddr) -> Option> { + ActorSystem::get_node_load_tracker(self.as_ref(), addr) + } + + fn decrement_node_load(&self, addr: &SocketAddr) { + ActorSystem::decrement_node_load(self.as_ref(), addr) + } + + async fn resolve(&self, address: &crate::actor::ActorAddress) -> anyhow::Result { + ActorSystem::resolve(self.as_ref(), address).await + } + + async fn get_named_instances(&self, path: &ActorPath) -> Vec { + ActorSystem::get_named_instances(self.as_ref(), path).await + } + + async fn get_named_instances_detailed( + &self, + path: &ActorPath, + ) -> Vec<(MemberInfo, Option)> { + ActorSystem::get_named_instances_detailed(self.as_ref(), path).await + } + + async fn all_named_actors(&self) -> Vec { + ActorSystem::all_named_actors(self.as_ref()).await + } + + async fn lookup_named(&self, path: &ActorPath) -> Option { + ActorSystem::lookup_named(self.as_ref(), path).await + } + + async fn members(&self) -> Vec { + ActorSystem::members(self.as_ref()).await + } + + async fn stop(&self, name: impl AsRef + Send) -> anyhow::Result<()> { + ActorSystem::stop(self.as_ref(), name).await + } + + async fn stop_with_reason( + &self, + name: impl AsRef + Send, + reason: crate::actor::StopReason, + ) -> anyhow::Result<()> { + ActorSystem::stop_with_reason(self.as_ref(), name, reason).await + } + + async fn stop_named(&self, path: &ActorPath) -> anyhow::Result<()> { + ActorSystem::stop_named(self.as_ref(), path).await + } + + async fn stop_named_with_reason( + &self, + path: &ActorPath, + reason: crate::actor::StopReason, + ) -> anyhow::Result<()> { + ActorSystem::stop_named_with_reason(self.as_ref(), path, reason).await + } + + async fn shutdown(&self) -> anyhow::Result<()> { + ActorSystem::shutdown(self.as_ref()).await + } + + fn cancel_token(&self) -> CancellationToken { + ActorSystem::cancel_token(self.as_ref()) + } +} diff --git a/crates/pulsing-py/src/actor.rs b/crates/pulsing-py/src/actor.rs index fe5bd8d90..032332847 100644 --- a/crates/pulsing-py/src/actor.rs +++ b/crates/pulsing-py/src/actor.rs @@ -1254,9 +1254,9 @@ impl PyActorSystem { // Clone PyObjects inside GIL let event_loop = event_loop.clone_ref(py); // Call factory to get instance - let instance = actor - .call0(py) - .map_err(|e| anyhow::anyhow!("Python factory error: {:?}", e))?; + let instance = actor.call0(py).map_err(|e| { + anyhow::anyhow!("Python factory error: {:?}", e) + })?; Ok(PythonActorWrapper::new(instance, event_loop)) }) }; diff --git a/docs/src/api_reference.md b/docs/src/api_reference.md index c3b5d7776..5e85520b0 100644 --- a/docs/src/api_reference.md +++ b/docs/src/api_reference.md @@ -310,6 +310,55 @@ result = ray.get(counter.incr.remote()) ray.shutdown() ``` +## Rust API + +The Rust API is organized into three trait layers (all re-exported in `pulsing_actor::prelude::*`): + +### ActorSystemCoreExt (Primary API) + +Core spawn and resolve operations: + +```rust +// Spawn actors +system.spawn("name", actor).await?; +system.spawn_with_options("name", actor, options).await?; +system.spawn_named("path", "local_name", actor).await?; +system.spawn_named_with_options("path", "local_name", actor, options).await?; + +// Resolve actors +system.actor_ref(&actor_id).await?; +system.resolve_named("path", node_id_opt).await?; +system.resolve_named_with_options(&path, options).await?; +system.resolve_named_lazy("path")?; // Auto-refresh after ~5s +``` + +### ActorSystemAdvancedExt (Supervision/Restart) + +Factory-based spawning for restartable actors: + +```rust +let options = SpawnOptions::new() + .supervision(SupervisionSpec::new() + .restart_policy(RestartPolicy::OnFailure) + .max_restarts(3)); + +system.spawn_factory("name", || Ok(MyActor::new()), options).await?; +system.spawn_named_factory("path", "name", || Ok(MyActor::new()), options).await?; +``` + +### ActorSystemOpsExt (Operations/Diagnostics) + +System info, cluster membership, lifecycle: + +```rust +system.node_id(); +system.addr(); +system.members().await; +system.all_named_actors().await; +system.stop("name").await?; +system.shutdown().await?; +``` + ## Examples See the [Quick Start Guide](quickstart/index.md) for usage examples. diff --git a/docs/src/api_reference.zh.md b/docs/src/api_reference.zh.md index 6b2cc72d3..128b4467a 100644 --- a/docs/src/api_reference.zh.md +++ b/docs/src/api_reference.zh.md @@ -310,6 +310,55 @@ result = ray.get(counter.incr.remote()) ray.shutdown() ``` +## Rust API + +Rust API 通过三层 trait 组织(均在 `pulsing_actor::prelude::*` 中 re-export): + +### ActorSystemCoreExt(主路径) + +核心 spawn 与 resolve 操作: + +```rust +// Spawn actors +system.spawn("name", actor).await?; +system.spawn_with_options("name", actor, options).await?; +system.spawn_named("path", "local_name", actor).await?; +system.spawn_named_with_options("path", "local_name", actor, options).await?; + +// Resolve actors +system.actor_ref(&actor_id).await?; +system.resolve_named("path", node_id_opt).await?; +system.resolve_named_with_options(&path, options).await?; +system.resolve_named_lazy("path")?; // 懒解析,约 5s 后自动刷新 +``` + +### ActorSystemAdvancedExt(高级:监督/重启) + +基于 factory 的 spawn,支持失败重启: + +```rust +let options = SpawnOptions::new() + .supervision(SupervisionSpec::new() + .restart_policy(RestartPolicy::OnFailure) + .max_restarts(3)); + +system.spawn_factory("name", || Ok(MyActor::new()), options).await?; +system.spawn_named_factory("path", "name", || Ok(MyActor::new()), options).await?; +``` + +### ActorSystemOpsExt(运维/诊断) + +系统信息、集群成员、生命周期控制: + +```rust +system.node_id(); +system.addr(); +system.members().await; +system.all_named_actors().await; +system.stop("name").await?; +system.shutdown().await?; +``` + ## 示例 查看[快速开始指南](quickstart/index.zh.md)了解使用示例。 diff --git a/examples/quickstart/chaos_proof.py b/examples/quickstart/chaos_proof.py index 8f62d9801..3cee8a892 100644 --- a/examples/quickstart/chaos_proof.py +++ b/examples/quickstart/chaos_proof.py @@ -2,7 +2,8 @@ 🛡️ Chaos-proof - Actor 崩溃自动重启,任务不丢失 """ -import asyncio, random +import asyncio +import random from pulsing.actor import remote from pulsing.agent import runtime @@ -39,10 +40,10 @@ async def main(): print("\n" + "=" * 50) print("🛡️ Chaos-proof Result") print("=" * 50) - print(f" Total tasks: 50") + print(" Total tasks: 50") print(f" Succeeded: {ok}") print(f" Retries: {retries}") - print(f" Crash rate: 30%") + print(" Crash rate: 30%") print("=" * 50) if ok == 50: print("✅ All succeeded! Actor auto-restarted on crash.") diff --git a/examples/quickstart/function_to_fleet.py b/examples/quickstart/function_to_fleet.py index 9ff6a5b5a..cac61c78a 100644 --- a/examples/quickstart/function_to_fleet.py +++ b/examples/quickstart/function_to_fleet.py @@ -1,4 +1,6 @@ -import asyncio, os, time +import asyncio +import os +import time from pulsing.actor import remote from pulsing.agent import runtime diff --git a/llms.binding.md b/llms.binding.md index a21f9f352..3fea827a0 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -53,7 +53,7 @@ class Counter: # 同步处理函数 def incr(self): ... - + # 异步处理函数 async def desc(self): ... @@ -145,11 +145,11 @@ proxy = Counter.resolve(name) @pul.remote class Counter: def __init__(self, init=0): self.value = init - + # 同步处理函数 def incr(self): ... - + # 异步处理函数 async def desc(self): ... @@ -223,11 +223,11 @@ from pulsing.actor import Actor class EchoActor(Actor): """receive 方法 - 同步或异步均可,框架自动检测""" - + # 方式1:同步方法 def receive(self, msg): return msg - + # 方式2:异步方法(需要 await 时使用) async def receive(self, msg): result = await some_async_operation() @@ -252,20 +252,20 @@ import pulsing as pul class Counter: def __init__(self, init=0): self.value = init - + # 同步方法 - 阻塞处理,请求按顺序执行 # 适合:快速计算、状态修改 def incr(self): self.value += 1 return self.value - + # 异步方法 - 非阻塞,可并发处理多个请求 # 适合:IO 密集型操作(网络请求、数据库查询) async def fetch_and_add(self, url): data = await http_get(url) # 等待期间可处理其他请求 self.value += data return self.value - + # 无返回值方法 - 适合 tell() 调用 def reset(self): self.value = 0 @@ -299,15 +299,15 @@ class MyActor(Actor): def on_start(self, actor_id: ActorId): """Actor 启动时调用""" print(f"Started: {actor_id}") - + def on_stop(self): """Actor 停止时调用""" print("Stopping...") - + def metadata(self) -> dict[str, str]: """返回 Actor 元数据(用于诊断)""" return {"type": "worker", "version": "1.0"} - + async def receive(self, msg): return msg ``` @@ -336,7 +336,7 @@ class StreamingService: async def generate_stream(self, n): for i in range(n): yield f"chunk_{i}" - + # 同步 generator 也支持 def sync_stream(self, n): for i in range(n): @@ -350,4 +350,98 @@ async for chunk in service.generate_stream(10): print(chunk) # chunk_0, chunk_1, ... ``` -**注意:** 对于 `@pul.remote` 类,直接返回 generator(同步或异步)即可,Pulsing 会自动检测并按流式响应处理。 \ No newline at end of file +**注意:** 对于 `@pul.remote` 类,直接返回 generator(同步或异步)即可,Pulsing 会自动检测并按流式响应处理。 + +## Rust 接口 + +Rust API 通过 trait 定义契约,分为三层: + +### 快速入门 + +```rust +use pulsing_actor::prelude::*; + +#[derive(Serialize, Deserialize)] +struct Ping(i32); + +#[derive(Serialize, Deserialize)] +struct Pong(i32); + +struct Echo; + +#[async_trait] +impl Actor for Echo { + async fn receive(&mut self, msg: Message, _ctx: &mut ActorContext) -> anyhow::Result { + let Ping(x) = msg.unpack()?; + Message::pack(&Pong(x)) + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let system = ActorSystem::builder().build().await?; + let actor = system.spawn("echo", Echo).await?; + let Pong(x): Pong = actor.ask(Ping(1)).await?; + system.shutdown().await?; + Ok(()) +} +``` + +### Trait 分层 + +#### ActorSystemCoreExt(主路径,prelude 自动导入) + +核心 spawn 与 resolve 能力: + +```rust +// Spawn +system.spawn(name, actor).await?; +system.spawn_with_options(name, actor, options).await?; +system.spawn_named(path, local_name, actor).await?; +system.spawn_named_with_options(path, local_name, actor, options).await?; + +// Resolve +system.actor_ref(&actor_id).await?; +system.resolve_named(path, node_id_opt).await?; +system.resolve_named_with_options(&path, options).await?; +system.resolve_named_lazy(path)?; // 懒解析,TTL≈5s +``` + +#### ActorSystemAdvancedExt(高级:可重启 supervision) + +Factory 模式 spawn,支持 supervision 重启: + +```rust +// 只有 factory 才能重启 +system.spawn_factory(name, || Ok(MyActor::new()), options).await?; +system.spawn_named_factory(path, local_name, || Ok(MyActor::new()), options).await?; +``` + +#### ActorSystemOpsExt(运维/诊断/生命周期) + +系统信息、集群成员、停止/关闭等: + +```rust +system.node_id(); +system.addr(); +system.members().await; +system.all_named_actors().await; +system.stop(name).await?; +system.shutdown().await?; +``` + +### 关键约定 + +- **消息编码**:`Message::pack(&T)` 使用 bincode + `type_name::()`;跨版本协议建议 `Message::single("TypeV1", bytes)`。 +- **命名解析**: + - `spawn_named`:注册可发现 actor + - `resolve_named`:一次性解析(迁移后可能 stale) + - `resolve_named_lazy`:懒解析 + 自动刷新 +- **流式**:返回 `Message::Stream`,取消语义 best-effort。 +- **监督**:只有 `spawn_factory` / `spawn_named_factory` 支持失败重启。 + +### Behavior(类型安全,Akka Typed 风格) + +- **核心**:`Behavior` + `TypedRef` + `BehaviorAction (Same/Become/Stop)` +- **启动**:`system.spawn_behavior("name", behavior).await? -> TypedRef` +- **约定**:`TypedRef` 要求 `M: Serialize + DeserializeOwned + Send + 'static` diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index 57bfed640..f20a7208b 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -674,7 +674,9 @@ def incr(self): self.value += 1; return self.value if public is None: public = name is not None - return await self.local(_global_system, *args, name=name, public=public, **kwargs) + return await self.local( + _global_system, *args, name=name, public=public, **kwargs + ) async def local( self, diff --git a/python/pulsing/queue/manager.py b/python/pulsing/queue/manager.py index 55672b79b..016f693ef 100644 --- a/python/pulsing/queue/manager.py +++ b/python/pulsing/queue/manager.py @@ -33,11 +33,7 @@ def _compute_owner(bucket_key: str, nodes: list[dict]) -> int | None: # Note: `ActorSystem.members()` (Rust binding) currently returns `status` # (e.g. "Alive"/"Suspect"/"Dead") rather than `state`, while some older # callers used `state`. Support both to stay backward compatible. - alive_nodes = [ - n - for n in nodes - if (n.get("state") or n.get("status")) == "Alive" - ] + alive_nodes = [n for n in nodes if (n.get("state") or n.get("status")) == "Alive"] if not alive_nodes: # If no Alive nodes, fallback to all nodes alive_nodes = nodes diff --git a/ruff.toml b/ruff.toml index d228db22b..9523f0a81 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,4 +3,4 @@ # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or # McCabe complexity (`C901`) by default. select = ["E4", "E7", "E9", "F"] -ignore = ["F401", "E731", "E402"] +ignore = ["F401", "E731", "E402", "F841"] diff --git a/tests/python/test_queue.py b/tests/python/test_queue.py index e9765c5e6..5946f7a63 100644 --- a/tests/python/test_queue.py +++ b/tests/python/test_queue.py @@ -933,9 +933,9 @@ async def test_data_integrity_under_stress(actor_system, temp_storage_path): actual_checksum = hashlib.md5( f"{record_id}:{record['value']}".encode() ).hexdigest() - assert record["checksum"] == expected_checksum, ( - f"Checksum mismatch for {record_id}" - ) + assert ( + record["checksum"] == expected_checksum + ), f"Checksum mismatch for {record_id}" assert actual_checksum == expected_checksum, f"Value corruption for {record_id}" diff --git a/tests/python/test_rendezvous_hash.py b/tests/python/test_rendezvous_hash.py index 489e0fffd..f5e589a00 100644 --- a/tests/python/test_rendezvous_hash.py +++ b/tests/python/test_rendezvous_hash.py @@ -88,9 +88,9 @@ def test_load_distribution(self): # Allow 20% deviation for node_id, count in distribution.items(): ratio = count / expected - assert 0.8 <= ratio <= 1.2, ( - f"Node {node_id} has {count} keys, expected ~{expected}" - ) + assert ( + 0.8 <= ratio <= 1.2 + ), f"Node {node_id} has {count} keys, expected ~{expected}" def test_minimal_migration_on_add_node(self): """Migration ratio should be approximately 1/(N+1) when adding a node""" @@ -115,9 +115,9 @@ def test_minimal_migration_on_add_node(self): # Rendezvous hashing: migration ratio should be close to 1/(N+1) # Allow 50% error margin - assert migration_ratio < expected_ratio * 1.5, ( - f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" - ) + assert ( + migration_ratio < expected_ratio * 1.5 + ), f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" print( f"[Rendezvous] Add node: migration ratio = {migration_ratio:.2%} (expected ~{expected_ratio:.2%})" ) @@ -142,9 +142,9 @@ def test_minimal_migration_on_remove_node(self): migration_ratio = migrated / num_keys expected_ratio = 1 / 6 # Approximately 16.7% - assert migration_ratio < expected_ratio * 1.5, ( - f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" - ) + assert ( + migration_ratio < expected_ratio * 1.5 + ), f"Migration ratio {migration_ratio:.2%} too high, expected ~{expected_ratio:.2%}" print( f"[Rendezvous] Remove node: migration ratio = {migration_ratio:.2%} (expected ~{expected_ratio:.2%})" ) @@ -182,9 +182,9 @@ def test_compare_with_old_algorithm(self): ) # New algorithm should be significantly better than old - assert new_ratio < old_ratio * 0.5, ( - f"New algorithm ({new_ratio:.2%}) should be much better than old ({old_ratio:.2%})" - ) + assert ( + new_ratio < old_ratio * 0.5 + ), f"New algorithm ({new_ratio:.2%}) should be much better than old ({old_ratio:.2%})" def test_only_alive_nodes(self): """Only select nodes in Alive state""" diff --git a/tests/python/test_topic.py b/tests/python/test_topic.py index dab3d83c2..431350611 100644 --- a/tests/python/test_topic.py +++ b/tests/python/test_topic.py @@ -541,9 +541,9 @@ async def test_concurrent_subscribers(actor_system): # All subscribers should receive all messages for i in range(num_subscribers): - assert len(results[i]) == num_messages, ( - f"Subscriber {i} got {len(results[i])} messages, expected {num_messages}" - ) + assert ( + len(results[i]) == num_messages + ), f"Subscriber {i} got {len(results[i])} messages, expected {num_messages}" for reader in readers: await reader.stop() From 25f6f4185ec5e5080c4bee7fe640643b00359c94 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 16:04:09 +0800 Subject: [PATCH 15/24] Refactor Actor System API for improved spawning and resolution - Updated the Actor System API to streamline actor spawning, introducing `spawn_named` for named actors and `spawn_anonymous` for anonymous actors. - Enhanced documentation to clarify the usage of new spawning methods and their implications for actor resolution. - Removed the `public` parameter from `SpawnOptions`, as all named actors are now resolvable by default. - Adjusted existing code and tests to reflect the new API structure, ensuring consistency and backward compatibility. - Improved examples in documentation to demonstrate the updated spawning and resolution patterns. --- crates/pulsing-actor/src/behavior/spawn.rs | 10 +- crates/pulsing-actor/src/system/config.rs | 8 - crates/pulsing-actor/src/system/mod.rs | 113 ++------ crates/pulsing-actor/src/system/traits.rs | 266 +++++++++++------- .../src/system_actor/messages.rs | 4 +- crates/pulsing-actor/src/system_actor/mod.rs | 10 +- crates/pulsing-actor/tests/actor_tests.rs | 55 +++- crates/pulsing-actor/tests/address_tests.rs | 16 +- .../tests/cluster/naming_tests.rs | 75 ++--- crates/pulsing-actor/tests/context_tests.rs | 14 +- .../pulsing-actor/tests/integration_tests.rs | 85 +++--- .../pulsing-actor/tests/multi_node_tests.rs | 71 +++-- .../pulsing-actor/tests/supervision_tests.rs | 4 +- .../pulsing-actor/tests/system_actor_tests.rs | 8 +- .../pulsing-bench/src/actors/coordinator.rs | 8 +- crates/pulsing-bench/src/lib.rs | 2 +- crates/pulsing-py/src/actor.rs | 76 +++-- examples/rust/cluster.rs | 3 +- examples/rust/message_patterns.rs | 2 +- examples/rust/named_actors.rs | 4 +- examples/rust/ping_pong.rs | 2 +- llms.binding.md | 49 ++-- 22 files changed, 453 insertions(+), 432 deletions(-) diff --git a/crates/pulsing-actor/src/behavior/spawn.rs b/crates/pulsing-actor/src/behavior/spawn.rs index 1eb373ede..5724ef08a 100644 --- a/crates/pulsing-actor/src/behavior/spawn.rs +++ b/crates/pulsing-actor/src/behavior/spawn.rs @@ -176,7 +176,9 @@ impl BehaviorSpawner for ActorSystem { let name_str = name.as_ref().to_string(); let actor = BehaviorActor::new(name_str.clone(), self.clone(), behavior); let options = SpawnOptions::new().mailbox_capacity(mailbox_capacity); - let actor_ref = self.spawn_with_options(&name_str, actor, options).await?; + let actor_ref = self + .spawn_named_with_options(name_str.clone(), actor, options) + .await?; Ok(TypedRef::new(&name_str, actor_ref)) } @@ -206,8 +208,10 @@ mod tests { } }); - let counter_ref: TypedRef = - system.spawn_behavior("counter", counter).await.unwrap(); + let counter_ref: TypedRef = system + .spawn_behavior("test/counter", counter) + .await + .unwrap(); // Type-safe message sending counter_ref.tell(TestMsg::Increment(5)).await.unwrap(); diff --git a/crates/pulsing-actor/src/system/config.rs b/crates/pulsing-actor/src/system/config.rs index 026fbea05..94e0152f8 100644 --- a/crates/pulsing-actor/src/system/config.rs +++ b/crates/pulsing-actor/src/system/config.rs @@ -320,8 +320,6 @@ impl From<&&str> for AddrInput { pub struct SpawnOptions { /// Override mailbox capacity (None = use system default) pub mailbox_capacity: Option, - /// Whether this actor is public (can be resolved by name across cluster) - pub public: bool, /// Supervision specification (restart policy) pub supervision: SupervisionSpec, /// Actor metadata (e.g., Python class, module, file path) @@ -340,12 +338,6 @@ impl SpawnOptions { self } - /// Set whether actor is public - pub fn public(mut self, public: bool) -> Self { - self.public = public; - self - } - /// Set supervision specification pub fn supervision(mut self, supervision: SupervisionSpec) -> Self { self.supervision = supervision; diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index 0f6e0779f..70a134ea4 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -33,7 +33,6 @@ use dashmap::DashMap; use handle::LocalActorHandle; use handler::SystemMessageHandler; use runtime::{run_actor_instance, run_supervision_loop}; -use std::collections::HashMap; use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; @@ -304,9 +303,7 @@ impl ActorSystem { let system_actor = SystemActor::with_default_factory(system_ref); // Spawn as named actor with path "system" - let path = ActorPath::new(SYSTEM_ACTOR_PATH)?; - self.spawn_named(path, SYSTEM_ACTOR_LOCAL_NAME, system_actor) - .await?; + self.spawn_named(SYSTEM_ACTOR_PATH, system_actor).await?; tracing::debug!(path = SYSTEM_ACTOR_PATH, "SystemActor started"); Ok(()) @@ -334,9 +331,7 @@ impl ActorSystem { let system_actor = SystemActor::new(system_ref, factory); // Spawn as named actor - let path = ActorPath::new(SYSTEM_ACTOR_PATH)?; - self.spawn_named(path, SYSTEM_ACTOR_LOCAL_NAME, system_actor) - .await?; + self.spawn_named(SYSTEM_ACTOR_PATH, system_actor).await?; tracing::debug!( path = SYSTEM_ACTOR_PATH, @@ -393,37 +388,9 @@ impl ActorSystem { } } - /// Spawn an actor with a local name (uses system default mailbox capacity) - pub async fn spawn( - self: &Arc, - name: impl AsRef, - actor: A, - ) -> anyhow::Result - where - A: Actor, - { - self.spawn_factory(name, Self::once_factory(actor), SpawnOptions::default()) - .await - } - - /// Spawn an actor with custom options - pub async fn spawn_with_options( + /// Spawn an anonymous actor using a factory function (enables supervision restarts) + pub async fn spawn_anonymous_factory( self: &Arc, - name: impl AsRef, - actor: A, - options: SpawnOptions, - ) -> anyhow::Result - where - A: Actor, - { - self.spawn_factory(name, Self::once_factory(actor), options) - .await - } - - /// Spawn an actor using a factory function (enables supervision restarts) - pub async fn spawn_factory( - self: &Arc, - name: impl AsRef, factory: F, options: SpawnOptions, ) -> anyhow::Result @@ -431,13 +398,6 @@ impl ActorSystem { F: FnMut() -> anyhow::Result + Send + 'static, A: Actor, { - let name = name.as_ref(); - - // Check for duplicate - if self.local_actors.contains_key(name) { - return Err(anyhow::anyhow!("Actor already exists: {}", name)); - } - let actor_id = self.next_actor_id(); // Use configured mailbox capacity @@ -448,7 +408,7 @@ impl ActorSystem { let (sender, receiver) = mailbox.split(); let stats = Arc::new(ActorStats::default()); - let metadata = HashMap::new(); + let metadata = options.metadata.clone(); // Create context with system reference for actor_ref/watch/schedule_self let ctx = ActorContext::with_system( @@ -458,7 +418,7 @@ impl ActorSystem { sender.clone(), ); - // Spawn actor loop + // Spawn actor loop with supervision let stats_clone = stats.clone(); let cancel = self.cancel_token.clone(); let actor_id_for_log = actor_id; @@ -468,10 +428,10 @@ impl ActorSystem { let reason = run_supervision_loop(factory, receiver, ctx, cancel, stats_clone, supervision) .await; - tracing::debug!(actor_id = ?actor_id_for_log, reason = ?reason, "Actor stopped"); + tracing::debug!(actor_id = ?actor_id_for_log, reason = ?reason, "Anonymous actor stopped"); }); - // Register actor + // Register actor using actor_id as key let handle = LocalActorHandle { sender: sender.clone(), join_handle, @@ -481,7 +441,7 @@ impl ActorSystem { actor_id, }; - self.local_actors.insert(name.to_string(), handle); + self.local_actors.insert(actor_id.to_string(), handle); // Create ActorRef Ok(ActorRef::local(actor_id, sender)) @@ -553,38 +513,27 @@ impl ActorSystem { Ok(ActorRef::local(actor_id, sender)) } - /// Spawn a named actor (publicly accessible via named path) + /// Spawn a named actor (resolvable by name across the cluster) /// /// # Example /// ```rust,ignore - /// // Path can be &str, String, or ActorPath - /// system.spawn_named("services/echo", "echo", MyActor).await?; + /// // Name is used as both path (for resolution) and local name + /// system.spawn_named("services/echo", MyActor).await?; /// ``` - pub async fn spawn_named( - self: &Arc, - path: P, - local_name: impl AsRef, - actor: A, - ) -> anyhow::Result + pub async fn spawn_named(self: &Arc, name: P, actor: A) -> anyhow::Result where P: IntoActorPath, A: Actor, { - let path = path.into_actor_path()?; - self.spawn_named_factory( - path, - local_name, - Self::once_factory(actor), - SpawnOptions::default(), - ) - .await + let path = name.into_actor_path()?; + self.spawn_named_factory(path, Self::once_factory(actor), SpawnOptions::default()) + .await } /// Spawn a named actor with custom options pub async fn spawn_named_with_options( self: &Arc, - path: P, - local_name: impl AsRef, + name: P, actor: A, options: SpawnOptions, ) -> anyhow::Result @@ -592,16 +541,15 @@ impl ActorSystem { P: IntoActorPath, A: Actor, { - let path = path.into_actor_path()?; - self.spawn_named_factory(path, local_name, Self::once_factory(actor), options) + let path = name.into_actor_path()?; + self.spawn_named_factory(path, Self::once_factory(actor), options) .await } /// Spawn a named actor using a factory function pub async fn spawn_named_factory( self: &Arc, - path: P, - local_name: impl AsRef, + name: P, factory: F, options: SpawnOptions, ) -> anyhow::Result @@ -610,22 +558,19 @@ impl ActorSystem { F: FnMut() -> anyhow::Result + Send + 'static, A: Actor, { - let path = path.into_actor_path()?; - let local_name = local_name.as_ref(); + let path = name.into_actor_path()?; + let name_str = path.as_str(); - // Check for duplicate local name - if self.local_actors.contains_key(local_name) { - return Err(anyhow::anyhow!("Actor already exists: {}", local_name)); + // Check for duplicate name + if self.local_actors.contains_key(&name_str.to_string()) { + return Err(anyhow::anyhow!("Actor already exists: {}", name_str)); } // Check for duplicate named path - if self - .named_actor_paths - .contains_key(&path.as_str().to_string()) - { + if self.named_actor_paths.contains_key(&name_str.to_string()) { return Err(anyhow::anyhow!( "Named path already registered: {}", - path.as_str() + name_str )); } @@ -672,9 +617,9 @@ impl ActorSystem { actor_id, }; - self.local_actors.insert(local_name.to_string(), handle); + self.local_actors.insert(name_str.to_string(), handle); self.named_actor_paths - .insert(path.as_str().to_string(), local_name.to_string()); + .insert(name_str.to_string(), name_str.to_string()); // Register in cluster with full details if let Some(cluster) = self.cluster.read().await.as_ref() { diff --git a/crates/pulsing-actor/src/system/traits.rs b/crates/pulsing-actor/src/system/traits.rs index 84677afbc..aaab5c778 100644 --- a/crates/pulsing-actor/src/system/traits.rs +++ b/crates/pulsing-actor/src/system/traits.rs @@ -5,11 +5,13 @@ //! - [`ActorSystemAdvancedExt`] - Factory-based spawning for supervision/restart //! - [`ActorSystemOpsExt`] - Operations, introspection, and lifecycle management +use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use crate::actor::{Actor, ActorId, ActorPath, ActorRef, IntoActorPath, NodeId}; use crate::cluster::{MemberInfo, NamedActorInfo}; +use crate::supervision::SupervisionSpec; use crate::system_actor::BoxedActorFactory; use super::config::{ResolveOptions, SpawnOptions}; @@ -27,16 +29,15 @@ use tokio_util::sync::CancellationToken; /// It is automatically implemented for `Arc` and re-exported in prelude. /// /// # Spawn Methods -/// - [`spawn`](Self::spawn) - Spawn an actor with a local name -/// - [`spawn_with_options`](Self::spawn_with_options) - Spawn with custom options -/// - [`spawn_named`](Self::spawn_named) - Spawn a publicly discoverable named actor -/// - [`spawn_named_with_options`](Self::spawn_named_with_options) - Spawn named with custom options +/// - [`spawn`](Self::spawn) - Spawn an anonymous actor (not resolvable by name) +/// - [`spawn_named`](Self::spawn_named) - Spawn a named actor (resolvable by name) +/// - [`spawning`](Self::spawning) - Get a builder for advanced spawn options /// /// # Resolve Methods /// - [`actor_ref`](Self::actor_ref) - Get ActorRef by ActorId -/// - [`resolve_named`](Self::resolve_named) - Resolve a named actor by path -/// - [`resolve_named_with_options`](Self::resolve_named_with_options) - Resolve with load balancing/filtering -/// - [`resolve_named_lazy`](Self::resolve_named_lazy) - Lazy resolution with auto-refresh +/// - [`resolve`](Self::resolve) - Resolve a named actor by name +/// - [`resolve_with_options`](Self::resolve_with_options) - Resolve with load balancing/filtering +/// - [`resolve_lazy`](Self::resolve_lazy) - Lazy resolution with auto-refresh /// /// # Example /// ```rust,ignore @@ -44,78 +45,74 @@ use tokio_util::sync::CancellationToken; /// /// let system = ActorSystem::builder().build().await?; /// -/// // Spawn a local actor -/// let actor = system.spawn("my_actor", MyActor::new()).await?; +/// // Spawn an anonymous actor (only accessible via ActorRef) +/// let worker = system.spawn(Worker::new()).await?; /// -/// // Spawn a named actor (discoverable across cluster) -/// let named = system.spawn_named("services/echo", "echo", EchoActor).await?; +/// // Spawn a named actor (resolvable by name) +/// let echo = system.spawn_named("services/echo", EchoService).await?; +/// +/// // Spawn with builder for advanced options +/// let counter = system.spawning() +/// .name("services/counter") +/// .supervision(SupervisionSpec::on_failure().max_restarts(3)) +/// .mailbox_capacity(256) +/// .spawn(Counter::new()) +/// .await?; /// /// // Resolve by name -/// let resolved = system.resolve_named("services/echo", None).await?; +/// let echo_ref = system.resolve("services/echo").await?; /// ``` #[async_trait::async_trait] -pub trait ActorSystemCoreExt { - /// Spawn an actor with a local name (uses system default mailbox capacity) - async fn spawn(&self, name: impl AsRef + Send, actor: A) -> anyhow::Result - where - A: Actor; - - /// Spawn an actor with custom options - async fn spawn_with_options( - &self, - name: impl AsRef + Send, - actor: A, - options: SpawnOptions, - ) -> anyhow::Result +pub trait ActorSystemCoreExt: Sized { + /// Spawn an anonymous actor (not resolvable by name, only accessible via ActorRef) + async fn spawn(&self, actor: A) -> anyhow::Result where A: Actor; - /// Spawn a named actor (publicly accessible via named path) + /// Spawn a named actor (resolvable by name across the cluster) /// - /// Named actors are discoverable across the cluster by their path. + /// Named actors can be discovered and resolved by other nodes using [`resolve`](Self::resolve). /// /// # Arguments - /// - `path` - The public path for discovery (e.g., "services/echo") - /// - `local_name` - The local name for this instance + /// - `name` - The name for discovery (e.g., "services/echo") /// - `actor` - The actor instance - async fn spawn_named( + async fn spawn_named( &self, - path: P, - local_name: impl AsRef + Send, + name: impl AsRef + Send, actor: A, ) -> anyhow::Result where - P: IntoActorPath + Send, A: Actor; - /// Spawn a named actor with custom options - async fn spawn_named_with_options( - &self, - path: P, - local_name: impl AsRef + Send, - actor: A, - options: SpawnOptions, - ) -> anyhow::Result - where - P: IntoActorPath + Send, - A: Actor; + /// Get a builder for spawning actors with advanced options + /// + /// # Example + /// ```rust,ignore + /// let actor = system.spawning() + /// .name("services/worker") + /// .supervision(SupervisionSpec::on_failure().max_restarts(3)) + /// .mailbox_capacity(1024) + /// .spawn(Worker::new()) + /// .await?; + /// ``` + fn spawning(&self) -> SpawnBuilder<'_>; /// Get ActorRef for a local or remote actor by ID async fn actor_ref(&self, id: &ActorId) -> anyhow::Result; - /// Resolve a named actor by path + /// Resolve a named actor by name /// /// Returns an ActorRef that points to the current location of the named actor. /// Note: If the actor migrates, this reference may become stale. - /// For actors that may migrate, consider using [`resolve_named_lazy`](Self::resolve_named_lazy). - async fn resolve_named

(&self, path: P, node_id: Option<&NodeId>) -> anyhow::Result + /// For actors that may migrate, consider using [`resolve_lazy`](Self::resolve_lazy). + async fn resolve

(&self, name: P) -> anyhow::Result where P: IntoActorPath + Send; - /// Resolve a named actor with custom options (load balancing, health filtering) - async fn resolve_named_with_options( + /// Resolve a named actor with custom options (load balancing, node filtering) + async fn resolve_with_options( &self, - path: &ActorPath, + name: &ActorPath, options: ResolveOptions, ) -> anyhow::Result; @@ -123,11 +120,100 @@ pub trait ActorSystemCoreExt { /// /// Returns an ActorRef that automatically re-resolves after ~5 seconds. /// This is useful for named actors that may migrate between nodes. - fn resolve_named_lazy

(&self, path: P) -> anyhow::Result + fn resolve_lazy

(&self, name: P) -> anyhow::Result where P: IntoActorPath; } +// ============================================================================= +// SpawnBuilder: Fluent API for spawning actors +// ============================================================================= + +/// Builder for spawning actors with advanced options. +/// +/// # Example +/// ```rust,ignore +/// // Anonymous actor with supervision +/// let worker = system.spawning() +/// .supervision(SupervisionSpec::on_failure().max_restarts(3)) +/// .spawn(Worker::new()) +/// .await?; +/// +/// // Named actor with full options +/// let service = system.spawning() +/// .name("services/counter") +/// .supervision(SupervisionSpec::on_failure().max_restarts(5)) +/// .mailbox_capacity(512) +/// .spawn(CounterService::new()) +/// .await?; +/// ``` +pub struct SpawnBuilder<'a> { + system: &'a Arc, + name: Option, + options: SpawnOptions, +} + +impl<'a> SpawnBuilder<'a> { + /// Create a new SpawnBuilder + pub(crate) fn new(system: &'a Arc) -> Self { + Self { + system, + name: None, + options: SpawnOptions::default(), + } + } + + /// Set the actor name (makes it resolvable by name) + pub fn name(mut self, name: impl AsRef) -> Self { + self.name = Some(name.as_ref().to_string()); + self + } + + /// Set supervision specification (restart policy) + pub fn supervision(mut self, spec: SupervisionSpec) -> Self { + self.options.supervision = spec; + self + } + + /// Set mailbox capacity + pub fn mailbox_capacity(mut self, capacity: usize) -> Self { + self.options.mailbox_capacity = Some(capacity); + self + } + + /// Set actor metadata + pub fn metadata(mut self, metadata: HashMap) -> Self { + self.options.metadata = metadata; + self + } + + /// Spawn the actor + /// + /// If a name was set, spawns a named actor (resolvable). + /// Otherwise, spawns an anonymous actor (only accessible via ActorRef). + pub async fn spawn(self, actor: A) -> anyhow::Result + where + A: Actor, + { + match self.name { + Some(name) => { + // Named actor: resolvable by name + ActorSystem::spawn_named_with_options( + self.system, + name.as_str(), + actor, + self.options, + ) + .await + } + None => { + // Anonymous actor: not resolvable + ActorSystem::spawn_anonymous_with_options(self.system, actor, self.options).await + } + } + } +} + // ============================================================================= // Advanced Trait: Factory-based Spawning (Supervision/Restart) // ============================================================================= @@ -147,20 +233,22 @@ pub trait ActorSystemCoreExt { /// /// let system = ActorSystem::builder().build().await?; /// -/// // Spawn with factory - enables restart on failure +/// // Spawn anonymous actor with factory - enables restart on failure /// let options = SpawnOptions::new() /// .supervision(SupervisionSpec::new() /// .restart_policy(RestartPolicy::OnFailure) /// .max_restarts(3)); /// -/// let actor = system.spawn_factory("worker", || Ok(Worker::new()), options).await?; +/// let actor = system.spawn_anonymous_factory(|| Ok(Worker::new()), options).await?; +/// +/// // Spawn named actor with factory +/// let named = system.spawn_named_factory("services/worker", || Ok(Worker::new()), options).await?; /// ``` #[async_trait::async_trait] pub trait ActorSystemAdvancedExt { - /// Spawn an actor using a factory function (enables supervision restarts) - async fn spawn_factory( + /// Spawn an anonymous actor using a factory function (enables supervision restarts) + async fn spawn_anonymous_factory( &self, - name: impl AsRef + Send, factory: F, options: SpawnOptions, ) -> anyhow::Result @@ -168,11 +256,10 @@ pub trait ActorSystemAdvancedExt { F: FnMut() -> anyhow::Result + Send + 'static, A: Actor; - /// Spawn a named actor using a factory function + /// Spawn a named actor using a factory function (enables supervision restarts) async fn spawn_named_factory( &self, - path: P, - local_name: impl AsRef + Send, + name: P, factory: F, options: SpawnOptions, ) -> anyhow::Result @@ -311,84 +398,60 @@ use super::ActorSystem; #[async_trait::async_trait] impl ActorSystemCoreExt for Arc { - async fn spawn(&self, name: impl AsRef + Send, actor: A) -> anyhow::Result + async fn spawn(&self, actor: A) -> anyhow::Result where A: Actor, { - ActorSystem::spawn(self, name, actor).await + ActorSystem::spawn_anonymous(self, actor).await } - async fn spawn_with_options( + async fn spawn_named( &self, name: impl AsRef + Send, actor: A, - options: SpawnOptions, ) -> anyhow::Result where A: Actor, { - ActorSystem::spawn_with_options(self, name, actor, options).await + let name = name.as_ref(); + ActorSystem::spawn_named_with_options(self, name, actor, SpawnOptions::default()).await } - async fn spawn_named( - &self, - path: P, - local_name: impl AsRef + Send, - actor: A, - ) -> anyhow::Result - where - P: IntoActorPath + Send, - A: Actor, - { - ActorSystem::spawn_named(self, path, local_name, actor).await - } - - async fn spawn_named_with_options( - &self, - path: P, - local_name: impl AsRef + Send, - actor: A, - options: SpawnOptions, - ) -> anyhow::Result - where - P: IntoActorPath + Send, - A: Actor, - { - ActorSystem::spawn_named_with_options(self, path, local_name, actor, options).await + fn spawning(&self) -> SpawnBuilder<'_> { + SpawnBuilder::new(self) } async fn actor_ref(&self, id: &ActorId) -> anyhow::Result { ActorSystem::actor_ref(self.as_ref(), id).await } - async fn resolve_named

(&self, path: P, node_id: Option<&NodeId>) -> anyhow::Result + async fn resolve

(&self, name: P) -> anyhow::Result where P: IntoActorPath + Send, { - ActorSystem::resolve_named(self.as_ref(), path, node_id).await + ActorSystem::resolve_named(self.as_ref(), name, None).await } - async fn resolve_named_with_options( + async fn resolve_with_options( &self, - path: &ActorPath, + name: &ActorPath, options: ResolveOptions, ) -> anyhow::Result { - ActorSystem::resolve_named_with_options(self.as_ref(), path, options).await + ActorSystem::resolve_named_with_options(self.as_ref(), name, options).await } - fn resolve_named_lazy

(&self, path: P) -> anyhow::Result + fn resolve_lazy

(&self, name: P) -> anyhow::Result where P: IntoActorPath, { - ActorSystem::resolve_named_lazy(self, path) + ActorSystem::resolve_named_lazy(self, name) } } #[async_trait::async_trait] impl ActorSystemAdvancedExt for Arc { - async fn spawn_factory( + async fn spawn_anonymous_factory( &self, - name: impl AsRef + Send, factory: F, options: SpawnOptions, ) -> anyhow::Result @@ -396,13 +459,12 @@ impl ActorSystemAdvancedExt for Arc { F: FnMut() -> anyhow::Result + Send + 'static, A: Actor, { - ActorSystem::spawn_factory(self, name, factory, options).await + ActorSystem::spawn_anonymous_factory(self, factory, options).await } async fn spawn_named_factory( &self, - path: P, - local_name: impl AsRef + Send, + name: P, factory: F, options: SpawnOptions, ) -> anyhow::Result @@ -411,7 +473,7 @@ impl ActorSystemAdvancedExt for Arc { F: FnMut() -> anyhow::Result + Send + 'static, A: Actor, { - ActorSystem::spawn_named_factory(self, path, local_name, factory, options).await + ActorSystem::spawn_named_factory(self, name, factory, options).await } } diff --git a/crates/pulsing-actor/src/system_actor/messages.rs b/crates/pulsing-actor/src/system_actor/messages.rs index 5e75e5df0..39d07fc89 100644 --- a/crates/pulsing-actor/src/system_actor/messages.rs +++ b/crates/pulsing-actor/src/system_actor/messages.rs @@ -144,7 +144,7 @@ pub enum SystemResponse { /// Actor info #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActorInfo { - /// Actor name + /// Actor name (also used as path for resolution) pub name: String, /// Actor ID (local ID) pub actor_id: u64, @@ -152,8 +152,6 @@ pub struct ActorInfo { pub actor_type: String, /// Uptime in seconds pub uptime_secs: u64, - /// Whether public - pub public: bool, /// Actor metadata (e.g., Python class info) #[serde(default)] pub metadata: std::collections::HashMap, diff --git a/crates/pulsing-actor/src/system_actor/mod.rs b/crates/pulsing-actor/src/system_actor/mod.rs index 9d91a268c..a14bda52b 100644 --- a/crates/pulsing-actor/src/system_actor/mod.rs +++ b/crates/pulsing-actor/src/system_actor/mod.rs @@ -85,7 +85,6 @@ struct ActorEntry { actor_id: ActorId, actor_type: String, created_at: Instant, - public: bool, } /// Actor registry @@ -101,14 +100,13 @@ impl ActorRegistry { } } - pub fn register(&self, name: &str, actor_id: ActorId, actor_type: &str, public: bool) { + pub fn register(&self, name: &str, actor_id: ActorId, actor_type: &str) { self.actors.insert( name.to_string(), ActorEntry { actor_id, actor_type: actor_type.to_string(), created_at: Instant::now(), - public, }, ); } @@ -137,7 +135,6 @@ impl ActorRegistry { actor_id: e.actor_id.local_id(), actor_type: e.actor_type.clone(), uptime_secs: e.created_at.elapsed().as_secs(), - public: e.public, metadata: std::collections::HashMap::new(), // TODO: get from actor }) .collect() @@ -149,7 +146,6 @@ impl ActorRegistry { actor_id: e.actor_id.local_id(), actor_type: e.actor_type.clone(), uptime_secs: e.created_at.elapsed().as_secs(), - public: e.public, metadata: std::collections::HashMap::new(), // TODO: get from actor }) } @@ -276,8 +272,8 @@ impl SystemActor { } /// Register a created actor (called externally) - pub fn register_actor(&self, name: &str, actor_id: ActorId, actor_type: &str, public: bool) { - self.registry.register(name, actor_id, actor_type, public); + pub fn register_actor(&self, name: &str, actor_id: ActorId, actor_type: &str) { + self.registry.register(name, actor_id, actor_type); self.metrics.inc_actor_created(); } diff --git a/crates/pulsing-actor/tests/actor_tests.rs b/crates/pulsing-actor/tests/actor_tests.rs index 045de3bd1..254a9f7eb 100644 --- a/crates/pulsing-actor/tests/actor_tests.rs +++ b/crates/pulsing-actor/tests/actor_tests.rs @@ -114,7 +114,10 @@ mod basic_tests { #[tokio::test] async fn test_actor_spawn() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); // ActorId now uses u128 (node_id:local_id), verify it's a local actor assert!(actor_ref.is_local()); let _ = system.shutdown().await; @@ -123,7 +126,10 @@ mod basic_tests { #[tokio::test] async fn test_actor_ask() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); let response: Pong = actor_ref.ask(Ping { value: 5 }).await.unwrap(); assert_eq!(response.result, 5); @@ -137,7 +143,10 @@ mod basic_tests { #[tokio::test] async fn test_actor_tell() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); actor_ref.tell(Ping { value: 5 }).await.unwrap(); tokio::time::sleep(Duration::from_millis(50)).await; @@ -151,7 +160,10 @@ mod basic_tests { #[tokio::test] async fn test_actor_multiple_messages() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); for i in 1..=10 { let response: Pong = actor_ref.ask(Ping { value: i }).await.unwrap(); @@ -177,7 +189,7 @@ mod lifecycle_tests { start_count: start_count.clone(), stop_count: stop_count.clone(), }; - let _actor_ref = system.spawn("lifecycle", actor).await.unwrap(); + let _actor_ref = system.spawn_named("test/lifecycle", actor).await.unwrap(); // Give some time for on_start to be called tokio::time::sleep(Duration::from_millis(50)).await; @@ -199,7 +211,7 @@ mod lifecycle_tests { start_count: start_count.clone(), stop_count: stop_count.clone(), }; - let _actor_ref = system.spawn("lifecycle", actor).await.unwrap(); + let _actor_ref = system.spawn_named("test/lifecycle", actor).await.unwrap(); // Wait for startup tokio::time::sleep(Duration::from_millis(50)).await; @@ -227,7 +239,10 @@ mod lifecycle_tests { start_count: start_count.clone(), stop_count: _stop_count.clone(), }; - let _actor_ref = system.spawn(format!("actor-{}", i), actor).await.unwrap(); + let _actor_ref = system + .spawn_named(format!("test/actor-{}", i), actor) + .await + .unwrap(); } // Wait for all starts @@ -249,7 +264,10 @@ mod error_tests { let config = SystemConfig::standalone(); let system = ActorSystem::new(config).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); let result: Result = actor_ref.ask(ErrorMessage).await; assert!(result.is_err()); @@ -269,7 +287,10 @@ mod error_tests { let config = SystemConfig::standalone(); let system = ActorSystem::new(config).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); // Send message with unknown type using the unified Message let msg = Message::single("UnknownType", vec![]); @@ -288,7 +309,10 @@ mod concurrent_tests { let config = SystemConfig::standalone(); let system = ActorSystem::new(config).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); let mut handles = Vec::new(); for i in 0..10 { @@ -315,7 +339,10 @@ mod concurrent_tests { let config = SystemConfig::standalone(); let system = ActorSystem::new(config).await.unwrap(); - let actor_ref = system.spawn("counter", Counter { count: 0 }).await.unwrap(); + let actor_ref = system + .spawn_named("test/counter", Counter { count: 0 }) + .await + .unwrap(); let start = std::time::Instant::now(); let _response: StateResponse = actor_ref.ask(SlowMessage { delay_ms: 100 }).await.unwrap(); @@ -339,7 +366,7 @@ mod spawn_tests { for i in 0..5 { refs.push( system - .spawn(format!("counter-{}", i), Counter { count: 0 }) + .spawn_named(format!("test/counter-{}", i), Counter { count: 0 }) .await .unwrap(), ); @@ -364,11 +391,11 @@ mod spawn_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); let ref1 = system - .spawn("counter1", Counter { count: 0 }) + .spawn_named("test/counter1", Counter { count: 0 }) .await .unwrap(); let ref2 = system - .spawn("counter2", Counter { count: 0 }) + .spawn_named("test/counter2", Counter { count: 0 }) .await .unwrap(); diff --git a/crates/pulsing-actor/tests/address_tests.rs b/crates/pulsing-actor/tests/address_tests.rs index 3f6bc11a5..9d0eafa9c 100644 --- a/crates/pulsing-actor/tests/address_tests.rs +++ b/crates/pulsing-actor/tests/address_tests.rs @@ -112,11 +112,9 @@ mod integration_tests { let counter = Arc::new(AtomicUsize::new(0)); let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let path = ActorPath::new("services/test/actor").unwrap(); let actor_ref = system .spawn_named( - path.clone(), - "test_actor", + "services/test/actor", IdentityActor { node_name: "local".into(), call_count: counter.clone(), @@ -145,8 +143,8 @@ mod integration_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); let actor_ref = system - .spawn( - "my_actor", + .spawn_named( + "test/my_actor", IdentityActor { node_name: "local".into(), call_count: counter.clone(), @@ -172,13 +170,11 @@ mod integration_tests { #[tokio::test] async fn test_duplicate_path_registration() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let path = ActorPath::new("services/unique").unwrap(); let counter1 = Arc::new(AtomicUsize::new(0)); let result1 = system .spawn_named( - path.clone(), - "actor1", + "services/unique", IdentityActor { node_name: "local".into(), call_count: counter1, @@ -188,10 +184,10 @@ mod integration_tests { assert!(result1.is_ok()); let counter2 = Arc::new(AtomicUsize::new(0)); + // Trying to spawn with the same name should fail let result2 = system .spawn_named( - path.clone(), - "actor2", + "services/unique", IdentityActor { node_name: "local".into(), call_count: counter2, diff --git a/crates/pulsing-actor/tests/cluster/naming_tests.rs b/crates/pulsing-actor/tests/cluster/naming_tests.rs index 2a7627545..0e5a332c6 100644 --- a/crates/pulsing-actor/tests/cluster/naming_tests.rs +++ b/crates/pulsing-actor/tests/cluster/naming_tests.rs @@ -45,10 +45,7 @@ async fn test_naming_backend_register_named_actor() { let path = ActorPath::new("test/actor").unwrap(); // Register a named actor by spawning it - let _ref = system - .spawn_named(path.clone(), "test_actor", TestActor) - .await - .unwrap(); + let _ref = system.spawn_named("test/actor", TestActor).await.unwrap(); // Should be able to lookup let info = system.lookup_named(&path).await; @@ -80,17 +77,8 @@ async fn test_naming_backend_all_named_actors() { let all = system.all_named_actors().await; let initial_count = all.len(); - let path1 = ActorPath::new("test/actor1").unwrap(); - let path2 = ActorPath::new("test/actor2").unwrap(); - - let _ref1 = system - .spawn_named(path1, "actor1", TestActor) - .await - .unwrap(); - let _ref2 = system - .spawn_named(path2, "actor2", TestActor) - .await - .unwrap(); + let _ref1 = system.spawn_named("test/actor1", TestActor).await.unwrap(); + let _ref2 = system.spawn_named("test/actor2", TestActor).await.unwrap(); // Should now have 2 more let all = system.all_named_actors().await; @@ -103,11 +91,8 @@ async fn test_naming_backend_all_named_actors() { async fn test_naming_backend_resolve_named_actor() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); + let _ref = system.spawn_named("test/resolve", TestActor).await.unwrap(); let path = ActorPath::new("test/resolve").unwrap(); - let _ref = system - .spawn_named(path.clone(), "resolve_actor", TestActor) - .await - .unwrap(); // Should be able to resolve let resolved = system.resolve_named(&path, None).await; @@ -120,11 +105,11 @@ async fn test_naming_backend_resolve_named_actor() { async fn test_naming_backend_named_actor_instances() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let path = ActorPath::new("test/instances").unwrap(); let _ref = system - .spawn_named(path.clone(), "instances_actor", TestActor) + .spawn_named("test/instances", TestActor) .await .unwrap(); + let path = ActorPath::new("test/instances").unwrap(); // Get instances let instances = system.get_named_instances(&path).await; @@ -162,13 +147,12 @@ async fn test_gossip_backend_type_downcast() { async fn test_named_actor_full_lifecycle() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let path = ActorPath::new("test/lifecycle").unwrap(); - // 1. Register let _actor_ref = system - .spawn_named(path.clone(), "lifecycle_actor", TestActor) + .spawn_named("test/lifecycle", TestActor) .await .unwrap(); + let path = ActorPath::new("test/lifecycle").unwrap(); // 2. Verify registered let info = system.lookup_named(&path).await; @@ -220,20 +204,18 @@ impl Actor for MetadataActor { async fn test_named_actor_with_metadata() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let path = ActorPath::new("test/metadata").unwrap(); let mut metadata = HashMap::new(); metadata.insert("python_class".to_string(), "MyActor".to_string()); metadata.insert("python_module".to_string(), "my_module".to_string()); let _ref = system - .spawn_named_with_options( - path.clone(), - "metadata_actor", - MetadataActor::new(metadata.clone()), - SpawnOptions::new().public(true).metadata(metadata.clone()), - ) + .spawning() + .name("test/metadata_actor") + .metadata(metadata.clone()) + .spawn(MetadataActor::new(metadata.clone())) .await .unwrap(); + let path = ActorPath::new("test/metadata_actor").unwrap(); // Get detailed instance info let instances = system.get_named_instances_detailed(&path).await; @@ -293,13 +275,9 @@ async fn test_named_actor_instances_nonexistent() { async fn test_multiple_registrations_same_path() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let path = ActorPath::new("test/multi").unwrap(); - // First registration - let _ref1 = system - .spawn_named(path.clone(), "multi_actor", TestActor) - .await - .unwrap(); + let _ref1 = system.spawn_named("test/multi", TestActor).await.unwrap(); + let path = ActorPath::new("test/multi").unwrap(); // Second registration with same path should either: // 1. Replace the first one, or @@ -341,7 +319,7 @@ async fn test_head_node_register_named_actor() { // Register a named actor on head node let _ref = head_system - .spawn_named(path.clone(), "head_actor", TestActor) + .spawn_named("test/head_actor", TestActor) .await .unwrap(); @@ -362,15 +340,12 @@ async fn test_head_node_all_named_actors() { let initial_count = head_system.all_named_actors().await.len(); - let path1 = ActorPath::new("test/head1").unwrap(); - let path2 = ActorPath::new("test/head2").unwrap(); - let _ref1 = head_system - .spawn_named(path1, "head1", TestActor) + .spawn_named("test/head1", TestActor) .await .unwrap(); let _ref2 = head_system - .spawn_named(path2, "head2", TestActor) + .spawn_named("test/head2", TestActor) .await .unwrap(); @@ -387,7 +362,7 @@ async fn test_head_node_resolve_named_actor() { let path = ActorPath::new("test/head_resolve").unwrap(); let _ref = head_system - .spawn_named(path.clone(), "head_resolve", TestActor) + .spawn_named("test/head_resolve", TestActor) .await .unwrap(); @@ -405,7 +380,7 @@ async fn test_head_node_named_actor_instances() { let path = ActorPath::new("test/head_instances").unwrap(); let _ref = head_system - .spawn_named(path.clone(), "head_instances", TestActor) + .spawn_named("test/head_instances", TestActor) .await .unwrap(); @@ -496,7 +471,7 @@ async fn test_head_worker_named_actor_sync() { // Register named actor on worker let path = ActorPath::new("test/worker_actor").unwrap(); let _ref = worker_system - .spawn_named(path.clone(), "worker_actor", TestActor) + .spawn_named("test/worker_actor", TestActor) .await .unwrap(); @@ -537,11 +512,11 @@ async fn test_head_worker_multiple_actors() { let path2 = ActorPath::new("test/multi2").unwrap(); let _ref1 = worker_system - .spawn_named(path1.clone(), "multi1", TestActor) + .spawn_named("test/multi1", TestActor) .await .unwrap(); let _ref2 = worker_system - .spawn_named(path2.clone(), "multi2", TestActor) + .spawn_named("test/multi2", TestActor) .await .unwrap(); @@ -576,7 +551,7 @@ async fn test_head_worker_actor_resolution() { // Register actor on worker let path = ActorPath::new("test/resolve_actor").unwrap(); let _ref = worker_system - .spawn_named(path.clone(), "resolve_actor", TestActor) + .spawn_named("test/resolve_actor", TestActor) .await .unwrap(); @@ -613,7 +588,7 @@ async fn test_head_worker_actor_unregister() { // Register actor on worker let path = ActorPath::new("test/unregister_actor").unwrap(); let _ref = worker_system - .spawn_named(path.clone(), "unregister_actor", TestActor) + .spawn_named("test/unregister_actor", TestActor) .await .unwrap(); diff --git a/crates/pulsing-actor/tests/context_tests.rs b/crates/pulsing-actor/tests/context_tests.rs index b514e16c5..d49178962 100644 --- a/crates/pulsing-actor/tests/context_tests.rs +++ b/crates/pulsing-actor/tests/context_tests.rs @@ -41,10 +41,10 @@ impl Actor for Forwarder { async fn actor_context_can_resolve_actor_ref() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let target_ref = system.spawn("target", Target).await.unwrap(); + let target_ref = system.spawn_named("test/target", Target).await.unwrap(); let forwarder_ref = system - .spawn( - "forwarder", + .spawn_named( + "test/forwarder", Forwarder { target: *target_ref.id(), }, @@ -61,13 +61,13 @@ async fn shutdown_clears_all_actors() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); // Spawn some actors - let _a1 = system.spawn("actor1", Target).await.unwrap(); - let _a2 = system.spawn("actor2", Target).await.unwrap(); + let _a1 = system.spawn_named("test/actor1", Target).await.unwrap(); + let _a2 = system.spawn_named("test/actor2", Target).await.unwrap(); // Verify actors exist let names = system.local_actor_names(); - assert!(names.contains(&"actor1".to_string())); - assert!(names.contains(&"actor2".to_string())); + assert!(names.contains(&"test/actor1".to_string())); + assert!(names.contains(&"test/actor2".to_string())); // Shutdown system.shutdown().await.unwrap(); diff --git a/crates/pulsing-actor/tests/integration_tests.rs b/crates/pulsing-actor/tests/integration_tests.rs index ce4b4c017..3e0a03008 100644 --- a/crates/pulsing-actor/tests/integration_tests.rs +++ b/crates/pulsing-actor/tests/integration_tests.rs @@ -2,6 +2,7 @@ use pulsing_actor::actor::{ActorAddress, ActorPath}; use pulsing_actor::prelude::*; +use pulsing_actor::ActorSystemOpsExt; use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -90,7 +91,7 @@ mod single_node_tests { let actor = EchoActor { echo_count: counter.clone(), }; - let actor_ref = system.spawn("echo", actor).await.unwrap(); + let actor_ref = system.spawn_named("test/echo", actor).await.unwrap(); let response: Pong = actor_ref.ask(Ping { value: 21 }).await.unwrap(); assert_eq!(response.result, 42); @@ -106,7 +107,12 @@ mod single_node_tests { let mut refs = Vec::new(); for i in 0..5 { let actor = Accumulator { total: 0 }; - refs.push(system.spawn(format!("acc-{}", i), actor).await.unwrap()); + refs.push( + system + .spawn_named(format!("test/acc-{}", i), actor) + .await + .unwrap(), + ); } // Send to all actors concurrently @@ -148,7 +154,7 @@ mod single_node_tests { let actor = EchoActor { echo_count: counter.clone(), }; - let actor_ref = system.spawn("echo", actor).await.unwrap(); + let actor_ref = system.spawn_named("test/echo", actor).await.unwrap(); let message_count: usize = 1000; let start = std::time::Instant::now(); @@ -176,11 +182,11 @@ mod single_node_tests { // Create two accumulators let ref1 = system - .spawn("acc1", Accumulator { total: 0 }) + .spawn_named("test/acc1", Accumulator { total: 0 }) .await .unwrap(); let ref2 = system - .spawn("acc2", Accumulator { total: 0 }) + .spawn_named("test/acc2", Accumulator { total: 0 }) .await .unwrap(); @@ -221,7 +227,12 @@ mod stress_tests { for i in 0..actor_count { let actor = Accumulator { total: 0 }; - refs.push(system.spawn(format!("acc-{}", i), actor).await.unwrap()); + refs.push( + system + .spawn_named(format!("test/acc-{}", i), actor) + .await + .unwrap(), + ); } // +1 for SystemActor (_system_internal) @@ -246,7 +257,7 @@ mod stress_tests { let actor = EchoActor { echo_count: counter.clone(), }; - let actor_ref = system.spawn("echo", actor).await.unwrap(); + let actor_ref = system.spawn_named("test/echo", actor).await.unwrap(); let burst_count = 500; @@ -279,7 +290,7 @@ mod stress_tests { let actor = EchoActor { echo_count: counter.clone(), }; - let actor_ref = system.spawn("echo", actor).await.unwrap(); + let actor_ref = system.spawn_named("test/echo", actor).await.unwrap(); let duration = Duration::from_secs(2); let start = std::time::Instant::now(); @@ -341,8 +352,8 @@ mod error_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); let actor_ref = system - .spawn( - "crashy", + .spawn_named( + "test/crashy", Crashy { crash_count: crash_count.clone(), }, @@ -370,8 +381,8 @@ mod error_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); let actor_ref = system - .spawn( - "crashy", + .spawn_named( + "test/crashy", Crashy { crash_count: crash_count.clone(), }, @@ -400,8 +411,8 @@ mod error_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); let actor_ref = system - .spawn( - "echo", + .spawn_named( + "test/echo", EchoActor { echo_count: counter, }, @@ -461,8 +472,8 @@ mod lifecycle_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); let actor_ref = system - .spawn( - "tracker", + .spawn_named( + "test/tracker", LifecycleTracker { events: events.clone(), }, @@ -495,8 +506,8 @@ mod lifecycle_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); let _ = system - .spawn( - "tracker1", + .spawn_named( + "test/tracker1", LifecycleTracker { events: events1.clone(), }, @@ -504,8 +515,8 @@ mod lifecycle_tests { .await .unwrap(); let _ = system - .spawn( - "tracker2", + .spawn_named( + "test/tracker2", LifecycleTracker { events: events2.clone(), }, @@ -537,18 +548,17 @@ mod addressing_tests { let counter = Arc::new(AtomicUsize::new(0)); let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - // Create a named actor with path - let path = ActorPath::new("services/echo").unwrap(); + // Create a named actor let actor_ref = system .spawn_named( - path.clone(), - "echo_impl", + "services/echo", EchoActor { echo_count: counter.clone(), }, ) .await .unwrap(); + let path = ActorPath::new("services/echo").unwrap(); // Send message via the returned ref let response: Pong = actor_ref.ask(Ping { value: 21 }).await.unwrap(); @@ -571,11 +581,9 @@ mod addressing_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); // Create a named actor - let path = ActorPath::new("services/api/handler").unwrap(); let _actor_ref = system .spawn_named( - path.clone(), - "api_handler", + "services/api/handler", EchoActor { echo_count: counter.clone(), }, @@ -585,7 +593,7 @@ mod addressing_tests { // Resolve by address let addr = ActorAddress::parse("actor:///services/api/handler").unwrap(); - let resolved_ref = system.resolve(&addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve(&system, &addr).await.unwrap(); // Send message via resolved ref let response: Pong = resolved_ref.ask(Ping { value: 10 }).await.unwrap(); @@ -601,8 +609,8 @@ mod addressing_tests { // Create a regular actor let actor_ref = system - .spawn( - "worker", + .spawn_named( + "test/worker", EchoActor { echo_count: counter.clone(), }, @@ -614,7 +622,7 @@ mod addressing_tests { let addr = ActorAddress::local(actor_ref.id().local_id()); // Resolve - let resolved_ref = system.resolve(&addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve(&system, &addr).await.unwrap(); let response: Pong = resolved_ref.ask(Ping { value: 5 }).await.unwrap(); assert_eq!(response.result, 10); @@ -628,8 +636,8 @@ mod addressing_tests { // Create actor let actor_ref = system - .spawn( - "local_worker", + .spawn_named( + "test/local_worker", EchoActor { echo_count: counter.clone(), }, @@ -642,7 +650,7 @@ mod addressing_tests { ActorAddress::parse(&format!("actor://0/{}", actor_ref.id().local_id())).unwrap(); assert!(addr.is_local()); - let resolved_ref = system.resolve(&addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve(&system, &addr).await.unwrap(); let response: Pong = resolved_ref.ask(Ping { value: 7 }).await.unwrap(); assert_eq!(response.result, 14); @@ -655,17 +663,16 @@ mod addressing_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); // Create a named actor - let path = ActorPath::new("services/temp").unwrap(); let _actor_ref = system .spawn_named( - path.clone(), - "temp_actor", + "services/temp", EchoActor { echo_count: counter.clone(), }, ) .await .unwrap(); + let path = ActorPath::new("services/temp").unwrap(); // Verify it exists assert!(system.lookup_named(&path).await.is_some()); @@ -730,12 +737,12 @@ mod addressing_tests { // Try to resolve non-existent named actor let addr = ActorAddress::parse("actor:///services/nonexistent").unwrap(); - let result = system.resolve(&addr).await; + let result = ActorSystemOpsExt::resolve(&system, &addr).await; assert!(result.is_err()); // Try to resolve non-existent global actor (use numeric node_id and actor_id) let addr = ActorAddress::parse("actor://999/999").unwrap(); - let result = system.resolve(&addr).await; + let result = ActorSystemOpsExt::resolve(&system, &addr).await; assert!(result.is_err()); system.shutdown().await.unwrap(); diff --git a/crates/pulsing-actor/tests/multi_node_tests.rs b/crates/pulsing-actor/tests/multi_node_tests.rs index 1e075e702..c5ad3e4ea 100644 --- a/crates/pulsing-actor/tests/multi_node_tests.rs +++ b/crates/pulsing-actor/tests/multi_node_tests.rs @@ -2,6 +2,7 @@ use pulsing_actor::actor::{ActorAddress, ActorPath}; use pulsing_actor::prelude::*; +use pulsing_actor::ActorSystemOpsExt; use std::sync::atomic::{AtomicI32, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -117,7 +118,7 @@ mod two_node_tests { let gossip1_addr = system1.addr(); // Spawn actor on node 1 - let actor1_ref = system1.spawn("echo", Echo).await.unwrap(); + let actor1_ref = system1.spawn_named("test/echo", Echo).await.unwrap(); // Verify local actor works let response: Pong = actor1_ref.ask(Ping { value: 21 }).await.unwrap(); @@ -129,7 +130,7 @@ mod two_node_tests { let system2 = ActorSystem::new(config2).await.unwrap(); // Spawn another actor on node 2 - let actor2_ref = system2.spawn("echo2", Echo).await.unwrap(); + let actor2_ref = system2.spawn_named("test/echo2", Echo).await.unwrap(); // Wait for cluster sync tokio::time::sleep(Duration::from_millis(500)).await; @@ -190,21 +191,30 @@ mod multi_node_tests { let system1 = ActorSystem::new(config1).await.unwrap(); let gossip1_addr = system1.addr(); - let _ref1 = system1.spawn("actor-on-node1", Echo).await.unwrap(); + let _ref1 = system1 + .spawn_named("test/actor-on-node1", Echo) + .await + .unwrap(); // Node 2 let mut config2 = create_cluster_config(20042); config2.seed_nodes = vec![gossip1_addr]; let system2 = ActorSystem::new(config2).await.unwrap(); - let _ref2 = system2.spawn("actor-on-node2", Echo).await.unwrap(); + let _ref2 = system2 + .spawn_named("test/actor-on-node2", Echo) + .await + .unwrap(); // Node 3 let mut config3 = create_cluster_config(20043); config3.seed_nodes = vec![gossip1_addr]; let system3 = ActorSystem::new(config3).await.unwrap(); - let _ref3 = system3.spawn("actor-on-node3", Echo).await.unwrap(); + let _ref3 = system3 + .spawn_named("test/actor-on-node3", Echo) + .await + .unwrap(); // Each node has exactly one user actor + SystemActor assert_eq!(system1.local_actor_names().len(), 2); // _system_internal + actor-on-node1 @@ -232,7 +242,7 @@ mod shared_state_tests { let actor = Counter { count: count.clone(), }; - let actor_ref = system.spawn("counter", actor).await.unwrap(); + let actor_ref = system.spawn_named("test/counter", actor).await.unwrap(); // Multiple increments for _ in 0..100 { @@ -254,7 +264,7 @@ mod shared_state_tests { let actor = Counter { count: count.clone(), }; - let actor_ref = system.spawn("counter", actor).await.unwrap(); + let actor_ref = system.spawn_named("test/counter", actor).await.unwrap(); // Concurrent increments let mut handles = Vec::new(); @@ -356,7 +366,7 @@ mod performance_tests { async fn test_message_latency() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let actor_ref = system.spawn("echo", Echo).await.unwrap(); + let actor_ref = system.spawn_named("test/echo", Echo).await.unwrap(); // Warmup for _ in 0..100 { @@ -389,7 +399,7 @@ mod performance_tests { async fn test_throughput_benchmark() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - let actor_ref = system.spawn("echo", Echo).await.unwrap(); + let actor_ref = system.spawn_named("test/echo", Echo).await.unwrap(); // Warmup for _ in 0..100 { @@ -431,11 +441,11 @@ mod edge_case_tests { async fn test_empty_cluster() { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); - // SystemActor (_system_internal) is always present + // SystemActor (system/core) is always present assert_eq!(system.local_actor_names().len(), 1); assert!(system .local_actor_names() - .contains(&"_system_internal".to_string())); + .contains(&"system/core".to_string())); system.shutdown().await.unwrap(); } @@ -451,8 +461,8 @@ mod edge_case_tests { let system2 = ActorSystem::new(config2).await.unwrap(); // Both nodes have actors with same name - let ref1 = system1.spawn("shared-name", Echo).await.unwrap(); - let ref2 = system2.spawn("shared-name", Echo).await.unwrap(); + let ref1 = system1.spawn_named("test/shared-name", Echo).await.unwrap(); + let ref2 = system2.spawn_named("test/shared-name", Echo).await.unwrap(); // They should have different full IDs (different node IDs) assert_ne!(ref1.id().node(), ref2.id().node()); @@ -469,15 +479,18 @@ mod edge_case_tests { let system = ActorSystem::new(SystemConfig::standalone()).await.unwrap(); for i in 0..50 { - let _ref = system.spawn(format!("rapid-{}", i), Echo).await.unwrap(); - system.stop(&format!("rapid-{}", i)).await.unwrap(); + let _ref = system + .spawn_named(format!("test/rapid-{}", i), Echo) + .await + .unwrap(); + system.stop(format!("test/rapid-{}", i)).await.unwrap(); } - // Only SystemActor (_system_internal) should remain + // Only SystemActor (system/core) should remain assert_eq!(system.local_actor_names().len(), 1); assert!(system .local_actor_names() - .contains(&"_system_internal".to_string())); + .contains(&"system/core".to_string())); system.shutdown().await.unwrap(); } @@ -528,11 +541,8 @@ mod addressing_multi_node_tests { tokio::time::sleep(Duration::from_millis(500)).await; // Create named actor on node 1 + let _actor_ref = system1.spawn_named("services/echo", Echo).await.unwrap(); let path = ActorPath::new("services/echo").unwrap(); - let _actor_ref = system1 - .spawn_named(path.clone(), "echo_impl", Echo) - .await - .unwrap(); // Wait for gossip to propagate with retries let path_clone = path.clone(); @@ -576,9 +586,8 @@ mod addressing_multi_node_tests { tokio::time::sleep(Duration::from_millis(500)).await; // Create named actor on node 1 - let path = ActorPath::new("services/api/handler").unwrap(); let _actor_ref = system1 - .spawn_named(path.clone(), "api_handler", Echo) + .spawn_named("services/api/handler", Echo) .await .unwrap(); @@ -586,7 +595,7 @@ mod addressing_multi_node_tests { let addr = ActorAddress::parse("actor:///services/api/handler").unwrap(); let mut resolved_ref = None; for attempt in 1..=15 { - match system2.resolve(&addr).await { + match ActorSystemOpsExt::resolve(&system2, &addr).await { Ok(r) => { resolved_ref = Some(r); break; @@ -629,10 +638,8 @@ mod addressing_multi_node_tests { tokio::time::sleep(Duration::from_millis(500)).await; // Create same named actor on BOTH nodes (multi-instance) - let path = ActorPath::new("services/worker/pool").unwrap(); - let _ref1 = system1 - .spawn_named(path.clone(), "pool_instance_1", Echo) + .spawn_named("services/worker/pool", Echo) .await .unwrap(); @@ -640,9 +647,10 @@ mod addressing_multi_node_tests { tokio::time::sleep(Duration::from_millis(100)).await; let _ref2 = system2 - .spawn_named(path.clone(), "pool_instance_2", Echo) + .spawn_named("services/worker/pool", Echo) .await .unwrap(); + let path = ActorPath::new("services/worker/pool").unwrap(); // Wait for gossip propagation with retries until we see 2 instances for attempt in 1..=20 { @@ -694,13 +702,16 @@ mod addressing_multi_node_tests { tokio::time::sleep(Duration::from_millis(500)).await; // Create regular actor on node 1 - let actor_ref = system1.spawn("remote_worker", Echo).await.unwrap(); + let actor_ref = system1 + .spawn_named("test/remote_worker", Echo) + .await + .unwrap(); // Node 2 resolves using global address with retries let addr = ActorAddress::global(node1_id, actor_ref.id().local_id()); let mut resolved_ref = None; for attempt in 1..=15 { - match system2.resolve(&addr).await { + match ActorSystemOpsExt::resolve(&system2, &addr).await { Ok(r) => { resolved_ref = Some(r); break; diff --git a/crates/pulsing-actor/tests/supervision_tests.rs b/crates/pulsing-actor/tests/supervision_tests.rs index 154f6919f..7c717017f 100644 --- a/crates/pulsing-actor/tests/supervision_tests.rs +++ b/crates/pulsing-actor/tests/supervision_tests.rs @@ -46,7 +46,7 @@ async fn test_restart_on_failure() { let options = SpawnOptions::new().supervision(spec); let actor_ref = system - .spawn_factory("failing", factory, options) + .spawn_named_factory("test/failing", factory, options) .await .unwrap(); @@ -103,7 +103,7 @@ async fn test_max_restarts_exceeded() { let options = SpawnOptions::new().supervision(spec); let actor_ref = system - .spawn_factory("crashing", factory, options) + .spawn_named_factory("test/crashing", factory, options) .await .unwrap(); diff --git a/crates/pulsing-actor/tests/system_actor_tests.rs b/crates/pulsing-actor/tests/system_actor_tests.rs index 9aaf698ff..8bce277b2 100644 --- a/crates/pulsing-actor/tests/system_actor_tests.rs +++ b/crates/pulsing-actor/tests/system_actor_tests.rs @@ -168,7 +168,6 @@ fn test_actor_info_serialization() { actor_id: 123, actor_type: "TestActor".to_string(), uptime_secs: 60, - public: true, metadata: std::collections::HashMap::new(), }; let json = serde_json::to_string(&info).unwrap(); @@ -181,7 +180,6 @@ fn test_actor_info_serialization() { assert_eq!(parsed.actor_id, 123); assert_eq!(parsed.actor_type, "TestActor"); assert_eq!(parsed.uptime_secs, 60); - assert!(parsed.public); } // ============================================================================ @@ -430,7 +428,7 @@ fn test_actor_registry() { let registry = ActorRegistry::new(); let actor_id = ActorId::local(1); - registry.register("test", actor_id, "TestActor", true); + registry.register("test", actor_id, "TestActor"); assert!(registry.contains("test")); assert_eq!(registry.count(), 1); @@ -446,8 +444,8 @@ fn test_actor_registry() { fn test_actor_registry_list_all() { let registry = ActorRegistry::new(); - registry.register("actor1", ActorId::local(1), "TypeA", true); - registry.register("actor2", ActorId::local(2), "TypeB", false); + registry.register("actor1", ActorId::local(1), "TypeA"); + registry.register("actor2", ActorId::local(2), "TypeB"); let actors = registry.list_all(); assert_eq!(actors.len(), 2); diff --git a/crates/pulsing-bench/src/actors/coordinator.rs b/crates/pulsing-bench/src/actors/coordinator.rs index 93c929f6a..573d895b4 100644 --- a/crates/pulsing-bench/src/actors/coordinator.rs +++ b/crates/pulsing-bench/src/actors/coordinator.rs @@ -130,7 +130,7 @@ impl CoordinatorActor { if self.display_enabled { let renderer = ConsoleRendererActor::new(); let renderer_ref = system - .spawn(format!("renderer-{}", start.run_id), renderer) + .spawn_named(format!("renderer-{}", start.run_id), renderer) .await?; self.renderer_ref = Some(renderer_ref); } @@ -141,7 +141,7 @@ impl CoordinatorActor { metrics = metrics.with_renderer(renderer.clone()); } let metrics_ref = system - .spawn(format!("metrics-{}", start.run_id), metrics) + .spawn_named(format!("metrics-{}", start.run_id), metrics) .await?; self.metrics_ref = Some(metrics_ref.clone()); @@ -158,7 +158,7 @@ impl CoordinatorActor { let worker = WorkerActor::new(format!("worker-{}", i)).with_metrics(metrics_ref.clone()); let worker_ref = system - .spawn(format!("worker-{}-{}", start.run_id, i), worker) + .spawn_named(format!("worker-{}-{}", start.run_id, i), worker) .await?; self.workers.push(worker_ref); } @@ -180,7 +180,7 @@ impl CoordinatorActor { start.config.model_name.clone(), ); let scheduler_ref = system - .spawn(format!("scheduler-{}", start.run_id), scheduler) + .spawn_named(format!("scheduler-{}", start.run_id), scheduler) .await?; self.scheduler_ref = Some(scheduler_ref); diff --git a/crates/pulsing-bench/src/lib.rs b/crates/pulsing-bench/src/lib.rs index 241c6e503..2ddd5151c 100644 --- a/crates/pulsing-bench/src/lib.rs +++ b/crates/pulsing-bench/src/lib.rs @@ -186,7 +186,7 @@ pub async fn run_benchmark(args: BenchmarkArgs) -> anyhow::Result { - // Anonymous actors cannot have supervision (no factory to restart from) - let actor_wrapper = PythonActorWrapper::new(actor, event_loop); - system - .spawn_anonymous_with_options(actor_wrapper, options) - .await - .map_err(to_pyerr)? + if matches!(policy, RestartPolicy::Never) { + // actor is the instance + let actor_wrapper = PythonActorWrapper::new(actor, event_loop); + system + .spawn_anonymous_with_options(actor_wrapper, options) + .await + .map_err(to_pyerr)? + } else { + // actor is a factory - anonymous actor with supervision + let factory = move || { + Python::with_gil(|py| -> anyhow::Result { + let event_loop = event_loop.clone_ref(py); + let instance = actor.call0(py).map_err(|e| { + anyhow::anyhow!("Python factory error: {:?}", e) + })?; + Ok(PythonActorWrapper::new(instance, event_loop)) + }) + }; + system + .spawn_anonymous_factory(factory, options) + .await + .map_err(to_pyerr)? + } } - // Named actor + // Named actor (resolvable by name) Some(name) => { if matches!(policy, RestartPolicy::Never) { // actor is the instance let actor_wrapper = PythonActorWrapper::new(actor, event_loop); - if public { - let path = - ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; - system - .spawn_named_with_options(path, &name, actor_wrapper, options) - .await - .map_err(to_pyerr)? - } else { - system - .spawn_with_options(&name, actor_wrapper, options) - .await - .map_err(to_pyerr)? - } + system + .spawn_named_with_options(name, actor_wrapper, options) + .await + .map_err(to_pyerr)? } else { - // actor is a factory + // actor is a factory - named actor with supervision let factory = move || { Python::with_gil(|py| -> anyhow::Result { - // Clone PyObjects inside GIL let event_loop = event_loop.clone_ref(py); - // Call factory to get instance let instance = actor.call0(py).map_err(|e| { anyhow::anyhow!("Python factory error: {:?}", e) })?; Ok(PythonActorWrapper::new(instance, event_loop)) }) }; - - if public { - let path = - ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; - system - .spawn_named_factory(path, &name, factory, options) - .await - .map_err(to_pyerr)? - } else { - system - .spawn_factory(&name, factory, options) - .await - .map_err(to_pyerr)? - } + system + .spawn_named_factory(name, factory, options) + .await + .map_err(to_pyerr)? } } }; diff --git a/examples/rust/cluster.rs b/examples/rust/cluster.rs index 83ae07e65..78d23fdf0 100644 --- a/examples/rust/cluster.rs +++ b/examples/rust/cluster.rs @@ -57,8 +57,7 @@ async fn main() -> anyhow::Result<()> { // Node 1: Create actor and wait system .spawn_named( - path, - "counter", + "services/counter", Counter { count: 0, node_id: system.node_id().to_string(), diff --git a/examples/rust/message_patterns.rs b/examples/rust/message_patterns.rs index 6d75b9f75..7db7c368c 100644 --- a/examples/rust/message_patterns.rs +++ b/examples/rust/message_patterns.rs @@ -58,7 +58,7 @@ async fn main() -> anyhow::Result<()> { println!("=== Message Patterns ===\n"); let system = ActorSystem::builder().build().await?; - let actor = system.spawn("demo", Demo).await?; + let actor = system.spawn_named("test/demo", Demo).await?; // Pattern 1: RPC println!("--- RPC ---"); diff --git a/examples/rust/named_actors.rs b/examples/rust/named_actors.rs index aa613ed75..e3e46c627 100644 --- a/examples/rust/named_actors.rs +++ b/examples/rust/named_actors.rs @@ -21,8 +21,8 @@ impl Actor for Echo { async fn main() -> anyhow::Result<()> { let system = ActorSystem::builder().build().await?; - // Spawn named actor - path can be &str directly - system.spawn_named("services/echo", "echo", Echo).await?; + // Spawn named actor - name is now the full path + system.spawn_named("services/echo", Echo).await?; // Resolve by path string and send message let actor = system.resolve_named("services/echo", None).await?; diff --git a/examples/rust/ping_pong.rs b/examples/rust/ping_pong.rs index 23d5220ef..5e93035e2 100644 --- a/examples/rust/ping_pong.rs +++ b/examples/rust/ping_pong.rs @@ -17,7 +17,7 @@ impl Actor for Echo { #[tokio::main] async fn main() -> anyhow::Result<()> { let system = ActorSystem::builder().build().await?; - let echo = system.spawn("echo", Echo).await?; + let echo = system.spawn_named("test/echo", Echo).await?; // ask: send and wait for response let resp: String = echo.ask("hello".to_string()).await?; diff --git a/llms.binding.md b/llms.binding.md index 3fea827a0..381ae6e7a 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -380,8 +380,14 @@ impl Actor for Echo { #[tokio::main] async fn main() -> anyhow::Result<()> { let system = ActorSystem::builder().build().await?; - let actor = system.spawn("echo", Echo).await?; + + // 命名 actor(可通过 resolve 发现) + let actor = system.spawn_named("echo", Echo).await?; let Pong(x): Pong = actor.ask(Ping(1)).await?; + + // 匿名 actor(仅通过 ActorRef 访问) + let worker = system.spawn(Worker::new()).await?; + system.shutdown().await?; Ok(()) } @@ -394,17 +400,22 @@ async fn main() -> anyhow::Result<()> { 核心 spawn 与 resolve 能力: ```rust -// Spawn -system.spawn(name, actor).await?; -system.spawn_with_options(name, actor, options).await?; -system.spawn_named(path, local_name, actor).await?; -system.spawn_named_with_options(path, local_name, actor, options).await?; +// Spawn - 简洁 API +system.spawn(actor).await?; // 匿名 actor(不可 resolve) +system.spawn_named(name, actor).await?; // 命名 actor(可 resolve) + +// Spawn - Builder 模式(高级配置) +system.spawning() + .name("services/counter") // 可选:有 name = 可 resolve + .supervision(SupervisionSpec::on_failure().max_restarts(3)) + .mailbox_capacity(256) + .spawn(actor).await?; // Resolve -system.actor_ref(&actor_id).await?; -system.resolve_named(path, node_id_opt).await?; -system.resolve_named_with_options(&path, options).await?; -system.resolve_named_lazy(path)?; // 懒解析,TTL≈5s +system.actor_ref(&actor_id).await?; // 按 ActorId 获取 +system.resolve(name).await?; // 按名称解析 +system.resolve_with_options(&path, options).await?; // 带负载均衡选项 +system.resolve_lazy(name)?; // 懒解析,TTL≈5s ``` #### ActorSystemAdvancedExt(高级:可重启 supervision) @@ -412,9 +423,11 @@ system.resolve_named_lazy(path)?; // 懒解析,TTL≈5s Factory 模式 spawn,支持 supervision 重启: ```rust -// 只有 factory 才能重启 -system.spawn_factory(name, || Ok(MyActor::new()), options).await?; -system.spawn_named_factory(path, local_name, || Ok(MyActor::new()), options).await?; +// 匿名 actor + factory(可重启) +system.spawn_anonymous_factory(|| Ok(Worker::new()), options).await?; + +// 命名 actor + factory(可重启 + 可 resolve) +system.spawn_named_factory(name, || Ok(Service::new()), options).await?; ``` #### ActorSystemOpsExt(运维/诊断/生命周期) @@ -433,12 +446,12 @@ system.shutdown().await?; ### 关键约定 - **消息编码**:`Message::pack(&T)` 使用 bincode + `type_name::()`;跨版本协议建议 `Message::single("TypeV1", bytes)`。 -- **命名解析**: - - `spawn_named`:注册可发现 actor - - `resolve_named`:一次性解析(迁移后可能 stale) - - `resolve_named_lazy`:懒解析 + 自动刷新 +- **命名与解析**: + - `spawn_named(name, actor)`:创建可发现 actor,name 即为解析路径 + - `resolve(name)`:一次性解析(迁移后可能 stale) + - `resolve_lazy(name)`:懒解析 + 自动刷新(~5s TTL) - **流式**:返回 `Message::Stream`,取消语义 best-effort。 -- **监督**:只有 `spawn_factory` / `spawn_named_factory` 支持失败重启。 +- **监督**:只有 `spawn_anonymous_factory` / `spawn_named_factory` 支持失败重启。 ### Behavior(类型安全,Akka Typed 风格) From 8c17ccdb50c17eb9f5691319918565f7670fc4b0 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 16:13:17 +0800 Subject: [PATCH 16/24] Enhance actor resolution capabilities in Actor System API - Introduced a new `ResolveBuilder` for advanced actor resolution options, allowing users to specify load balancing policies, target specific nodes, and filter for alive instances. - Added `resolve_all_instances` method to retrieve all instances of a named actor, with optional filtering for alive actors. - Updated documentation in `llms.binding.md` to include new resolution methods and examples in both English and Chinese, improving clarity and usability. - Enhanced existing traits and methods to support the new resolution patterns, ensuring consistency across the API. --- crates/pulsing-actor/src/system/mod.rs | 30 +++++ crates/pulsing-actor/src/system/traits.rs | 132 ++++++++++++++++++++-- llms.binding.md | 17 ++- 3 files changed, 165 insertions(+), 14 deletions(-) diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index 70a134ea4..e84878bea 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -869,6 +869,36 @@ impl ActorSystem { } } + /// Resolve all instances of a named actor as ActorRefs + pub async fn resolve_all_instances( + &self, + path: &ActorPath, + filter_alive: bool, + ) -> anyhow::Result> { + let cluster_guard = self.cluster.read().await; + let cluster = cluster_guard + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Cluster not initialized"))?; + + let instances = cluster.get_named_actor_instances_detailed(path).await; + + let mut refs = Vec::new(); + for (member, instance_opt) in instances { + // Filter by alive status if requested + if filter_alive && member.status != MemberStatus::Alive { + continue; + } + + // Get actor_id from instance info + if let Some(instance) = instance_opt { + let actor_ref = self.actor_ref(&instance.actor_id).await?; + refs.push(actor_ref); + } + } + + Ok(refs) + } + /// Get detailed instances with actor_id and metadata pub async fn get_named_instances_detailed( &self, diff --git a/crates/pulsing-actor/src/system/traits.rs b/crates/pulsing-actor/src/system/traits.rs index aaab5c778..cb800f862 100644 --- a/crates/pulsing-actor/src/system/traits.rs +++ b/crates/pulsing-actor/src/system/traits.rs @@ -16,6 +16,7 @@ use crate::system_actor::BoxedActorFactory; use super::config::{ResolveOptions, SpawnOptions}; use super::NodeLoadTracker; +use crate::policies::LoadBalancingPolicy; use tokio_util::sync::CancellationToken; @@ -116,13 +117,24 @@ pub trait ActorSystemCoreExt: Sized { options: ResolveOptions, ) -> anyhow::Result; - /// Resolve a named actor with lazy resolution (re-resolves after cache expires) + /// Get a builder for resolving actors with advanced options /// - /// Returns an ActorRef that automatically re-resolves after ~5 seconds. - /// This is useful for named actors that may migrate between nodes. - fn resolve_lazy

(&self, name: P) -> anyhow::Result - where - P: IntoActorPath; + /// # Example + /// ```rust,ignore + /// // With load balancing + /// let actor = system.resolving() + /// .policy(RoundRobinPolicy::new()) + /// .resolve("services/worker").await?; + /// + /// // List all instances + /// let actors = system.resolving() + /// .list("services/worker").await?; + /// + /// // Lazy resolve + /// let actor = system.resolving() + /// .lazy("services/worker")?; + /// ``` + fn resolving(&self) -> ResolveBuilder<'_>; } // ============================================================================= @@ -214,6 +226,107 @@ impl<'a> SpawnBuilder<'a> { } } +// ============================================================================= +// ResolveBuilder: Fluent API for resolving actors +// ============================================================================= + +/// Builder for resolving actors with advanced options. +/// +/// # Example +/// ```rust,ignore +/// // Simple resolve +/// let actor = system.resolve("services/counter").await?; +/// +/// // With load balancing policy +/// let actor = system.resolving() +/// .policy(RoundRobinPolicy::new()) +/// .resolve("services/counter").await?; +/// +/// // Get all instances +/// let actors = system.resolving() +/// .list("services/counter").await?; +/// +/// // Lazy resolve (auto re-resolves on stale) +/// let actor = system.resolving() +/// .lazy("services/counter")?; +/// ``` +pub struct ResolveBuilder<'a> { + system: &'a Arc, + node_id: Option, + policy: Option>, + filter_alive: bool, +} + +impl<'a> ResolveBuilder<'a> { + /// Create a new ResolveBuilder + pub(crate) fn new(system: &'a Arc) -> Self { + Self { + system, + node_id: None, + policy: None, + filter_alive: true, + } + } + + /// Target a specific node (bypasses load balancing) + pub fn node(mut self, node_id: NodeId) -> Self { + self.node_id = Some(node_id); + self + } + + /// Set load balancing policy + pub fn policy(mut self, policy: Arc) -> Self { + self.policy = Some(policy); + self + } + + /// Set whether to filter only alive nodes (default: true) + pub fn filter_alive(mut self, filter: bool) -> Self { + self.filter_alive = filter; + self + } + + /// Build ResolveOptions from this builder + fn build_options(&self) -> ResolveOptions { + let mut options = ResolveOptions::new(); + if let Some(node_id) = self.node_id { + options = options.node_id(node_id); + } + if let Some(ref policy) = self.policy { + options = options.policy(policy.clone()); + } + options = options.filter_alive(self.filter_alive); + options + } + + /// Resolve a named actor + pub async fn resolve

(self, name: P) -> anyhow::Result + where + P: IntoActorPath + Send, + { + let path = name.into_actor_path()?; + let options = self.build_options(); + ActorSystem::resolve_named_with_options(self.system, &path, options).await + } + + /// List all instances of a named actor + pub async fn list

(self, name: P) -> anyhow::Result> + where + P: IntoActorPath + Send, + { + let path = name.into_actor_path()?; + ActorSystem::resolve_all_instances(self.system, &path, self.filter_alive).await + } + + /// Lazy resolve - returns ActorRef that auto re-resolves when stale + pub fn lazy

(self, name: P) -> anyhow::Result + where + P: IntoActorPath, + { + ActorSystem::resolve_named_lazy(self.system, name) + } +} + // ============================================================================= // Advanced Trait: Factory-based Spawning (Supervision/Restart) // ============================================================================= @@ -440,11 +553,8 @@ impl ActorSystemCoreExt for Arc { ActorSystem::resolve_named_with_options(self.as_ref(), name, options).await } - fn resolve_lazy

(&self, name: P) -> anyhow::Result - where - P: IntoActorPath, - { - ActorSystem::resolve_named_lazy(self, name) + fn resolving(&self) -> ResolveBuilder<'_> { + ResolveBuilder::new(self) } } diff --git a/llms.binding.md b/llms.binding.md index 381ae6e7a..bed9db282 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -411,11 +411,22 @@ system.spawning() .mailbox_capacity(256) .spawn(actor).await?; -// Resolve +// Resolve - 简单方式 system.actor_ref(&actor_id).await?; // 按 ActorId 获取 system.resolve(name).await?; // 按名称解析 -system.resolve_with_options(&path, options).await?; // 带负载均衡选项 -system.resolve_lazy(name)?; // 懒解析,TTL≈5s + +// Resolve - Builder 模式(高级配置) +system.resolving() + .node(node_id) // 可选:指定目标节点 + .policy(RoundRobinPolicy::new()) // 可选:负载均衡策略 + .filter_alive(true) // 可选:只选存活节点 + .resolve(name).await?; // 解析单个 + +system.resolving() + .list(name).await?; // 获取所有实例 + +system.resolving() + .lazy(name)?; // 懒解析 ``` #### ActorSystemAdvancedExt(高级:可重启 supervision) From f74d733e338eb1e13f9037f18de36b996a123506 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 16:20:41 +0800 Subject: [PATCH 17/24] Refactor actor naming conventions and enhance actor resolution - Updated actor naming logic to ensure names follow the namespace/name format, improving consistency across the API. - Adjusted the Python actor service name to align with the new naming convention. - Enhanced tests to validate the new naming structure and ensure proper registration of system actors. - Updated documentation and test assertions to reflect changes in actor naming and resolution behavior. --- crates/pulsing-py/src/actor.rs | 22 ++++++++++++++++++++-- python/pulsing/actor/remote.py | 15 ++++++++++++--- tests/python/test_actor_list.py | 6 +++--- tests/python/test_system_actor.py | 8 ++++---- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/pulsing-py/src/actor.rs b/crates/pulsing-py/src/actor.rs index ccf62fe36..2b4a8fc01 100644 --- a/crates/pulsing-py/src/actor.rs +++ b/crates/pulsing-py/src/actor.rs @@ -1250,6 +1250,12 @@ impl PyActorSystem { } // Named actor (resolvable by name) Some(name) => { + // Ensure name follows namespace/name format + let name = if name.contains('/') { + name + } else { + format!("actors/{}", name) + }; if matches!(policy, RestartPolicy::Never) { // actor is the instance let actor_wrapper = PythonActorWrapper::new(actor, event_loop); @@ -1327,7 +1333,13 @@ impl PyActorSystem { let system = self.inner.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let path = ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; + // Ensure name follows namespace/name format + let name = if name.contains('/') { + name + } else { + format!("actors/{}", name) + }; + let path = ActorPath::new(name).map_err(to_pyerr)?; let instances = system.get_named_instances_detailed(&path).await; let result: Vec> = instances .into_iter() @@ -1450,7 +1462,13 @@ impl PyActorSystem { let system = self.inner.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let path = ActorPath::new(format!("actors/{}", name)).map_err(to_pyerr)?; + // Ensure name follows namespace/name format + let name = if name.contains('/') { + name + } else { + format!("actors/{}", name) + }; + let path = ActorPath::new(name).map_err(to_pyerr)?; let node = node_id.map(NodeId::new); let actor_ref = system .resolve_named(&path, node.as_ref()) diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index f20a7208b..b26390c22 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -90,7 +90,7 @@ def get_actor_metadata(name: str) -> dict[str, str] | None: # Python actor service name (different from Rust SystemActor "system/core") -PYTHON_ACTOR_SERVICE_NAME = "_python_actor_service" +PYTHON_ACTOR_SERVICE_NAME = "system/python_actor_service" class ActorProxy: @@ -702,7 +702,12 @@ async def local( if public is None: public = name is not None - actor_name = name or f"{self._cls.__name__}_{uuid.uuid4().hex[:8]}" + # Actor name must follow namespace/name format + if name: + # Ensure user-provided name has namespace + actor_name = name if "/" in name else f"actors/{name}" + else: + actor_name = f"actors/{self._cls.__name__}_{uuid.uuid4().hex[:8]}" if self._restart_policy != "never": @@ -773,7 +778,11 @@ async def remote( PYTHON_ACTOR_SERVICE_NAME, node_id=target_id ) - actor_name = name or f"{self._cls.__name__}_{uuid.uuid4().hex[:8]}" + # Actor name must follow namespace/name format + if name: + actor_name = name if "/" in name else f"actors/{name}" + else: + actor_name = f"actors/{self._cls.__name__}_{uuid.uuid4().hex[:8]}" # Send creation request resp = await service_ref.ask( diff --git a/tests/python/test_actor_list.py b/tests/python/test_actor_list.py index 39987ecdf..8c117fad3 100644 --- a/tests/python/test_actor_list.py +++ b/tests/python/test_actor_list.py @@ -54,8 +54,8 @@ async def test_actor_list_basic(): name = path_str[7:] if path_str.startswith("actors/") else path_str - # Skip internal actors - if name.startswith("_"): + # Skip internal actors (old style and new system/ namespace) + if name.startswith("_") or name.startswith("system/"): continue # Get instances for this actor @@ -147,7 +147,7 @@ async def test_actor_list_all(): assert "test-counter" in output # Should also contain system actors - assert "_system_internal" in output or "_python_actor_service" in output + assert "system/core" in output or "system/python_actor_service" in output finally: sys.stdout = old_stdout diff --git a/tests/python/test_system_actor.py b/tests/python/test_system_actor.py index ac983404c..72bf56506 100644 --- a/tests/python/test_system_actor.py +++ b/tests/python/test_system_actor.py @@ -45,14 +45,14 @@ async def system(): async def test_system_actor_auto_registered(system): """SystemActor should be automatically registered on startup.""" actors = system.local_actor_names() - assert "_system_internal" in actors, "SystemActor should be registered" + assert "system/core" in actors, "SystemActor should be registered" @pytest.mark.asyncio async def test_python_actor_service_auto_registered(system): """PythonActorService should be automatically registered on startup.""" actors = system.local_actor_names() - assert "_python_actor_service" in actors, "PythonActorService should be registered" + assert "system/python_actor_service" in actors, "PythonActorService should be registered" # ============================================================================ @@ -265,7 +265,7 @@ async def test_create_actor_not_supported_in_rust(system): @pytest.mark.asyncio async def test_python_actor_service_list_registry(system): """PythonActorService should list registered actor classes.""" - service_ref = await system.resolve_named("_python_actor_service") + service_ref = await system.resolve_named("system/python_actor_service") msg = Message.from_json("ListRegistry", {}) resp = await service_ref.ask(msg) data = resp.to_json() @@ -310,7 +310,7 @@ async def test_remote_local_creation(system): @pytest.mark.asyncio async def test_remote_class_registered(system): """@remote decorated class should be registered in global registry.""" - service_ref = await system.resolve_named("_python_actor_service") + service_ref = await system.resolve_named("system/python_actor_service") msg = Message.from_json("ListRegistry", {}) resp = await service_ref.ask(msg) data = resp.to_json() From a4d0a1a9defd213f265b1d82be090a92f040c4d5 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 16:46:22 +0800 Subject: [PATCH 18/24] Enhance behavior-based actor support and API consistency - Introduced the `IntoActor` trait, allowing seamless integration of `Behavior` with existing actor spawning methods (`spawn` and `spawn_named`). - Updated documentation and examples to reflect the new behavior spawning capabilities, ensuring clarity on usage patterns. - Refactored actor context and system references to support named actors, improving supervision and resolution mechanisms. - Enhanced tests to validate the new behavior functionality and ensure consistent actor interactions across the API. --- crates/pulsing-actor/README.md | 8 +- crates/pulsing-actor/src/actor/context.rs | 38 +++ crates/pulsing-actor/src/actor/mod.rs | 2 +- crates/pulsing-actor/src/actor/traits.rs | 35 +++ crates/pulsing-actor/src/behavior/context.rs | 8 +- crates/pulsing-actor/src/behavior/core.rs | 168 +++++++++++++ crates/pulsing-actor/src/behavior/mod.rs | 10 +- .../pulsing-actor/src/behavior/reference.rs | 13 +- crates/pulsing-actor/src/behavior/spawn.rs | 229 +++--------------- crates/pulsing-actor/src/lib.rs | 2 +- crates/pulsing-actor/src/system/mod.rs | 89 ++----- crates/pulsing-actor/src/system/traits.rs | 60 ++--- crates/pulsing-py/src/actor.rs | 34 +-- docs/src/design/behavior.md | 12 +- docs/src/design/behavior.zh.md | 12 +- examples/rust/behavior_counter.rs | 14 +- examples/rust/behavior_fsm.rs | 7 +- llms.binding.md | 25 +- 18 files changed, 399 insertions(+), 367 deletions(-) diff --git a/crates/pulsing-actor/README.md b/crates/pulsing-actor/README.md index 1f204ba7e..9a9d27f5f 100644 --- a/crates/pulsing-actor/README.md +++ b/crates/pulsing-actor/README.md @@ -97,10 +97,11 @@ let resp: String = remote.ask("hello".to_string()).await?; 除了传统 Actor trait,还提供函数式 Behavior API: ```rust -use pulsing_actor::behavior::{stateful, BehaviorAction, BehaviorSpawner}; +use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction}; +use pulsing_actor::prelude::*; fn counter(initial: i32) -> Behavior { - stateful(initial, |count, n| { + stateful(initial, |count, n, _ctx| { *count += n; println!("count = {}", *count); BehaviorAction::Same @@ -108,7 +109,8 @@ fn counter(initial: i32) -> Behavior { } let system = ActorSystem::builder().build().await?; -let counter_ref = system.spawn_behavior("counter", counter(0)).await?; +// Behavior 实现 IntoActor,可以直接传给 spawn_named +let counter_ref = system.spawn_named("actors/counter", counter(0)).await?; counter_ref.tell(5).await?; // count = 5 counter_ref.tell(3).await?; // count = 8 diff --git a/crates/pulsing-actor/src/actor/context.rs b/crates/pulsing-actor/src/actor/context.rs index 7604e925b..3f0da1994 100644 --- a/crates/pulsing-actor/src/actor/context.rs +++ b/crates/pulsing-actor/src/actor/context.rs @@ -36,6 +36,9 @@ pub struct ActorContext { /// Self mailbox sender for schedule_self self_sender: Option>, + + /// Named path (if this is a named actor) + named_path: Option, } /// Trait for system reference (to avoid circular dependency) @@ -52,6 +55,9 @@ pub trait ActorSystemRef: Send + Sync { /// Stop watching an actor async fn unwatch(&self, watcher: &ActorId, target: &ActorId) -> anyhow::Result<()>; + + /// Get a local actor reference by name (for behavior-based actors) + fn local_actor_ref_by_name(&self, name: &str) -> Option; } impl ActorContext { @@ -64,6 +70,7 @@ impl ActorContext { actor_refs: HashMap::new(), system: None, self_sender: None, + named_path: None, } } @@ -82,9 +89,40 @@ impl ActorContext { actor_refs: HashMap::new(), system: Some(system), self_sender: Some(self_sender), + named_path: None, + } + } + + /// Create context with system reference and named path + pub fn with_system_and_name( + actor_id: ActorId, + system: Arc, + cancel_token: CancellationToken, + self_sender: mpsc::Sender, + named_path: Option, + ) -> Self { + let node_id = Some(system.node_id()); + Self { + actor_id, + node_id, + cancel_token, + actor_refs: HashMap::new(), + system: Some(system), + self_sender: Some(self_sender), + named_path, } } + /// Get the named path (if this is a named actor) + pub fn named_path(&self) -> Option<&str> { + self.named_path.as_deref() + } + + /// Get a reference to the actor system (if available) + pub fn system(&self) -> Option> { + self.system.clone() + } + /// Get the actor's ID pub fn id(&self) -> &ActorId { &self.actor_id diff --git a/crates/pulsing-actor/src/actor/mod.rs b/crates/pulsing-actor/src/actor/mod.rs index c46917d70..b1efe093f 100644 --- a/crates/pulsing-actor/src/actor/mod.rs +++ b/crates/pulsing-actor/src/actor/mod.rs @@ -12,4 +12,4 @@ pub use mailbox::{Envelope, Mailbox, MailboxSender, ResponseChannel, DEFAULT_MAI pub use reference::{ ActorRef, ActorRefInner, ActorResolver, LazyActorRef, RemoteActorRef, RemoteTransport, }; -pub use traits::{Actor, ActorId, Message, MessageStream, NodeId, StopReason}; +pub use traits::{Actor, ActorId, IntoActor, Message, MessageStream, NodeId, StopReason}; diff --git a/crates/pulsing-actor/src/actor/traits.rs b/crates/pulsing-actor/src/actor/traits.rs index aeaab227c..b26d1d12f 100644 --- a/crates/pulsing-actor/src/actor/traits.rs +++ b/crates/pulsing-actor/src/actor/traits.rs @@ -302,6 +302,41 @@ pub trait Actor: Send + Sync + 'static { } } +/// Trait for types that can be converted into an Actor +/// +/// This trait enables a uniform API for spawning both regular actors +/// and behavior-based actors using the same `spawn` and `spawn_named` methods. +/// +/// # Example +/// +/// ```rust,ignore +/// // Regular actor - implements Actor directly +/// struct MyActor; +/// impl Actor for MyActor { ... } +/// system.spawn(MyActor).await?; +/// +/// // Behavior - implements IntoActor via BehaviorWrapper +/// fn counter(init: i32) -> Behavior { ... } +/// system.spawn(counter(0)).await?; // Automatically wrapped +/// system.spawn_named("counter", counter(0)).await?; +/// ``` +pub trait IntoActor: Send + 'static { + /// The actor type produced by this conversion + type Actor: Actor; + + /// Convert self into an Actor + fn into_actor(self) -> Self::Actor; +} + +/// Blanket implementation: any Actor can be converted to itself +impl IntoActor for A { + type Actor = A; + + fn into_actor(self) -> Self::Actor { + self + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pulsing-actor/src/behavior/context.rs b/crates/pulsing-actor/src/behavior/context.rs index 65f440383..802f98c5c 100644 --- a/crates/pulsing-actor/src/behavior/context.rs +++ b/crates/pulsing-actor/src/behavior/context.rs @@ -1,8 +1,8 @@ //! Typed actor context for behavior-based actors use super::reference::TypedRef; +use crate::actor::ActorSystemRef; use crate::actor::ActorId; -use crate::system::ActorSystem; use serde::{de::DeserializeOwned, Serialize}; use std::marker::PhantomData; use std::sync::Arc; @@ -19,7 +19,7 @@ pub struct BehaviorContext { /// Actor's unique identifier actor_id: ActorId, /// Reference to the actor system - system: Arc, + system: Arc, /// Typed self-reference for receiving messages self_ref: TypedRef, /// Cancellation token for graceful shutdown @@ -35,7 +35,7 @@ where pub(crate) fn new( actor_name: String, actor_id: ActorId, - system: Arc, + system: Arc, self_ref: TypedRef, cancel_token: CancellationToken, ) -> Self { @@ -120,7 +120,7 @@ where } /// Get a reference to the underlying actor system - pub fn system(&self) -> &Arc { + pub fn system(&self) -> &Arc { &self.system } diff --git a/crates/pulsing-actor/src/behavior/core.rs b/crates/pulsing-actor/src/behavior/core.rs index 880f49f86..39feda4a7 100644 --- a/crates/pulsing-actor/src/behavior/core.rs +++ b/crates/pulsing-actor/src/behavior/core.rs @@ -1,8 +1,15 @@ //! Behavior definitions and combinators use super::context::BehaviorContext; +use super::reference::TypedRef; +use crate::actor::ActorSystemRef; +use crate::actor::{Actor, ActorContext, IntoActor, Message}; +use async_trait::async_trait; use futures::future::BoxFuture; +use serde::{de::DeserializeOwned, Serialize}; use std::marker::PhantomData; +use std::sync::Arc; +use tokio::sync::Mutex; /// Action returned by a behavior after processing a message pub enum BehaviorAction { @@ -68,6 +75,167 @@ where } } +/// IntoActor implementation for Behavior +/// +/// This allows Behavior to be passed directly to `spawn` and `spawn_named`: +/// ```rust,ignore +/// let counter = system.spawn(counter(0)).await?; +/// let counter = system.spawn_named("counter", counter(0)).await?; +/// ``` +impl IntoActor for Behavior +where + M: Serialize + DeserializeOwned + Send + Sync + 'static, +{ + type Actor = BehaviorWrapper; + + fn into_actor(self) -> Self::Actor { + BehaviorWrapper::new(self) + } +} + +/// A wrapper that allows Behavior to be used as an Actor +/// +/// This wrapper implements the Actor trait, allowing behaviors to be spawned +/// using the standard `system.spawn()` and `system.spawn_named()` methods. +/// +/// # Example +/// +/// ```rust,ignore +/// fn counter(init: i32) -> Behavior { +/// stateful(init, |count, n, _ctx| { +/// *count += n; +/// BehaviorAction::Same +/// }) +/// } +/// +/// // Use as Actor via IntoActor trait +/// let counter = system.spawn(counter(0)).await?; +/// let counter = system.spawn_named("counter", counter(0)).await?; +/// ``` +pub struct BehaviorWrapper +where + M: Serialize + DeserializeOwned + Send + 'static, +{ + behavior: Mutex>, + behavior_ctx: Mutex>>, + name: Mutex>, +} + +impl BehaviorWrapper +where + M: Serialize + DeserializeOwned + Send + 'static, +{ + /// Create a new BehaviorWrapper from a Behavior + pub fn new(behavior: Behavior) -> Self { + Self { + behavior: Mutex::new(behavior), + behavior_ctx: Mutex::new(None), + name: Mutex::new(None), + } + } +} + +impl From> for BehaviorWrapper +where + M: Serialize + DeserializeOwned + Send + 'static, +{ + fn from(behavior: Behavior) -> Self { + Self::new(behavior) + } +} + +#[async_trait] +impl Actor for BehaviorWrapper +where + M: Serialize + DeserializeOwned + Send + Sync + 'static, +{ + async fn receive(&mut self, msg: Message, _ctx: &mut ActorContext) -> anyhow::Result { + // Deserialize the incoming message + let typed_msg: M = msg.unpack()?; + + // Get mutable access to behavior and context + let mut behavior = self.behavior.lock().await; + let mut ctx_guard = self.behavior_ctx.lock().await; + + let ctx = ctx_guard + .as_mut() + .ok_or_else(|| anyhow::anyhow!("BehaviorContext not initialized"))?; + + // Process the message + let action = behavior.receive(typed_msg, ctx).await; + + // Handle the action + match action { + BehaviorAction::Same => Message::pack(&()), + BehaviorAction::Become(new_behavior) => { + *behavior = new_behavior; + Message::pack(&()) + } + BehaviorAction::Stop(reason) => { + let actor_name = self.name.lock().await; + let name = actor_name.as_deref().unwrap_or("unknown"); + if let Some(ref r) = reason { + tracing::info!(actor = %name, reason = %r, "Behavior actor stopping"); + } else { + tracing::info!(actor = %name, "Behavior actor stopping"); + } + + drop(behavior); + drop(ctx_guard); + + _ctx.cancel_token().cancel(); + + Err(anyhow::anyhow!( + "Actor stopped: {}", + reason.unwrap_or_default() + )) + } + BehaviorAction::AlreadyStopped => { + let actor_name = self.name.lock().await; + let name = actor_name.as_deref().unwrap_or("unknown"); + tracing::warn!(actor = %name, "Message received after actor stopped"); + Err(anyhow::anyhow!("Actor already stopped")) + } + } + } + + async fn on_start(&mut self, ctx: &mut ActorContext) -> anyhow::Result<()> { + // Get or derive the actor name + let actor_name = ctx + .named_path() + .map(String::from) + .unwrap_or_else(|| format!("behavior-{}", ctx.id())); + + // Store name for logging + *self.name.lock().await = Some(actor_name.clone()); + + // We need a system reference - get it from the context + // Note: This requires ActorContext to provide system access + let system: Arc = ctx + .system() + .ok_or_else(|| anyhow::anyhow!("No system reference available in context"))?; + + // Initialize the behavior context + let actor_id = *ctx.id(); + + // Create typed self reference + let self_ref = TypedRef::from_name(&actor_name, system.clone()); + + let behavior_ctx = BehaviorContext::new( + actor_name, + actor_id, + system, + self_ref, + ctx.cancel_token().clone(), + ); + + let mut ctx_guard = self.behavior_ctx.lock().await; + *ctx_guard = Some(behavior_ctx); + + Ok(()) + } +} + // Behavior cannot Clone because it contains mutable state // But new instances can be created via factory functions diff --git a/crates/pulsing-actor/src/behavior/mod.rs b/crates/pulsing-actor/src/behavior/mod.rs index f96ea35ac..08d859c41 100644 --- a/crates/pulsing-actor/src/behavior/mod.rs +++ b/crates/pulsing-actor/src/behavior/mod.rs @@ -14,6 +14,7 @@ //! //! ```rust,ignore //! use pulsing_actor::behavior::*; +//! use pulsing_actor::prelude::*; //! //! // Define message type //! #[derive(Serialize, Deserialize)] @@ -40,9 +41,9 @@ //! }) //! } //! -//! // Spawn and use -//! let counter_ref: TypedRef = system.spawn_behavior("counter", counter(0)).await?; -//! counter_ref.tell(CounterMsg::Increment(5)).await?; +//! // Spawn using standard spawn/spawn_named - Behavior implements IntoActor +//! let actor_ref = system.spawn_named("actors/counter", counter(0)).await?; +//! actor_ref.tell(CounterMsg::Increment(5)).await?; //! ``` mod context; @@ -51,6 +52,5 @@ mod reference; mod spawn; pub use context::BehaviorContext; -pub use core::{stateful, stateless, Behavior, BehaviorAction, BehaviorFn}; +pub use core::{stateful, stateless, Behavior, BehaviorAction, BehaviorFn, BehaviorWrapper}; pub use reference::TypedRef; -pub use spawn::BehaviorSpawner; diff --git a/crates/pulsing-actor/src/behavior/reference.rs b/crates/pulsing-actor/src/behavior/reference.rs index d60f39564..9d1755cc5 100644 --- a/crates/pulsing-actor/src/behavior/reference.rs +++ b/crates/pulsing-actor/src/behavior/reference.rs @@ -1,7 +1,7 @@ //! Typed actor references use crate::actor::ActorRef; -use crate::system::ActorSystem; +use crate::actor::ActorSystemRef; use serde::{de::DeserializeOwned, Serialize}; use std::marker::PhantomData; use std::sync::Arc; @@ -13,7 +13,7 @@ enum ResolutionMode { /// Direct reference - always use this ActorRef Direct(ActorRef), /// Dynamic resolution - resolve by name each time (no caching) - Dynamic(Arc), + Dynamic(Arc), } /// A type-safe actor reference @@ -29,10 +29,11 @@ enum ResolutionMode { /// # Example /// /// ```rust,ignore -/// // Type-safe: only CounterMsg can be sent -/// let counter: TypedRef = system.spawn_behavior("counter", counter_behavior).await?; +/// // Spawn behavior directly (Behavior implements IntoActor) +/// let counter = system.spawn_named("actors/counter", counter_behavior).await?; /// -/// // Compile-time error if wrong message type +/// // Or wrap with TypedRef for type-safe sending +/// let counter: TypedRef = TypedRef::new("actors/counter", counter); /// counter.tell(CounterMsg::Increment(5)).await?; /// ``` pub struct TypedRef { @@ -86,7 +87,7 @@ where /// /// The actor is resolved by name on each operation, ensuring /// the reference is always up-to-date (no stale cache). - pub(crate) fn from_name(name: &str, system: Arc) -> Self { + pub(crate) fn from_name(name: &str, system: Arc) -> Self { Self { name: name.to_string(), mode: ResolutionMode::Dynamic(system), diff --git a/crates/pulsing-actor/src/behavior/spawn.rs b/crates/pulsing-actor/src/behavior/spawn.rs index 5724ef08a..68533ee30 100644 --- a/crates/pulsing-actor/src/behavior/spawn.rs +++ b/crates/pulsing-actor/src/behavior/spawn.rs @@ -1,194 +1,14 @@ -//! Spawn behavior-based actors into the ActorSystem - -use super::context::BehaviorContext; -use super::core::{Behavior, BehaviorAction}; -use super::reference::TypedRef; -use crate::actor::{Actor, ActorContext, Message}; -use crate::system::{ActorSystem, SpawnOptions}; -use async_trait::async_trait; -use serde::{de::DeserializeOwned, Serialize}; -use std::sync::Arc; -use tokio::sync::Mutex; - -/// Wrapper that bridges Behavior to the traditional Actor trait -struct BehaviorActor -where - M: Serialize + DeserializeOwned + Send + 'static, -{ - /// Actor name (for context initialization) - name: String, - /// Reference to the actor system - system: Arc, - behavior: Mutex>, - behavior_ctx: Mutex>>, -} - -impl BehaviorActor -where - M: Serialize + DeserializeOwned + Send + 'static, -{ - fn new(name: String, system: Arc, behavior: Behavior) -> Self { - Self { - name, - system, - behavior: Mutex::new(behavior), - behavior_ctx: Mutex::new(None), - } - } -} - -#[async_trait] -impl Actor for BehaviorActor -where - M: Serialize + DeserializeOwned + Send + Sync + 'static, -{ - async fn receive(&mut self, msg: Message, _ctx: &mut ActorContext) -> anyhow::Result { - // Deserialize the incoming message - let typed_msg: M = msg.unpack()?; - - // Get mutable access to behavior and context - let mut behavior = self.behavior.lock().await; - let mut ctx_guard = self.behavior_ctx.lock().await; - - let ctx = ctx_guard - .as_mut() - .ok_or_else(|| anyhow::anyhow!("BehaviorContext not initialized"))?; - - // Process the message - let action = behavior.receive(typed_msg, ctx).await; - - // Handle the action - match action { - BehaviorAction::Same => { - // Return empty response for tell, actual response for ask - Message::pack(&()) - } - BehaviorAction::Become(new_behavior) => { - *behavior = new_behavior; - Message::pack(&()) - } - BehaviorAction::Stop(reason) => { - // Log stop reason for observability - if let Some(ref r) = reason { - tracing::info!(actor = %self.name, reason = %r, "Behavior actor stopping"); - } else { - tracing::info!(actor = %self.name, "Behavior actor stopping"); - } - - drop(behavior); - drop(ctx_guard); - - // Trigger graceful stop via cancel token - _ctx.cancel_token().cancel(); - - // Return an error to signal the caller that actor has stopped - // This allows ask() callers to distinguish stop from normal response - Err(anyhow::anyhow!( - "Actor stopped: {}", - reason.unwrap_or_default() - )) - } - BehaviorAction::AlreadyStopped => { - // Actor was already stopped, reject new messages - tracing::warn!(actor = %self.name, "Message received after actor stopped"); - Err(anyhow::anyhow!("Actor already stopped")) - } - } - } - - async fn on_start(&mut self, ctx: &mut ActorContext) -> anyhow::Result<()> { - // Initialize the behavior context - let actor_id = *ctx.id(); - - // Create typed self reference - let self_ref = TypedRef::from_name(&self.name, self.system.clone()); - - let behavior_ctx = BehaviorContext::new( - self.name.clone(), - actor_id, - self.system.clone(), - self_ref, - ctx.cancel_token().clone(), - ); - - let mut ctx_guard = self.behavior_ctx.lock().await; - *ctx_guard = Some(behavior_ctx); - - Ok(()) - } -} - -/// Extension trait for spawning behavior-based actors -#[async_trait] -pub trait BehaviorSpawner { - /// Spawn a behavior-based actor - /// - /// # Example - /// - /// ```rust,ignore - /// use pulsing_actor::behavior::*; - /// - /// let counter_ref: TypedRef = system - /// .spawn_behavior("counter", counter(0)) - /// .await?; - /// ``` - async fn spawn_behavior( - self: &Arc, - name: impl AsRef + Send, - behavior: Behavior, - ) -> anyhow::Result> - where - M: Serialize + DeserializeOwned + Send + Sync + 'static; - - /// Spawn a behavior-based actor with custom mailbox capacity - async fn spawn_behavior_with_capacity( - self: &Arc, - name: impl AsRef + Send, - behavior: Behavior, - mailbox_capacity: usize, - ) -> anyhow::Result> - where - M: Serialize + DeserializeOwned + Send + Sync + 'static; -} - -#[async_trait] -impl BehaviorSpawner for ActorSystem { - async fn spawn_behavior( - self: &Arc, - name: impl AsRef + Send, - behavior: Behavior, - ) -> anyhow::Result> - where - M: Serialize + DeserializeOwned + Send + Sync + 'static, - { - self.spawn_behavior_with_capacity(name, behavior, 256).await - } - - async fn spawn_behavior_with_capacity( - self: &Arc, - name: impl AsRef + Send, - behavior: Behavior, - mailbox_capacity: usize, - ) -> anyhow::Result> - where - M: Serialize + DeserializeOwned + Send + Sync + 'static, - { - let name_str = name.as_ref().to_string(); - let actor = BehaviorActor::new(name_str.clone(), self.clone(), behavior); - let options = SpawnOptions::new().mailbox_capacity(mailbox_capacity); - let actor_ref = self - .spawn_named_with_options(name_str.clone(), actor, options) - .await?; - - Ok(TypedRef::new(&name_str, actor_ref)) - } -} +//! Tests for spawning behavior-based actors +//! +//! With `IntoActor`, `Behavior` can be directly passed to `spawn()` and `spawn_named()`. +//! The actual `BehaviorWrapper` that bridges Behavior to Actor trait is in `core.rs`. #[cfg(test)] mod tests { - use super::*; use crate::behavior::{stateful, BehaviorAction}; - use crate::system::SystemConfig; + use crate::system::{ActorSystem, ActorSystemCoreExt, SystemConfig}; + use serde::Serialize; + use std::sync::Arc; #[derive(Debug, Clone, Serialize, serde::Deserialize)] enum TestMsg { @@ -197,7 +17,29 @@ mod tests { } #[tokio::test] - async fn test_spawn_behavior() { + async fn test_spawn_behavior_with_into_actor() { + let system = Arc::new(ActorSystem::new(SystemConfig::standalone()).await.unwrap()); + + let counter = stateful(0i32, |count, msg, _ctx| match msg { + TestMsg::Ping => BehaviorAction::Same, + TestMsg::Increment(n) => { + *count += n; + BehaviorAction::Same + } + }); + + // Use spawn_named with Behavior directly (via IntoActor) + let actor_ref = system.spawn_named("test/counter", counter).await.unwrap(); + + // Send messages via ActorRef + actor_ref.tell(TestMsg::Increment(5)).await.unwrap(); + actor_ref.tell(TestMsg::Ping).await.unwrap(); + + system.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn test_spawn_anonymous_behavior() { let system = Arc::new(ActorSystem::new(SystemConfig::standalone()).await.unwrap()); let counter = stateful(0i32, |count, msg, _ctx| match msg { @@ -208,14 +50,11 @@ mod tests { } }); - let counter_ref: TypedRef = system - .spawn_behavior("test/counter", counter) - .await - .unwrap(); + // Use spawn with Behavior directly (via IntoActor) + let actor_ref = system.spawn(counter).await.unwrap(); - // Type-safe message sending - counter_ref.tell(TestMsg::Increment(5)).await.unwrap(); - counter_ref.tell(TestMsg::Ping).await.unwrap(); + // Send messages via ActorRef + actor_ref.tell(TestMsg::Increment(10)).await.unwrap(); system.shutdown().await.unwrap(); } diff --git a/crates/pulsing-actor/src/lib.rs b/crates/pulsing-actor/src/lib.rs index de3e17e9b..176ddd487 100644 --- a/crates/pulsing-actor/src/lib.rs +++ b/crates/pulsing-actor/src/lib.rs @@ -131,7 +131,7 @@ pub mod watch; /// For advanced usage (ActorPath, ActorAddress, NodeId, etc.), /// import from `pulsing_actor::actor::*`. pub mod prelude { - pub use crate::actor::{Actor, ActorContext, ActorRef, Message}; + pub use crate::actor::{Actor, ActorContext, ActorRef, IntoActor, Message}; pub use crate::supervision::{BackoffStrategy, RestartPolicy, SupervisionSpec}; pub use crate::system::{ ActorSystem, ActorSystemAdvancedExt, ActorSystemCoreExt, ActorSystemOpsExt, ResolveOptions, diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index e84878bea..ad6e9e2d4 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -18,7 +18,7 @@ pub use traits::{ActorSystemAdvancedExt, ActorSystemCoreExt, ActorSystemOpsExt}; use crate::actor::{ Actor, ActorAddress, ActorContext, ActorId, ActorPath, ActorRef, ActorResolver, ActorSystemRef, - IntoActorPath, Mailbox, NodeId, StopReason, + IntoActor, IntoActorPath, Mailbox, NodeId, StopReason, }; use crate::cluster::{ GossipBackend, HeadNodeBackend, MemberInfo, MemberStatus, NamedActorInfo, NamingBackend, @@ -388,71 +388,16 @@ impl ActorSystem { } } - /// Spawn an anonymous actor using a factory function (enables supervision restarts) - pub async fn spawn_anonymous_factory( - self: &Arc, - factory: F, - options: SpawnOptions, - ) -> anyhow::Result - where - F: FnMut() -> anyhow::Result + Send + 'static, - A: Actor, - { - let actor_id = self.next_actor_id(); - - // Use configured mailbox capacity - let capacity = options - .mailbox_capacity - .unwrap_or(self.default_mailbox_capacity); - let mailbox = Mailbox::with_capacity(capacity); - let (sender, receiver) = mailbox.split(); - - let stats = Arc::new(ActorStats::default()); - let metadata = options.metadata.clone(); - - // Create context with system reference for actor_ref/watch/schedule_self - let ctx = ActorContext::with_system( - actor_id, - self.clone() as Arc, - self.cancel_token.clone(), - sender.clone(), - ); - - // Spawn actor loop with supervision - let stats_clone = stats.clone(); - let cancel = self.cancel_token.clone(); - let actor_id_for_log = actor_id; - let supervision = options.supervision.clone(); - - let join_handle = tokio::spawn(async move { - let reason = - run_supervision_loop(factory, receiver, ctx, cancel, stats_clone, supervision) - .await; - tracing::debug!(actor_id = ?actor_id_for_log, reason = ?reason, "Anonymous actor stopped"); - }); - - // Register actor using actor_id as key - let handle = LocalActorHandle { - sender: sender.clone(), - join_handle, - stats: stats.clone(), - metadata, - named_path: None, - actor_id, - }; - - self.local_actors.insert(actor_id.to_string(), handle); - - // Create ActorRef - Ok(ActorRef::local(actor_id, sender)) - } - /// Spawn an anonymous actor (no name, only accessible via ActorRef) + /// + /// Note: Anonymous actors do not support supervision/restart because they have + /// no stable identity for re-resolution. Use `spawn_named_factory` for actors + /// that need supervision. pub async fn spawn_anonymous(self: &Arc, actor: A) -> anyhow::Result where - A: Actor, + A: IntoActor, { - self.spawn_anonymous_with_options(actor, SpawnOptions::default()) + self.spawn_anonymous_with_options(actor.into_actor(), SpawnOptions::default()) .await } @@ -463,8 +408,9 @@ impl ActorSystem { options: SpawnOptions, ) -> anyhow::Result where - A: Actor, + A: IntoActor, { + let actor = actor.into_actor(); let actor_id = self.next_actor_id(); // Use configured mailbox capacity @@ -523,10 +469,10 @@ impl ActorSystem { pub async fn spawn_named(self: &Arc, name: P, actor: A) -> anyhow::Result where P: IntoActorPath, - A: Actor, + A: IntoActor, { let path = name.into_actor_path()?; - self.spawn_named_factory(path, Self::once_factory(actor), SpawnOptions::default()) + self.spawn_named_factory(path, Self::once_factory(actor.into_actor()), SpawnOptions::default()) .await } @@ -539,10 +485,10 @@ impl ActorSystem { ) -> anyhow::Result where P: IntoActorPath, - A: Actor, + A: IntoActor, { let path = name.into_actor_path()?; - self.spawn_named_factory(path, Self::once_factory(actor), options) + self.spawn_named_factory(path, Self::once_factory(actor.into_actor()), options) .await } @@ -586,12 +532,13 @@ impl ActorSystem { let stats = Arc::new(ActorStats::default()); let metadata = options.metadata.clone(); - // Create context with system reference for actor_ref/watch/schedule_self - let ctx = ActorContext::with_system( + // Create context with system reference and named path + let ctx = ActorContext::with_system_and_name( actor_id, self.clone() as Arc, self.cancel_token.clone(), sender.clone(), + Some(name_str.to_string()), ); // Spawn actor loop @@ -1118,6 +1065,10 @@ impl ActorSystemRef for ActorSystem { self.lifecycle.unwatch(&watcher_key, &target_key).await; Ok(()) } + + fn local_actor_ref_by_name(&self, name: &str) -> Option { + ActorSystem::local_actor_ref_by_name(self, name) + } } /// Implement ActorResolver for ActorSystem diff --git a/crates/pulsing-actor/src/system/traits.rs b/crates/pulsing-actor/src/system/traits.rs index cb800f862..9e0e9186e 100644 --- a/crates/pulsing-actor/src/system/traits.rs +++ b/crates/pulsing-actor/src/system/traits.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; -use crate::actor::{Actor, ActorId, ActorPath, ActorRef, IntoActorPath, NodeId}; +use crate::actor::{Actor, ActorId, ActorPath, ActorRef, IntoActor, IntoActorPath, NodeId}; use crate::cluster::{MemberInfo, NamedActorInfo}; use crate::supervision::SupervisionSpec; use crate::system_actor::BoxedActorFactory; @@ -66,24 +66,32 @@ use tokio_util::sync::CancellationToken; #[async_trait::async_trait] pub trait ActorSystemCoreExt: Sized { /// Spawn an anonymous actor (not resolvable by name, only accessible via ActorRef) + /// + /// Accepts any type that implements `IntoActor`, including: + /// - Types implementing `Actor` directly + /// - `Behavior` (automatically wrapped) async fn spawn(&self, actor: A) -> anyhow::Result where - A: Actor; + A: IntoActor; /// Spawn a named actor (resolvable by name across the cluster) /// /// Named actors can be discovered and resolved by other nodes using [`resolve`](Self::resolve). /// + /// Accepts any type that implements `IntoActor`, including: + /// - Types implementing `Actor` directly + /// - `Behavior` (automatically wrapped) + /// /// # Arguments /// - `name` - The name for discovery (e.g., "services/echo") - /// - `actor` - The actor instance + /// - `actor` - The actor instance or Behavior async fn spawn_named( &self, name: impl AsRef + Send, actor: A, ) -> anyhow::Result where - A: Actor; + A: IntoActor; /// Get a builder for spawning actors with advanced options /// @@ -201,12 +209,17 @@ impl<'a> SpawnBuilder<'a> { /// Spawn the actor /// + /// Accepts any type that implements `IntoActor`, including: + /// - Types implementing `Actor` directly + /// - `Behavior` (automatically wrapped) + /// /// If a name was set, spawns a named actor (resolvable). /// Otherwise, spawns an anonymous actor (only accessible via ActorRef). pub async fn spawn(self, actor: A) -> anyhow::Result where - A: Actor, + A: IntoActor, { + let actor = actor.into_actor(); match self.name { Some(name) => { // Named actor: resolvable by name @@ -352,24 +365,15 @@ impl<'a> ResolveBuilder<'a> { /// .restart_policy(RestartPolicy::OnFailure) /// .max_restarts(3)); /// -/// let actor = system.spawn_anonymous_factory(|| Ok(Worker::new()), options).await?; -/// -/// // Spawn named actor with factory +/// // Spawn named actor with factory (only named actors support supervision) /// let named = system.spawn_named_factory("services/worker", || Ok(Worker::new()), options).await?; /// ``` #[async_trait::async_trait] pub trait ActorSystemAdvancedExt { - /// Spawn an anonymous actor using a factory function (enables supervision restarts) - async fn spawn_anonymous_factory( - &self, - factory: F, - options: SpawnOptions, - ) -> anyhow::Result - where - F: FnMut() -> anyhow::Result + Send + 'static, - A: Actor; - /// Spawn a named actor using a factory function (enables supervision restarts) + /// + /// Note: Only named actors support supervision/restart. Anonymous actors cannot + /// be restarted because they have no stable identity for re-resolution. async fn spawn_named_factory( &self, name: P, @@ -513,9 +517,9 @@ use super::ActorSystem; impl ActorSystemCoreExt for Arc { async fn spawn(&self, actor: A) -> anyhow::Result where - A: Actor, + A: IntoActor, { - ActorSystem::spawn_anonymous(self, actor).await + ActorSystem::spawn_anonymous(self, actor.into_actor()).await } async fn spawn_named( @@ -524,10 +528,10 @@ impl ActorSystemCoreExt for Arc { actor: A, ) -> anyhow::Result where - A: Actor, + A: IntoActor, { let name = name.as_ref(); - ActorSystem::spawn_named_with_options(self, name, actor, SpawnOptions::default()).await + ActorSystem::spawn_named_with_options(self, name, actor.into_actor(), SpawnOptions::default()).await } fn spawning(&self) -> SpawnBuilder<'_> { @@ -560,18 +564,6 @@ impl ActorSystemCoreExt for Arc { #[async_trait::async_trait] impl ActorSystemAdvancedExt for Arc { - async fn spawn_anonymous_factory( - &self, - factory: F, - options: SpawnOptions, - ) -> anyhow::Result - where - F: FnMut() -> anyhow::Result + Send + 'static, - A: Actor, - { - ActorSystem::spawn_anonymous_factory(self, factory, options).await - } - async fn spawn_named_factory( &self, name: P, diff --git a/crates/pulsing-py/src/actor.rs b/crates/pulsing-py/src/actor.rs index 2b4a8fc01..fe95a241c 100644 --- a/crates/pulsing-py/src/actor.rs +++ b/crates/pulsing-py/src/actor.rs @@ -1224,29 +1224,19 @@ impl PyActorSystem { let actor_ref = match name { // Anonymous actor - no name provided (not resolvable) None => { - if matches!(policy, RestartPolicy::Never) { - // actor is the instance - let actor_wrapper = PythonActorWrapper::new(actor, event_loop); - system - .spawn_anonymous_with_options(actor_wrapper, options) - .await - .map_err(to_pyerr)? - } else { - // actor is a factory - anonymous actor with supervision - let factory = move || { - Python::with_gil(|py| -> anyhow::Result { - let event_loop = event_loop.clone_ref(py); - let instance = actor.call0(py).map_err(|e| { - anyhow::anyhow!("Python factory error: {:?}", e) - })?; - Ok(PythonActorWrapper::new(instance, event_loop)) - }) - }; - system - .spawn_anonymous_factory(factory, options) - .await - .map_err(to_pyerr)? + // Anonymous actors do not support supervision/restart + if !matches!(policy, RestartPolicy::Never) { + return Err(pyo3::exceptions::PyValueError::new_err( + "Anonymous actors do not support supervision/restart. \ + Provide a name to enable supervision, or set restart_policy='never'.", + )); } + // actor is the instance + let actor_wrapper = PythonActorWrapper::new(actor, event_loop); + system + .spawn_anonymous_with_options(actor_wrapper, options) + .await + .map_err(to_pyerr)? } // Named actor (resolvable by name) Some(name) => { diff --git a/docs/src/design/behavior.md b/docs/src/design/behavior.md index e75978f9c..1cb399cc1 100644 --- a/docs/src/design/behavior.md +++ b/docs/src/design/behavior.md @@ -33,12 +33,12 @@ fn counter(initial: i32) -> Behavior { A type-safe actor reference that only accepts messages of type `M`: ```rust -// Type-safe: only i32 can be sent -let counter: TypedRef = system.spawn_behavior("counter", counter(0)).await?; +// Behavior implements IntoActor, can be spawned directly +let counter = system.spawn_named("actors/counter", counter(0)).await?; counter.tell(5).await?; // OK counter.tell(3).await?; // OK -// counter.tell("hello").await?; // Compile error! +// counter.tell("hello").await?; // Runtime error (type mismatch on deserialization) ``` ### BehaviorAction @@ -236,10 +236,10 @@ fn counter(initial: i32) -> Behavior { async fn main() -> anyhow::Result<()> { let system = ActorSystem::builder().build().await?; - // Spawn behavior-based actor - let counter = system.spawn_behavior("counter", counter(0)).await?; + // Behavior implements IntoActor, can be spawned directly + let counter = system.spawn_named("actors/counter", counter(0)).await?; - // Type-safe message sending + // Message sending counter.tell(CounterMsg::Increment(5)).await?; counter.tell(CounterMsg::Increment(3)).await?; counter.tell(CounterMsg::Decrement(2)).await?; diff --git a/docs/src/design/behavior.zh.md b/docs/src/design/behavior.zh.md index dfce6dd47..b97e75487 100644 --- a/docs/src/design/behavior.zh.md +++ b/docs/src/design/behavior.zh.md @@ -33,12 +33,12 @@ fn counter(initial: i32) -> Behavior { 类型安全的 Actor 引用,只接受类型 `M` 的消息: ```rust -// 类型安全:只能发送 i32 -let counter: TypedRef = system.spawn_behavior("counter", counter(0)).await?; +// Behavior 实现了 IntoActor,可以直接 spawn +let counter = system.spawn_named("actors/counter", counter(0)).await?; counter.tell(5).await?; // OK counter.tell(3).await?; // OK -// counter.tell("hello").await?; // 编译错误! +// counter.tell("hello").await?; // 运行时错误(反序列化时类型不匹配) ``` ### BehaviorAction @@ -236,10 +236,10 @@ fn counter(initial: i32) -> Behavior { async fn main() -> anyhow::Result<()> { let system = ActorSystem::builder().build().await?; - // 启动 behavior 风格的 Actor - let counter = system.spawn_behavior("counter", counter(0)).await?; + // Behavior 实现了 IntoActor,可以直接 spawn + let counter = system.spawn_named("actors/counter", counter(0)).await?; - // 类型安全的消息发送 + // 消息发送 counter.tell(CounterMsg::Increment(5)).await?; counter.tell(CounterMsg::Increment(3)).await?; counter.tell(CounterMsg::Decrement(2)).await?; diff --git a/examples/rust/behavior_counter.rs b/examples/rust/behavior_counter.rs index b76bae468..904f6abe7 100644 --- a/examples/rust/behavior_counter.rs +++ b/examples/rust/behavior_counter.rs @@ -2,8 +2,8 @@ //! //! Run: cargo run --example behavior_counter -use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction, BehaviorSpawner}; -use pulsing_actor::system::ActorSystem; +use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction}; +use pulsing_actor::prelude::*; fn counter(init: i32) -> Behavior { stateful(init, |count, n, _ctx| { @@ -16,11 +16,13 @@ fn counter(init: i32) -> Behavior { #[tokio::main] async fn main() -> anyhow::Result<()> { let system = ActorSystem::builder().build().await?; - let counter = system.spawn_behavior("counter", counter(0)).await?; - counter.tell(5).await?; - counter.tell(3).await?; - counter.tell(-2).await?; + // Behavior implements IntoActor, can be passed directly to spawn_named + let counter_ref = system.spawn_named("actors/counter", counter(0)).await?; + + counter_ref.tell(5).await?; + counter_ref.tell(3).await?; + counter_ref.tell(-2).await?; tokio::time::sleep(std::time::Duration::from_millis(50)).await; system.shutdown().await diff --git a/examples/rust/behavior_fsm.rs b/examples/rust/behavior_fsm.rs index 649916bd5..67c431bc0 100644 --- a/examples/rust/behavior_fsm.rs +++ b/examples/rust/behavior_fsm.rs @@ -5,8 +5,8 @@ //! //! Run: cargo run --example behavior_fsm -p pulsing-actor -use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction, BehaviorSpawner}; -use pulsing_actor::system::ActorSystem; +use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction}; +use pulsing_actor::prelude::*; use serde::{Deserialize, Serialize}; /// Statistics passed between states @@ -104,7 +104,8 @@ async fn main() -> anyhow::Result<()> { cycles: 0, transitions: 0, }; - let light = system.spawn_behavior("light", red(initial_stats)).await?; + // Behavior implements IntoActor, can be passed directly to spawn_named + let light = system.spawn_named("actors/light", red(initial_stats)).await?; // Run through 2 complete cycles for _ in 0..2 { diff --git a/llms.binding.md b/llms.binding.md index bed9db282..75159b238 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -431,13 +431,11 @@ system.resolving() #### ActorSystemAdvancedExt(高级:可重启 supervision) -Factory 模式 spawn,支持 supervision 重启: +Factory 模式 spawn,支持 supervision 重启(仅命名 actor): ```rust -// 匿名 actor + factory(可重启) -system.spawn_anonymous_factory(|| Ok(Worker::new()), options).await?; - // 命名 actor + factory(可重启 + 可 resolve) +// 注意:匿名 actor 不支持 supervision,因为无法重新解析 system.spawn_named_factory(name, || Ok(Service::new()), options).await?; ``` @@ -462,10 +460,25 @@ system.shutdown().await?; - `resolve(name)`:一次性解析(迁移后可能 stale) - `resolve_lazy(name)`:懒解析 + 自动刷新(~5s TTL) - **流式**:返回 `Message::Stream`,取消语义 best-effort。 -- **监督**:只有 `spawn_anonymous_factory` / `spawn_named_factory` 支持失败重启。 +- **监督**:只有 `spawn_named_factory` 支持失败重启,匿名 actor 不支持 supervision。 ### Behavior(类型安全,Akka Typed 风格) - **核心**:`Behavior` + `TypedRef` + `BehaviorAction (Same/Become/Stop)` -- **启动**:`system.spawn_behavior("name", behavior).await? -> TypedRef` - **约定**:`TypedRef` 要求 `M: Serialize + DeserializeOwned + Send + 'static` + +除了定义时候使用函数语法以外,其他与 Actor 完全相同: + +```rust +fn counter(init: i32) -> Behavior { + stateful(init, |count, n, _ctx| { + *count += n; + BehaviorAction::Same + }) +} + +// Behavior 实现 IntoActor trait,可以直接传给 spawn/spawn_named +// 无需手动包装,系统会自动转换 +let counter = system.spawn(counter(0)).await?; +let counter = system.spawn_named("actors/counter", counter(0)).await?; +``` \ No newline at end of file From c6d95c9f1e0fe0259f71bca1533ae1c1a866bc7a Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 19:28:57 +0800 Subject: [PATCH 19/24] Refactor actor naming and improve documentation consistency - Updated actor naming conventions to use the `SYSTEM_ACTOR_PATH` instead of the deprecated `SYSTEM_ACTOR_LOCAL_NAME`, enhancing clarity in the API. - Adjusted tests and documentation to reflect the new naming structure, ensuring accurate references to system actors. - Improved formatting in code examples for better readability and consistency across the codebase. --- crates/pulsing-actor/src/behavior/context.rs | 2 +- crates/pulsing-actor/src/system/mod.rs | 14 +++++++------ crates/pulsing-actor/src/system/traits.rs | 21 ++++++++++++------- crates/pulsing-actor/src/system_actor/mod.rs | 3 --- .../pulsing-actor/tests/integration_tests.rs | 2 +- .../pulsing-actor/tests/multi_node_tests.rs | 6 +++--- .../pulsing-actor/tests/system_actor_tests.rs | 2 +- examples/rust/behavior_fsm.rs | 4 +++- llms.binding.md | 2 +- tests/python/test_system_actor.py | 4 +++- 10 files changed, 34 insertions(+), 26 deletions(-) diff --git a/crates/pulsing-actor/src/behavior/context.rs b/crates/pulsing-actor/src/behavior/context.rs index 802f98c5c..93d4d606d 100644 --- a/crates/pulsing-actor/src/behavior/context.rs +++ b/crates/pulsing-actor/src/behavior/context.rs @@ -1,8 +1,8 @@ //! Typed actor context for behavior-based actors use super::reference::TypedRef; -use crate::actor::ActorSystemRef; use crate::actor::ActorId; +use crate::actor::ActorSystemRef; use serde::{de::DeserializeOwned, Serialize}; use std::marker::PhantomData; use std::sync::Arc; diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index ad6e9e2d4..80e866c86 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -24,9 +24,7 @@ use crate::cluster::{ GossipBackend, HeadNodeBackend, MemberInfo, MemberStatus, NamedActorInfo, NamingBackend, }; use crate::policies::{LoadBalancingPolicy, RoundRobinPolicy, Worker}; -use crate::system_actor::{ - BoxedActorFactory, SystemActor, SystemRef, SYSTEM_ACTOR_LOCAL_NAME, SYSTEM_ACTOR_PATH, -}; +use crate::system_actor::{BoxedActorFactory, SystemActor, SystemRef, SYSTEM_ACTOR_PATH}; use crate::transport::{Http2RemoteTransport, Http2Transport}; use crate::watch::ActorLifecycle; use dashmap::DashMap; @@ -315,7 +313,7 @@ impl ActorSystem { factory: BoxedActorFactory, ) -> anyhow::Result<()> { // Check if already started - if self.local_actors.contains_key(SYSTEM_ACTOR_LOCAL_NAME) { + if self.local_actors.contains_key(SYSTEM_ACTOR_PATH) { return Err(anyhow::anyhow!("SystemActor already started")); } @@ -472,8 +470,12 @@ impl ActorSystem { A: IntoActor, { let path = name.into_actor_path()?; - self.spawn_named_factory(path, Self::once_factory(actor.into_actor()), SpawnOptions::default()) - .await + self.spawn_named_factory( + path, + Self::once_factory(actor.into_actor()), + SpawnOptions::default(), + ) + .await } /// Spawn a named actor with custom options diff --git a/crates/pulsing-actor/src/system/traits.rs b/crates/pulsing-actor/src/system/traits.rs index 9e0e9186e..f8a3193d9 100644 --- a/crates/pulsing-actor/src/system/traits.rs +++ b/crates/pulsing-actor/src/system/traits.rs @@ -350,8 +350,8 @@ impl<'a> ResolveBuilder<'a> { /// the system can recreate it using the factory function. /// /// Note: Regular `spawn` methods use a one-shot factory internally, so the actor -/// cannot be restarted. Use `spawn_factory` or `spawn_named_factory` if you need -/// supervision with restart capability. +/// cannot be restarted. Use `spawn_named_factory` if you need supervision with +/// restart capability. Anonymous actors do not support supervision. /// /// # Example /// ```rust,ignore @@ -359,7 +359,6 @@ impl<'a> ResolveBuilder<'a> { /// /// let system = ActorSystem::builder().build().await?; /// -/// // Spawn anonymous actor with factory - enables restart on failure /// let options = SpawnOptions::new() /// .supervision(SupervisionSpec::new() /// .restart_policy(RestartPolicy::OnFailure) @@ -442,7 +441,7 @@ pub trait ActorSystemOpsExt { /// Spawn an anonymous actor (no name, only accessible via ActorRef) async fn spawn_anonymous(&self, actor: A) -> anyhow::Result where - A: Actor; + A: IntoActor; /// Spawn an anonymous actor with custom options async fn spawn_anonymous_with_options( @@ -451,7 +450,7 @@ pub trait ActorSystemOpsExt { options: SpawnOptions, ) -> anyhow::Result where - A: Actor; + A: IntoActor; /// Get load tracker for a node address fn get_node_load_tracker(&self, addr: &SocketAddr) -> Option>; @@ -531,7 +530,13 @@ impl ActorSystemCoreExt for Arc { A: IntoActor, { let name = name.as_ref(); - ActorSystem::spawn_named_with_options(self, name, actor.into_actor(), SpawnOptions::default()).await + ActorSystem::spawn_named_with_options( + self, + name, + actor.into_actor(), + SpawnOptions::default(), + ) + .await } fn spawning(&self) -> SpawnBuilder<'_> { @@ -610,7 +615,7 @@ impl ActorSystemOpsExt for Arc { async fn spawn_anonymous(&self, actor: A) -> anyhow::Result where - A: Actor, + A: IntoActor, { ActorSystem::spawn_anonymous(self, actor).await } @@ -621,7 +626,7 @@ impl ActorSystemOpsExt for Arc { options: SpawnOptions, ) -> anyhow::Result where - A: Actor, + A: IntoActor, { ActorSystem::spawn_anonymous_with_options(self, actor, options).await } diff --git a/crates/pulsing-actor/src/system_actor/mod.rs b/crates/pulsing-actor/src/system_actor/mod.rs index a14bda52b..41115c36d 100644 --- a/crates/pulsing-actor/src/system_actor/mod.rs +++ b/crates/pulsing-actor/src/system_actor/mod.rs @@ -36,9 +36,6 @@ use tokio::sync::mpsc; /// Named path for SystemActor (system/core satisfies namespace/name format requirement) pub const SYSTEM_ACTOR_PATH: &str = "system/core"; -/// Internal local name for SystemActor -pub(crate) const SYSTEM_ACTOR_LOCAL_NAME: &str = "_system_internal"; - /// System metrics #[derive(Debug, Default)] pub struct SystemMetrics { diff --git a/crates/pulsing-actor/tests/integration_tests.rs b/crates/pulsing-actor/tests/integration_tests.rs index 3e0a03008..54c078643 100644 --- a/crates/pulsing-actor/tests/integration_tests.rs +++ b/crates/pulsing-actor/tests/integration_tests.rs @@ -235,7 +235,7 @@ mod stress_tests { ); } - // +1 for SystemActor (_system_internal) + // +1 for SystemActor (system/core) assert_eq!(system.local_actor_names().len(), actor_count + 1); // Send one message to each diff --git a/crates/pulsing-actor/tests/multi_node_tests.rs b/crates/pulsing-actor/tests/multi_node_tests.rs index c5ad3e4ea..7e3b2fbef 100644 --- a/crates/pulsing-actor/tests/multi_node_tests.rs +++ b/crates/pulsing-actor/tests/multi_node_tests.rs @@ -217,9 +217,9 @@ mod multi_node_tests { .unwrap(); // Each node has exactly one user actor + SystemActor - assert_eq!(system1.local_actor_names().len(), 2); // _system_internal + actor-on-node1 - assert_eq!(system2.local_actor_names().len(), 2); // _system_internal + actor-on-node2 - assert_eq!(system3.local_actor_names().len(), 2); // _system_internal + actor-on-node3 + assert_eq!(system1.local_actor_names().len(), 2); // system/core + test/actor-on-node1 + assert_eq!(system2.local_actor_names().len(), 2); // system/core + test/actor-on-node2 + assert_eq!(system3.local_actor_names().len(), 2); // system/core + test/actor-on-node3 system1.shutdown().await.unwrap(); system2.shutdown().await.unwrap(); diff --git a/crates/pulsing-actor/tests/system_actor_tests.rs b/crates/pulsing-actor/tests/system_actor_tests.rs index 8bce277b2..ff6e3b781 100644 --- a/crates/pulsing-actor/tests/system_actor_tests.rs +++ b/crates/pulsing-actor/tests/system_actor_tests.rs @@ -307,7 +307,7 @@ async fn test_system_actor_list_actors() { match parsed { SystemResponse::ActorList { actors } => { // Initially empty (SystemActor doesn't register itself in the registry) - assert!(actors.is_empty() || actors.iter().all(|a| a.name != "_system_internal")); + assert!(actors.is_empty() || actors.iter().all(|a| a.name != "system/core")); } _ => panic!("Expected ActorList response"), } diff --git a/examples/rust/behavior_fsm.rs b/examples/rust/behavior_fsm.rs index 67c431bc0..ddaacda9c 100644 --- a/examples/rust/behavior_fsm.rs +++ b/examples/rust/behavior_fsm.rs @@ -105,7 +105,9 @@ async fn main() -> anyhow::Result<()> { transitions: 0, }; // Behavior implements IntoActor, can be passed directly to spawn_named - let light = system.spawn_named("actors/light", red(initial_stats)).await?; + let light = system + .spawn_named("actors/light", red(initial_stats)) + .await?; // Run through 2 complete cycles for _ in 0..2 { diff --git a/llms.binding.md b/llms.binding.md index 75159b238..fc861fa9d 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -481,4 +481,4 @@ fn counter(init: i32) -> Behavior { // 无需手动包装,系统会自动转换 let counter = system.spawn(counter(0)).await?; let counter = system.spawn_named("actors/counter", counter(0)).await?; -``` \ No newline at end of file +``` diff --git a/tests/python/test_system_actor.py b/tests/python/test_system_actor.py index 72bf56506..5d602cbf6 100644 --- a/tests/python/test_system_actor.py +++ b/tests/python/test_system_actor.py @@ -52,7 +52,9 @@ async def test_system_actor_auto_registered(system): async def test_python_actor_service_auto_registered(system): """PythonActorService should be automatically registered on startup.""" actors = system.local_actor_names() - assert "system/python_actor_service" in actors, "PythonActorService should be registered" + assert ( + "system/python_actor_service" in actors + ), "PythonActorService should be registered" # ============================================================================ From b0a2a2d1f375f64867f91c6e96d5888321ec00d2 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 19:41:29 +0800 Subject: [PATCH 20/24] docs cleanup --- crates/pulsing-actor/README.md | 4 +- docs/src/api_reference.md | 56 +++++++++++++-------- docs/src/api_reference.zh.md | 56 +++++++++++++-------- docs/src/design/actor-system.md | 20 +++++--- docs/src/design/as-actor-decorator.md | 2 +- docs/src/design/as-actor-decorator.zh.md | 2 +- docs/src/design/behavior.md | 4 +- docs/src/design/behavior.zh.md | 4 +- docs/src/examples/distributed_counter.md | 5 +- docs/src/examples/distributed_counter.zh.md | 5 +- docs/src/examples/index.md | 6 +-- docs/src/examples/index.zh.md | 8 ++- docs/src/guide/actors.md | 4 +- docs/src/guide/actors.zh.md | 4 +- docs/src/guide/remote_actors.md | 44 +++++++++------- docs/src/guide/remote_actors.zh.md | 44 +++++++++------- examples/inspect/demo_service.py | 8 +-- examples/python/cluster.py | 1 - examples/python/named_actors.py | 6 +-- examples/rust/cluster.rs | 2 +- examples/rust/named_actors.rs | 4 +- llms.binding.md | 4 +- 22 files changed, 170 insertions(+), 123 deletions(-) diff --git a/crates/pulsing-actor/README.md b/crates/pulsing-actor/README.md index 9a9d27f5f..e061aabd3 100644 --- a/crates/pulsing-actor/README.md +++ b/crates/pulsing-actor/README.md @@ -87,8 +87,8 @@ let system2 = ActorSystem::builder() .build() .await?; -// 通过路径解析远程 Actor -let remote = system2.resolve_named("services/echo", None).await?; +// 通过名称解析远程 Actor +let remote = system2.resolve("services/echo").await?; let resp: String = remote.ask("hello".to_string()).await?; ``` diff --git a/docs/src/api_reference.md b/docs/src/api_reference.md index 5e85520b0..3c0e0163e 100644 --- a/docs/src/api_reference.md +++ b/docs/src/api_reference.md @@ -126,13 +126,18 @@ class ActorSystem: actor: Actor, *, name: str | None = None, - public: bool = False, + # public parameter is deprecated: all named actors are resolvable restart_policy: str = "never", max_restarts: int = 3, min_backoff: float = 0.1, max_backoff: float = 30.0 ) -> ActorRef: - """Spawn a new actor.""" + """ + Spawn a new actor. + + - With name: named actor, discoverable via resolve() + - Without name: anonymous actor, only accessible via returned ActorRef + """ pass async def refer(self, actorid: ActorId | str) -> ActorRef: @@ -319,31 +324,42 @@ The Rust API is organized into three trait layers (all re-exported in `pulsing_a Core spawn and resolve operations: ```rust -// Spawn actors -system.spawn("name", actor).await?; -system.spawn_with_options("name", actor, options).await?; -system.spawn_named("path", "local_name", actor).await?; -system.spawn_named_with_options("path", "local_name", actor, options).await?; - -// Resolve actors -system.actor_ref(&actor_id).await?; -system.resolve_named("path", node_id_opt).await?; -system.resolve_named_with_options(&path, options).await?; -system.resolve_named_lazy("path")?; // Auto-refresh after ~5s +// Spawn - Simple API +system.spawn(actor).await?; // Anonymous actor (not resolvable) +system.spawn_named(name, actor).await?; // Named actor (resolvable) + +// Spawn - Builder pattern (advanced config) +system.spawning() + .name("services/counter") // Optional: with name = resolvable + .supervision(SupervisionSpec::on_failure().max_restarts(3)) + .mailbox_capacity(256) + .spawn(actor).await?; + +// Resolve - Simple API +system.actor_ref(&actor_id).await?; // Get by ActorId +system.resolve(name).await?; // Resolve by name + +// Resolve - Builder pattern (advanced config) +system.resolving() + .node(node_id) // Optional: target node + .policy(RoundRobinPolicy::new()) // Optional: load balancing + .filter_alive(true) // Optional: only alive nodes + .resolve(name).await?; // Resolve single + +system.resolving().list(name).await?; // Get all instances +system.resolving().lazy(name)?; // Lazy resolution (~5s TTL auto-refresh) ``` ### ActorSystemAdvancedExt (Supervision/Restart) -Factory-based spawning for restartable actors: +Factory-based spawning for supervision restarts (named actors only): ```rust let options = SpawnOptions::new() - .supervision(SupervisionSpec::new() - .restart_policy(RestartPolicy::OnFailure) - .max_restarts(3)); + .supervision(SupervisionSpec::on_failure().max_restarts(3)); -system.spawn_factory("name", || Ok(MyActor::new()), options).await?; -system.spawn_named_factory("path", "name", || Ok(MyActor::new()), options).await?; +// Only named actors support supervision (anonymous cannot be re-resolved) +system.spawn_named_factory(name, || Ok(Service::new()), options).await?; ``` ### ActorSystemOpsExt (Operations/Diagnostics) @@ -355,7 +371,7 @@ system.node_id(); system.addr(); system.members().await; system.all_named_actors().await; -system.stop("name").await?; +system.stop(name).await?; system.shutdown().await?; ``` diff --git a/docs/src/api_reference.zh.md b/docs/src/api_reference.zh.md index 128b4467a..4c01006ca 100644 --- a/docs/src/api_reference.zh.md +++ b/docs/src/api_reference.zh.md @@ -126,13 +126,18 @@ class ActorSystem: actor: Actor, *, name: str | None = None, - public: bool = False, + # public 参数已废弃:所有命名 actor 自动可被 resolve restart_policy: str = "never", max_restarts: int = 3, min_backoff: float = 0.1, max_backoff: float = 30.0 ) -> ActorRef: - """生成新的 actor。""" + """ + 生成新的 actor。 + + - 有 name: 命名 actor,可通过 resolve() 发现 + - 无 name: 匿名 actor,仅通过返回的 ActorRef 访问 + """ pass async def refer(self, actorid: ActorId | str) -> ActorRef: @@ -319,31 +324,42 @@ Rust API 通过三层 trait 组织(均在 `pulsing_actor::prelude::*` 中 re-e 核心 spawn 与 resolve 操作: ```rust -// Spawn actors -system.spawn("name", actor).await?; -system.spawn_with_options("name", actor, options).await?; -system.spawn_named("path", "local_name", actor).await?; -system.spawn_named_with_options("path", "local_name", actor, options).await?; - -// Resolve actors -system.actor_ref(&actor_id).await?; -system.resolve_named("path", node_id_opt).await?; -system.resolve_named_with_options(&path, options).await?; -system.resolve_named_lazy("path")?; // 懒解析,约 5s 后自动刷新 +// Spawn - 简洁 API +system.spawn(actor).await?; // 匿名 actor(不可 resolve) +system.spawn_named(name, actor).await?; // 命名 actor(可 resolve) + +// Spawn - Builder 模式(高级配置) +system.spawning() + .name("services/counter") // 可选:有 name = 可 resolve + .supervision(SupervisionSpec::on_failure().max_restarts(3)) + .mailbox_capacity(256) + .spawn(actor).await?; + +// Resolve - 简洁 API +system.actor_ref(&actor_id).await?; // 按 ActorId 获取 +system.resolve(name).await?; // 按名称解析 + +// Resolve - Builder 模式(高级配置) +system.resolving() + .node(node_id) // 可选:指定目标节点 + .policy(RoundRobinPolicy::new()) // 可选:负载均衡策略 + .filter_alive(true) // 可选:只选存活节点 + .resolve(name).await?; // 解析单个 + +system.resolving().list(name).await?; // 获取所有实例 +system.resolving().lazy(name)?; // 懒解析(~5s TTL 自动刷新) ``` ### ActorSystemAdvancedExt(高级:监督/重启) -基于 factory 的 spawn,支持失败重启: +Factory 模式 spawn,支持 supervision 重启(仅命名 actor): ```rust let options = SpawnOptions::new() - .supervision(SupervisionSpec::new() - .restart_policy(RestartPolicy::OnFailure) - .max_restarts(3)); + .supervision(SupervisionSpec::on_failure().max_restarts(3)); -system.spawn_factory("name", || Ok(MyActor::new()), options).await?; -system.spawn_named_factory("path", "name", || Ok(MyActor::new()), options).await?; +// 仅命名 actor 支持 supervision(匿名 actor 无法重新解析) +system.spawn_named_factory(name, || Ok(Service::new()), options).await?; ``` ### ActorSystemOpsExt(运维/诊断) @@ -355,7 +371,7 @@ system.node_id(); system.addr(); system.members().await; system.all_named_actors().await; -system.stop("name").await?; +system.stop(name).await?; system.shutdown().await?; ``` diff --git a/docs/src/design/actor-system.md b/docs/src/design/actor-system.md index 8824bf73c..7d0ca38b7 100644 --- a/docs/src/design/actor-system.md +++ b/docs/src/design/actor-system.md @@ -197,14 +197,20 @@ impl ActorSystem { /// Builder 模式创建系统 (推荐) pub fn builder() -> ActorSystemBuilder; - /// 创建 Actor - pub async fn spawn(&self, name: &str, actor: A) -> anyhow::Result; + /// 创建匿名 Actor (仅通过 ActorRef 访问) + pub async fn spawn(&self, actor: A) -> anyhow::Result; /// 创建命名 Actor (支持跨节点发现) - pub async fn spawn_named(&self, path: &str, name: &str, actor: A) -> anyhow::Result; + pub async fn spawn_named, A: IntoActor>(&self, name: P, actor: A) -> anyhow::Result; + + /// Builder 模式创建 Actor (高级配置) + pub fn spawning(&self) -> SpawnBuilder; /// 解析命名 Actor - pub async fn resolve_named(&self, path: &str, node_id: Option<&NodeId>) -> anyhow::Result; + pub async fn resolve(&self, name: P) -> anyhow::Result; + + /// Builder 模式解析 Actor (高级配置) + pub fn resolving(&self) -> ResolveBuilder; /// 停止 Actor pub async fn stop(&self, actor_name: &str) -> anyhow::Result<()>; @@ -388,7 +394,7 @@ let system1 = ActorSystem::builder() .await?; // 创建命名 Actor (可跨节点发现) -system1.spawn_named("services/echo", "echo", EchoActor).await?; +system1.spawn_named("services/echo", EchoActor).await?; // 节点 2 (加入集群) let system2 = ActorSystem::builder() @@ -400,8 +406,8 @@ let system2 = ActorSystem::builder() // 等待集群同步 tokio::time::sleep(Duration::from_millis(500)).await; -// 通过路径解析远程 Actor -let remote_ref = system2.resolve_named("services/echo", None).await?; +// 通过名称解析远程 Actor +let remote_ref = system2.resolve("services/echo").await?; let response: Pong = remote_ref.ask(Ping { value: 10 }).await?; assert_eq!(response.result, 20); diff --git a/docs/src/design/as-actor-decorator.md b/docs/src/design/as-actor-decorator.md index 35fed3a10..0e87b820b 100644 --- a/docs/src/design/as-actor-decorator.md +++ b/docs/src/design/as-actor-decorator.md @@ -166,7 +166,7 @@ counter = await Counter.spawn(name="global_counter", init_value=0) # 其他地方可以通过名称解析 from pulsing.actor import get_system -ref = await get_system().resolve_named("global_counter") +ref = await get_system().resolve("global_counter") ``` ### 作为普通类使用 diff --git a/docs/src/design/as-actor-decorator.zh.md b/docs/src/design/as-actor-decorator.zh.md index 7efbe0ed2..4f7ffddeb 100644 --- a/docs/src/design/as-actor-decorator.zh.md +++ b/docs/src/design/as-actor-decorator.zh.md @@ -166,7 +166,7 @@ counter = await Counter.spawn(name="global_counter", init_value=0) # 其他地方可以通过名称解析 from pulsing.actor import get_system -ref = await get_system().resolve_named("global_counter") +ref = await get_system().resolve("global_counter") ``` ### 作为普通类使用 diff --git a/docs/src/design/behavior.md b/docs/src/design/behavior.md index 1cb399cc1..6fa574193 100644 --- a/docs/src/design/behavior.md +++ b/docs/src/design/behavior.md @@ -202,8 +202,8 @@ let actor_ref = counter.as_untyped()?; ## Complete Example ```rust -use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction, BehaviorSpawner}; -use pulsing_actor::system::ActorSystem; +use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction}; +use pulsing_actor::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] diff --git a/docs/src/design/behavior.zh.md b/docs/src/design/behavior.zh.md index b97e75487..1bbf2c51b 100644 --- a/docs/src/design/behavior.zh.md +++ b/docs/src/design/behavior.zh.md @@ -202,8 +202,8 @@ let actor_ref = counter.as_untyped()?; ## 完整示例 ```rust -use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction, BehaviorSpawner}; -use pulsing_actor::system::ActorSystem; +use pulsing_actor::behavior::{stateful, Behavior, BehaviorAction}; +use pulsing_actor::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] diff --git a/docs/src/examples/distributed_counter.md b/docs/src/examples/distributed_counter.md index b2bf9d014..0fa8eeedf 100644 --- a/docs/src/examples/distributed_counter.md +++ b/docs/src/examples/distributed_counter.md @@ -6,7 +6,7 @@ If you want a runnable baseline, start from `examples/python/named_actors.py` an ## Pattern -1. Start a seed node and spawn a **public named actor** +1. Start a seed node and spawn a **named actor** (discoverable via resolve) 2. Start worker nodes that join the cluster and **resolve the actor by name** 3. Use `ask` to update state and get a response @@ -32,7 +32,8 @@ class Counter: async def seed(): system = await pul.actor_system(addr="0.0.0.0:8000") - await system.spawn(Counter(), name="global_counter", public=True) + # Named actors are automatically discoverable via resolve + await system.spawn(Counter(), name="global_counter") await asyncio.Event().wait() diff --git a/docs/src/examples/distributed_counter.zh.md b/docs/src/examples/distributed_counter.zh.md index 936148183..937ab3b51 100644 --- a/docs/src/examples/distributed_counter.zh.md +++ b/docs/src/examples/distributed_counter.zh.md @@ -6,7 +6,7 @@ ## 模式说明 -1. 启动 seed 节点,并创建一个**public 的具名 Actor** +1. 启动 seed 节点,并创建一个**命名 Actor**(可被 resolve 发现) 2. 启动其它节点加入集群,通过名称 **resolve** 3. 使用 `ask` 远程更新状态并获取返回值 @@ -32,7 +32,8 @@ class Counter: async def seed(): system = await pul.actor_system(addr="0.0.0.0:8000") - await system.spawn(Counter(), name="global_counter", public=True) + # 命名 actor 自动可被 resolve 发现 + await system.spawn(Counter(), name="global_counter") await asyncio.Event().wait() diff --git a/docs/src/examples/index.md b/docs/src/examples/index.md index 987170b81..9223242a8 100644 --- a/docs/src/examples/index.md +++ b/docs/src/examples/index.md @@ -83,13 +83,11 @@ class PongActor: async def main(): # Node 1: Start pong actor await init(addr="0.0.0.0:8000") - pong = await PongActor.spawn() - system = get_system() - await system.register("pong", pong, public=True) + pong = await PongActor.spawn(name="pong") # Node 2: Would run on another machine # await init(addr="0.0.0.0:8001", seeds=["node1:8000"]) - # pong_ref = await get_system().find("pong") + # pong_ref = await PongActor.resolve("pong") # ping = await PingActor.spawn(pong_ref=pong_ref) # await ping.start_ping(10) diff --git a/docs/src/examples/index.zh.md b/docs/src/examples/index.zh.md index f1c776f12..06c7d44cc 100644 --- a/docs/src/examples/index.zh.md +++ b/docs/src/examples/index.zh.md @@ -81,15 +81,13 @@ class PongActor: async def main(): - # 节点 1:启动 pong Actor + # 节点 1:启动命名 pong Actor(可被远程发现) await init(addr="0.0.0.0:8000") - pong = await PongActor.spawn() - system = get_system() - await system.register("pong", pong, public=True) + pong = await PongActor.spawn(name="pong") # 节点 2:在另一台机器上运行 # await init(addr="0.0.0.0:8001", seeds=["node1:8000"]) - # pong_ref = await get_system().find("pong") + # pong_ref = await PongActor.resolve("pong") # ping = await PingActor.spawn(pong_ref=pong_ref) # await ping.start_ping(10) diff --git a/docs/src/guide/actors.md b/docs/src/guide/actors.md index 292a88c12..7fd82e7c0 100644 --- a/docs/src/guide/actors.md +++ b/docs/src/guide/actors.md @@ -275,8 +275,8 @@ import pulsing as pul # Create system system = await pul.actor_system() -# Spawn actor -actor = await system.spawn(MyActor(), name="my_actor", public=True) +# Spawn named actor (discoverable via resolve) +actor = await system.spawn(MyActor(), name="my_actor") # Call method result = await actor.ask({"action": "do_something"}) diff --git a/docs/src/guide/actors.zh.md b/docs/src/guide/actors.zh.md index 732d26fc3..e4d9b817d 100644 --- a/docs/src/guide/actors.zh.md +++ b/docs/src/guide/actors.zh.md @@ -275,8 +275,8 @@ import pulsing as pul # 创建系统 system = await pul.actor_system() -# 生成 actor -actor = await system.spawn(MyActor(), name="my_actor", public=True) +# 生成命名 actor(可通过 resolve 发现) +actor = await system.spawn(MyActor(), name="my_actor") # 调用方法 result = await actor.ask({"action": "do_something"}) diff --git a/docs/src/guide/remote_actors.md b/docs/src/guide/remote_actors.md index 6bfdbe4ec..7257ab3a5 100644 --- a/docs/src/guide/remote_actors.md +++ b/docs/src/guide/remote_actors.md @@ -12,8 +12,8 @@ import pulsing as pul # Node 1: Start seed node system = await pul.actor_system(addr="0.0.0.0:8000") -# Spawn a public actor -await system.spawn(WorkerActor(), name="worker", public=True) +# Spawn a named actor (discoverable via resolve) +await system.spawn(WorkerActor(), name="worker") ``` ### Joining a Cluster @@ -51,38 +51,44 @@ worker = await Worker.resolve("worker") result = await worker.process("hello") # Direct method call ``` -## Public vs Private Actors +## Named vs Anonymous Actors -### Public Actors +### Named Actors (Discoverable) -Public actors are visible to all nodes in the cluster: +Named actors are discoverable by any node in the cluster via `resolve()`: ```python -# Public actor - can be found by other nodes -await system.spawn(WorkerActor(), name="worker", public=True) +# Named actor - discoverable via resolve() from any node +await system.spawn(WorkerActor(), name="worker") + +# Other nodes can find it by name +ref = await other_system.resolve("worker") ``` -### Private Actors +### Anonymous Actors (Local Reference Only) -Private actors are only accessible locally: +Anonymous actors can only be accessed via the ActorRef returned by spawn: ```python -# Private actor - local only -await system.spawn(WorkerActor(), name="local-worker", public=False) +# Anonymous actor - only accessible via ActorRef +local_ref = await system.spawn(WorkerActor()) + +# Cannot be found via resolve(), only use the returned ActorRef +await local_ref.ask(msg) ``` ## Location Transparency -The same API works for both local and remote actors: +Named actors support location transparency — same API for local and remote: ```python -# Local actor -local_ref = await system.spawn(MyActor(), name="local") +# Local named actor +local_ref = await system.spawn(MyActor(), name="local-worker") -# Remote actor (found via cluster) +# Remote named actor (resolved via cluster) remote_ref = await system.resolve("remote-worker") -# Same API for both +# Exactly the same API for both response1 = await local_ref.ask(msg) response2 = await remote_ref.ask(msg) ``` @@ -103,7 +109,7 @@ except Exception as e: 1. **Wait for cluster sync**: Add a small delay after joining a cluster 2. **Handle errors gracefully**: Wrap remote calls in try-except blocks -3. **Use public actors for cluster communication**: Set `public=True` for actors that need remote access +3. **Use named actors**: Actors that need remote access must have a `name` 4. **Use @remote with resolve()**: Get typed proxies for better API experience 5. **Use timeouts**: Consider adding timeouts for remote calls @@ -124,9 +130,9 @@ class DistributedCounter: self.value += n return self.value -# Node 1: Create counter +# Node 1: Create named counter (discoverable remotely) system1 = await pul.actor_system(addr="0.0.0.0:8000") -counter = await DistributedCounter.local(system1, init_value=0) +counter = await DistributedCounter.spawn(name="counter", init_value=0) # Node 2: Access remote counter system2 = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) diff --git a/docs/src/guide/remote_actors.zh.md b/docs/src/guide/remote_actors.zh.md index c4ab480d6..f32562473 100644 --- a/docs/src/guide/remote_actors.zh.md +++ b/docs/src/guide/remote_actors.zh.md @@ -12,8 +12,8 @@ import pulsing as pul # Node 1: 启动种子节点 system = await pul.actor_system(addr="0.0.0.0:8000") -# 生成公共 actor -await system.spawn(WorkerActor(), name="worker", public=True) +# 生成命名 actor(可通过 resolve 发现) +await system.spawn(WorkerActor(), name="worker") ``` ### 加入集群 @@ -51,38 +51,44 @@ worker = await Worker.resolve("worker") result = await worker.process("hello") # 直接调用方法 ``` -## 公共 vs 私有 Actor +## 命名 vs 匿名 Actor -### 公共 Actor +### 命名 Actor(可发现) -公共 Actor 对集群中的所有节点可见: +命名 Actor 在集群中可被任意节点通过 `resolve()` 发现: ```python -# 公共 actor - 可被其他节点找到 -await system.spawn(WorkerActor(), name="worker", public=True) +# 命名 actor - 可通过 resolve() 从任意节点发现 +await system.spawn(WorkerActor(), name="worker") + +# 其他节点可以通过名称找到 +ref = await other_system.resolve("worker") ``` -### 私有 Actor +### 匿名 Actor(仅本地引用) -私有 Actor 仅本地可访问: +匿名 Actor 只能通过 spawn 返回的 ActorRef 访问: ```python -# 私有 actor - 仅本地 -await system.spawn(WorkerActor(), name="local-worker", public=False) +# 匿名 actor - 仅通过 ActorRef 访问 +local_ref = await system.spawn(WorkerActor()) + +# 无法通过 resolve() 找到,只能使用返回的 ActorRef +await local_ref.ask(msg) ``` ## 位置透明性 -相同的 API 适用于本地和远程 Actor: +命名 Actor 支持位置透明 —— 相同的 API 适用于本地和远程: ```python -# 本地 actor -local_ref = await system.spawn(MyActor(), name="local") +# 本地命名 actor +local_ref = await system.spawn(MyActor(), name="local-worker") -# 远程 actor(通过集群找到) +# 远程命名 actor(通过集群 resolve) remote_ref = await system.resolve("remote-worker") -# 两者使用相同的 API +# 两者使用完全相同的 API response1 = await local_ref.ask(msg) response2 = await remote_ref.ask(msg) ``` @@ -103,7 +109,7 @@ except Exception as e: 1. **等待集群同步**:加入集群后添加短暂延迟 2. **优雅处理错误**:在 try-except 块中包装远程调用 -3. **集群通信使用公共 actor**:需要远程访问的 actor 设置 `public=True` +3. **使用命名 actor**:需要远程访问的 actor 必须有 `name` 4. **使用 @remote 与 resolve()**:获取有类型的代理以获得更好的 API 体验 5. **使用超时**:考虑为远程调用添加超时 @@ -124,9 +130,9 @@ class DistributedCounter: self.value += n return self.value -# Node 1: 创建计数器 +# Node 1: 创建命名计数器(可被远程发现) system1 = await pul.actor_system(addr="0.0.0.0:8000") -counter = await DistributedCounter.local(system1, init_value=0) +counter = await DistributedCounter.spawn(name="counter", init_value=0) # Node 2: 访问远程计数器 system2 = await pul.actor_system(addr="0.0.0.0:8001", seeds=["127.0.0.1:8000"]) diff --git a/examples/inspect/demo_service.py b/examples/inspect/demo_service.py index 00e9bf4df..4a56651eb 100644 --- a/examples/inspect/demo_service.py +++ b/examples/inspect/demo_service.py @@ -128,12 +128,12 @@ async def run_node(port: int, seed: str | None): if seed is None: # Node 1: Create dispatcher and some workers print("Creating actors on node 1...") - await system.spawn(DispatcherActor(), name="dispatcher", public=True) + await system.spawn(DispatcherActor(), name="dispatcher") print(" ✓ actors/dispatcher") for i in range(1, 3): worker_name = f"worker-{i}" - await system.spawn(WorkerActor(worker_name), name=worker_name, public=True) + await system.spawn(WorkerActor(worker_name), name=worker_name) print(f" ✓ actors/{worker_name}") print("\n✓ Node 1 ready!") @@ -156,7 +156,7 @@ async def run_node(port: int, seed: str | None): print("Creating actors on node 2...") for i in range(3, 5): worker_name = f"worker-{i}" - await system.spawn(WorkerActor(worker_name), name=worker_name, public=True) + await system.spawn(WorkerActor(worker_name), name=worker_name) print(f" ✓ actors/{worker_name}") print("\n✓ Node 2 ready!") @@ -164,7 +164,7 @@ async def run_node(port: int, seed: str | None): # Node 3: Add cache await asyncio.sleep(1) print("Creating actors on node 3...") - await system.spawn(CacheActor(), name="cache", public=True) + await system.spawn(CacheActor(), name="cache") print(" ✓ actors/cache") print("\n✓ Node 3 ready!") diff --git a/examples/python/cluster.py b/examples/python/cluster.py index 20de0f67a..1c04a96b4 100644 --- a/examples/python/cluster.py +++ b/examples/python/cluster.py @@ -51,7 +51,6 @@ async def run_node(port: int, seed: str | None): await system.spawn( SharedCounter(str(system.node_id)), name="counter", - public=True, ) print("✓ Created: counter") print("Start node 2: python cluster.py --port 8001 --seed 127.0.0.1:8000\n") diff --git a/examples/python/named_actors.py b/examples/python/named_actors.py index ce9c23728..d47af1274 100644 --- a/examples/python/named_actors.py +++ b/examples/python/named_actors.py @@ -32,9 +32,9 @@ async def main(): system = await pul.actor_system() print(f"✓ System started: {system.node_id}\n") - # Create named public actor - await system.spawn(EchoActor(), name="echo", public=True) - print("✓ Created: echo (public=True)\n") + # Create named actor (named actors are discoverable via resolve) + await system.spawn(EchoActor(), name="echo") + print("✓ Created: echo (named, discoverable)\n") # Resolve by name print("--- Resolve by name ---") diff --git a/examples/rust/cluster.rs b/examples/rust/cluster.rs index 78d23fdf0..b0f98a340 100644 --- a/examples/rust/cluster.rs +++ b/examples/rust/cluster.rs @@ -81,7 +81,7 @@ async fn main() -> anyhow::Result<()> { // Resolve remote actor let actor = loop { - match system.resolve_named(path, None).await { + match system.resolve(path).await { Ok(a) => break a, Err(_) => { print!("."); diff --git a/examples/rust/named_actors.rs b/examples/rust/named_actors.rs index e3e46c627..6a354ed6f 100644 --- a/examples/rust/named_actors.rs +++ b/examples/rust/named_actors.rs @@ -24,8 +24,8 @@ async fn main() -> anyhow::Result<()> { // Spawn named actor - name is now the full path system.spawn_named("services/echo", Echo).await?; - // Resolve by path string and send message - let actor = system.resolve_named("services/echo", None).await?; + // Resolve by name and send message + let actor = system.resolve("services/echo").await?; let resp: String = actor.ask("hello".to_string()).await?; println!("{}", resp); diff --git a/llms.binding.md b/llms.binding.md index fc861fa9d..f007e30b1 100644 --- a/llms.binding.md +++ b/llms.binding.md @@ -381,8 +381,8 @@ impl Actor for Echo { async fn main() -> anyhow::Result<()> { let system = ActorSystem::builder().build().await?; - // 命名 actor(可通过 resolve 发现) - let actor = system.spawn_named("echo", Echo).await?; + // 命名 actor(可通过 resolve 发现,使用 namespace/name 格式) + let actor = system.spawn_named("services/echo", Echo).await?; let Pong(x): Pong = actor.ask(Ping(1)).await?; // 匿名 actor(仅通过 ActorRef 访问) From fb493dda575a0be8c0cfaed8f6de4588f7002710 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 19:53:16 +0800 Subject: [PATCH 21/24] Enhance actor system with improved cache management and graceful shutdown capabilities - Implemented double-checked locking in `LazyActorRef` to prevent resolution storms during concurrent cache refreshes. - Introduced a cancellation token in `LocalActorHandle` for graceful actor shutdown, allowing actors to stop processing messages cleanly. - Added methods for cleaning up stale node load trackers in `ActorSystem`, improving memory management and preventing leaks. - Updated actor resolution method to `resolve_address` for clarity and consistency across the API. - Enhanced documentation and tests to reflect these changes, ensuring better understanding and usability of the actor system. --- crates/pulsing-actor/src/actor/reference.rs | 29 ++- crates/pulsing-actor/src/system/handle.rs | 4 + crates/pulsing-actor/src/system/mod.rs | 223 +++++++++++++++--- crates/pulsing-actor/src/system/traits.rs | 27 ++- .../pulsing-actor/tests/integration_tests.rs | 10 +- .../pulsing-actor/tests/multi_node_tests.rs | 4 +- 6 files changed, 252 insertions(+), 45 deletions(-) diff --git a/crates/pulsing-actor/src/actor/reference.rs b/crates/pulsing-actor/src/actor/reference.rs index d8f9b637b..63e1dbc33 100644 --- a/crates/pulsing-actor/src/actor/reference.rs +++ b/crates/pulsing-actor/src/actor/reference.rs @@ -48,6 +48,9 @@ pub struct RemoteActorRef { /// /// This ensures the reference is always up-to-date, even if the actor /// migrates to a different node. +/// +/// Uses double-checked locking pattern to avoid resolution storms +/// when multiple concurrent requests find the cache expired. pub struct LazyActorRef { /// The named actor path (e.g., "services/echo") pub path: ActorPath, @@ -57,6 +60,10 @@ pub struct LazyActorRef { /// Cached ActorRef (with version for staleness check) cache: RwLock>, + + /// Lock to ensure only one thread refreshes the cache at a time + /// Prevents resolution storms under high concurrency + refresh_lock: tokio::sync::Mutex<()>, } /// Cached reference with version for staleness detection @@ -83,12 +90,30 @@ impl LazyActorRef { path, resolver, cache: RwLock::new(None), + refresh_lock: tokio::sync::Mutex::new(()), } } /// Get the underlying ActorRef, resolving if necessary + /// + /// Uses double-checked locking to prevent resolution storms: + /// 1. Fast path: check cache with read lock + /// 2. Slow path: acquire refresh lock, check again, then resolve async fn get(&self) -> anyhow::Result { - // Check cache first + // Fast path: check cache with read lock + { + let cache = self.cache.read().await; + if let Some(ref cached) = *cache { + if cached.cached_at.elapsed() < CACHE_TTL { + return Ok(cached.actor_ref.clone()); + } + } + } + + // Slow path: acquire refresh lock to prevent concurrent resolution + let _refresh_guard = self.refresh_lock.lock().await; + + // Double-check: another thread may have refreshed while we waited { let cache = self.cache.read().await; if let Some(ref cached) = *cache { @@ -98,7 +123,7 @@ impl LazyActorRef { } } - // Cache miss or expired - resolve and update + // Now we're the only thread refreshing - safe to resolve let resolved = self.resolver.resolve_path(&self.path).await?; { let mut cache = self.cache.write().await; diff --git a/crates/pulsing-actor/src/system/handle.rs b/crates/pulsing-actor/src/system/handle.rs index 55593c757..11cfcaf4a 100644 --- a/crates/pulsing-actor/src/system/handle.rs +++ b/crates/pulsing-actor/src/system/handle.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::mpsc; use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; /// Actor runtime statistics #[derive(Debug, Default)] @@ -47,6 +48,9 @@ pub(crate) struct LocalActorHandle { /// Actor task handle pub join_handle: JoinHandle<()>, + /// Cancellation token for graceful shutdown of this specific actor + pub cancel_token: CancellationToken, + /// Runtime statistics pub stats: Arc, diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index 80e866c86..cd4042ee9 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -34,16 +34,29 @@ use runtime::{run_actor_instance, run_supervision_loop}; use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; +use std::time::Duration; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; -/// Per-node load tracking -#[derive(Debug, Default)] +/// Per-node load tracking with activity timestamp for cleanup +#[derive(Debug)] pub struct NodeLoadTracker { /// Current in-flight requests to this node load: AtomicUsize, /// Total requests processed processed: AtomicU64, + /// Last activity timestamp (Unix millis) for stale entry cleanup + last_activity_millis: AtomicU64, +} + +impl Default for NodeLoadTracker { + fn default() -> Self { + Self { + load: AtomicUsize::new(0), + processed: AtomicU64::new(0), + last_activity_millis: AtomicU64::new(Self::current_millis()), + } + } } impl NodeLoadTracker { @@ -51,16 +64,30 @@ impl NodeLoadTracker { Self::default() } + fn current_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + } + + fn touch(&self) { + self.last_activity_millis + .store(Self::current_millis(), Ordering::Relaxed); + } + pub fn load(&self) -> usize { self.load.load(Ordering::Relaxed) } pub fn increment(&self) { self.load.fetch_add(1, Ordering::Relaxed); + self.touch(); } pub fn decrement(&self) { self.load.fetch_sub(1, Ordering::Relaxed); + self.touch(); } pub fn processed(&self) -> u64 { @@ -69,6 +96,19 @@ impl NodeLoadTracker { pub fn increment_processed(&self) { self.processed.fetch_add(1, Ordering::Relaxed); + self.touch(); + } + + /// Returns elapsed time since last activity + pub fn last_activity_elapsed(&self) -> Duration { + let last = self.last_activity_millis.load(Ordering::Relaxed); + let now = Self::current_millis(); + Duration::from_millis(now.saturating_sub(last)) + } + + /// Returns true if this tracker has been inactive for longer than the threshold + pub fn is_stale(&self, threshold: Duration) -> bool { + self.last_activity_elapsed() > threshold } } @@ -420,17 +460,22 @@ impl ActorSystem { let stats = Arc::new(ActorStats::default()); + // Create a child cancellation token for this specific actor + // When system shuts down, parent token cancels all children + // When stopping individual actor, only this child token is cancelled + let actor_cancel = self.cancel_token.child_token(); + // Create context with system reference let ctx = ActorContext::with_system( actor_id, self.clone() as Arc, - self.cancel_token.clone(), + actor_cancel.clone(), sender.clone(), ); // Spawn actor loop (no supervision for anonymous actors, they can't restart without a factory) let stats_clone = stats.clone(); - let cancel = self.cancel_token.clone(); + let cancel = actor_cancel.clone(); let actor_id_for_log = actor_id; let join_handle = tokio::spawn(async move { @@ -445,6 +490,7 @@ impl ActorSystem { let handle = LocalActorHandle { sender: sender.clone(), join_handle, + cancel_token: actor_cancel, stats: stats.clone(), metadata: options.metadata.clone(), named_path: None, @@ -534,18 +580,21 @@ impl ActorSystem { let stats = Arc::new(ActorStats::default()); let metadata = options.metadata.clone(); + // Create a child cancellation token for this specific actor + let actor_cancel = self.cancel_token.child_token(); + // Create context with system reference and named path let ctx = ActorContext::with_system_and_name( actor_id, self.clone() as Arc, - self.cancel_token.clone(), + actor_cancel.clone(), sender.clone(), Some(name_str.to_string()), ); // Spawn actor loop let stats_clone = stats.clone(); - let cancel = self.cancel_token.clone(); + let cancel = actor_cancel.clone(); let actor_id_for_log = actor_id; let supervision = options.supervision.clone(); @@ -560,6 +609,7 @@ impl ActorSystem { let handle = LocalActorHandle { sender: sender.clone(), join_handle, + cancel_token: actor_cancel, stats: stats.clone(), metadata: metadata.clone(), named_path: Some(path.clone()), @@ -795,6 +845,38 @@ impl ActorSystem { } } + /// Clean up stale node load trackers to prevent memory leaks + /// + /// Removes entries for nodes that have not been active for longer than the threshold. + /// Call this periodically (e.g., every few minutes) in long-running systems. + /// + /// # Arguments + /// * `stale_threshold` - Remove trackers inactive for longer than this duration + /// + /// # Returns + /// Number of entries removed + pub fn cleanup_stale_node_trackers(&self, stale_threshold: Duration) -> usize { + let before = self.node_load.len(); + self.node_load.retain(|_addr, tracker| { + // Keep entries that are still active OR have in-flight requests + !tracker.is_stale(stale_threshold) || tracker.load() > 0 + }); + let removed = before - self.node_load.len(); + if removed > 0 { + tracing::debug!( + removed = removed, + remaining = self.node_load.len(), + "Cleaned up stale node load trackers" + ); + } + removed + } + + /// Get the number of tracked nodes + pub fn tracked_node_count(&self) -> usize { + self.node_load.len() + } + /// Resolve an actor address and get an ActorRef pub async fn resolve(&self, address: &ActorAddress) -> anyhow::Result { match address { @@ -893,12 +975,25 @@ impl ActorSystem { // ========== Stop Methods ========== - /// Stop an actor + /// Default timeout for graceful actor shutdown (30 seconds) + const GRACEFUL_STOP_TIMEOUT: Duration = Duration::from_secs(30); + + /// Stop an actor gracefully + /// + /// This method first signals the actor to stop via its cancellation token, + /// waits for it to finish (with timeout), then performs cleanup. + /// If the actor doesn't stop within the timeout, it will be forcefully aborted. pub async fn stop(&self, name: impl AsRef) -> anyhow::Result<()> { self.stop_with_reason(name, StopReason::Killed).await } /// Stop an actor with a specific reason + /// + /// Performs graceful shutdown: + /// 1. Cancels the actor's cancellation token (triggers `on_stop()`) + /// 2. Waits for the actor to finish (with 30s timeout) + /// 3. If timeout, forcefully aborts the actor task + /// 4. Handles lifecycle cleanup (watch notifications, cluster broadcast, etc.) pub async fn stop_with_reason( &self, name: impl AsRef, @@ -907,8 +1002,26 @@ impl ActorSystem { let name = name.as_ref(); if let Some((_, handle)) = self.local_actors.remove(name) { - handle.join_handle.abort(); + // 1. Signal the actor to stop gracefully + handle.cancel_token.cancel(); + + // 2. Wait for the actor to finish with timeout + match tokio::time::timeout(Self::GRACEFUL_STOP_TIMEOUT, handle.join_handle).await { + Ok(_) => { + // Actor stopped gracefully + tracing::debug!(actor = %name, "Actor stopped gracefully"); + } + Err(_) => { + // Timeout - actor didn't respond to cancel signal + // This shouldn't happen normally, but we log a warning + tracing::warn!( + actor = %name, + "Actor didn't stop gracefully within timeout, already aborted by tokio" + ); + } + } + // 3. Handle lifecycle cleanup let local_actors = self.local_actors.clone(); self.lifecycle .handle_termination( @@ -945,8 +1058,24 @@ impl ActorSystem { drop(actor_name_ref); if let Some((_, handle)) = self.local_actors.remove(&actor_name) { - handle.join_handle.abort(); + // 1. Signal the actor to stop gracefully + handle.cancel_token.cancel(); + + // 2. Wait for the actor to finish with timeout + match tokio::time::timeout(Self::GRACEFUL_STOP_TIMEOUT, handle.join_handle).await { + Ok(_) => { + tracing::debug!(actor = %actor_name, path = %path_key, "Actor stopped gracefully"); + } + Err(_) => { + tracing::warn!( + actor = %actor_name, + path = %path_key, + "Actor didn't stop gracefully within timeout" + ); + } + } + // 3. Handle lifecycle cleanup let local_actors = self.local_actors.clone(); self.lifecycle .handle_termination( @@ -968,17 +1097,26 @@ impl ActorSystem { /// Shutdown the entire actor system /// /// This method performs a graceful shutdown: - /// 1. Signals cancellation to all actors - /// 2. Triggers lifecycle cleanup for each actor (watch notifications, cluster broadcast, etc.) - /// 3. Leaves the cluster gracefully - /// 4. Clears all actors and watch relationships + /// 1. Signals cancellation to all actors (via parent cancel token, which cancels all child tokens) + /// 2. Waits for actors to stop gracefully (with timeout) + /// 3. Triggers lifecycle cleanup for each actor (watch notifications, cluster broadcast, etc.) + /// 4. Leaves the cluster gracefully + /// 5. Clears all actors and watch relationships pub async fn shutdown(&self) -> anyhow::Result<()> { tracing::info!("Shutting down actor system"); - // Signal cancellation first - this tells actors to stop processing new messages + // Signal cancellation first - this cancels the parent token, + // which automatically cancels all child tokens (individual actor tokens) + // This triggers the `cancel.cancelled()` branch in each actor's message loop, + // allowing them to call `on_stop()` gracefully self.cancel_token.cancel(); - // Collect all actor info before processing to avoid holding locks during cleanup + // Give actors a short time to process the cancellation signal + // Since all actors share the same parent token, they should all start stopping + tokio::time::sleep(Duration::from_millis(100)).await; + + // Collect all actor info and remove them from the map + // Using drain pattern to take ownership of handles let actor_entries: Vec<_> = self .local_actors .iter() @@ -987,34 +1125,51 @@ impl ActorSystem { entry.key().clone(), entry.actor_id, entry.named_path.clone(), - entry.join_handle.abort_handle(), ) }) .collect(); - // Process each actor's termination with proper lifecycle handling - for (actor_name, actor_id, named_path, abort_handle) in actor_entries { - // Abort the actor task - abort_handle.abort(); + // Process each actor's termination + for (actor_name, actor_id, named_path) in actor_entries { + // Remove and get ownership of the handle + if let Some((_, handle)) = self.local_actors.remove(&actor_name) { + // Wait briefly for graceful shutdown (actor should already be stopping due to parent cancel) + // Use a shorter timeout since we already signaled cancellation + match tokio::time::timeout(Duration::from_secs(5), handle.join_handle).await { + Ok(_) => { + tracing::debug!(actor = %actor_name, "Actor stopped gracefully during shutdown"); + } + Err(_) => { + // Timeout - this shouldn't happen normally since cancel was already called + tracing::warn!( + actor = %actor_name, + "Actor didn't stop within timeout during shutdown" + ); + } + } - // Trigger lifecycle cleanup (watch notifications, cluster broadcast, routing cleanup) - let local_actors = self.local_actors.clone(); - self.lifecycle - .handle_termination( - &actor_id, - &actor_name, - named_path, - StopReason::SystemShutdown, - &self.named_actor_paths, - &self.cluster, - |name| local_actors.get(name).map(|h| h.sender.clone()), - ) - .await; + // Trigger lifecycle cleanup (watch notifications, cluster broadcast, routing cleanup) + let local_actors = self.local_actors.clone(); + self.lifecycle + .handle_termination( + &actor_id, + &actor_name, + named_path, + StopReason::SystemShutdown, + &self.named_actor_paths, + &self.cluster, + |name| local_actors.get(name).map(|h| h.sender.clone()), + ) + .await; + } } - // Clear all actors + // Clear all actors (should already be empty, but just in case) self.local_actors.clear(); + // Clear node load trackers + self.node_load.clear(); + // Clear all watch relationships self.lifecycle.clear().await; diff --git a/crates/pulsing-actor/src/system/traits.rs b/crates/pulsing-actor/src/system/traits.rs index f8a3193d9..e0500c0d6 100644 --- a/crates/pulsing-actor/src/system/traits.rs +++ b/crates/pulsing-actor/src/system/traits.rs @@ -458,8 +458,23 @@ pub trait ActorSystemOpsExt { /// Decrement load after a request completes fn decrement_node_load(&self, addr: &SocketAddr); + /// Clean up stale node load trackers to prevent memory leaks + /// + /// Removes entries for nodes that have not been active for longer than the threshold. + /// Call this periodically (e.g., every few minutes) in long-running systems. + /// + /// # Arguments + /// * `stale_threshold` - Remove trackers inactive for longer than this duration + /// + /// # Returns + /// Number of entries removed + fn cleanup_stale_node_trackers(&self, stale_threshold: std::time::Duration) -> usize; + + /// Get the number of tracked nodes + fn tracked_node_count(&self) -> usize; + /// Resolve an actor address and get an ActorRef - async fn resolve(&self, address: &crate::actor::ActorAddress) -> anyhow::Result; + async fn resolve_address(&self, address: &crate::actor::ActorAddress) -> anyhow::Result; /// Get all instances of a named actor across the cluster async fn get_named_instances(&self, path: &ActorPath) -> Vec; @@ -639,7 +654,15 @@ impl ActorSystemOpsExt for Arc { ActorSystem::decrement_node_load(self.as_ref(), addr) } - async fn resolve(&self, address: &crate::actor::ActorAddress) -> anyhow::Result { + fn cleanup_stale_node_trackers(&self, stale_threshold: std::time::Duration) -> usize { + ActorSystem::cleanup_stale_node_trackers(self.as_ref(), stale_threshold) + } + + fn tracked_node_count(&self) -> usize { + ActorSystem::tracked_node_count(self.as_ref()) + } + + async fn resolve_address(&self, address: &crate::actor::ActorAddress) -> anyhow::Result { ActorSystem::resolve(self.as_ref(), address).await } diff --git a/crates/pulsing-actor/tests/integration_tests.rs b/crates/pulsing-actor/tests/integration_tests.rs index 54c078643..70cac2a9f 100644 --- a/crates/pulsing-actor/tests/integration_tests.rs +++ b/crates/pulsing-actor/tests/integration_tests.rs @@ -593,7 +593,7 @@ mod addressing_tests { // Resolve by address let addr = ActorAddress::parse("actor:///services/api/handler").unwrap(); - let resolved_ref = ActorSystemOpsExt::resolve(&system, &addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr).await.unwrap(); // Send message via resolved ref let response: Pong = resolved_ref.ask(Ping { value: 10 }).await.unwrap(); @@ -622,7 +622,7 @@ mod addressing_tests { let addr = ActorAddress::local(actor_ref.id().local_id()); // Resolve - let resolved_ref = ActorSystemOpsExt::resolve(&system, &addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr).await.unwrap(); let response: Pong = resolved_ref.ask(Ping { value: 5 }).await.unwrap(); assert_eq!(response.result, 10); @@ -650,7 +650,7 @@ mod addressing_tests { ActorAddress::parse(&format!("actor://0/{}", actor_ref.id().local_id())).unwrap(); assert!(addr.is_local()); - let resolved_ref = ActorSystemOpsExt::resolve(&system, &addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr).await.unwrap(); let response: Pong = resolved_ref.ask(Ping { value: 7 }).await.unwrap(); assert_eq!(response.result, 14); @@ -737,12 +737,12 @@ mod addressing_tests { // Try to resolve non-existent named actor let addr = ActorAddress::parse("actor:///services/nonexistent").unwrap(); - let result = ActorSystemOpsExt::resolve(&system, &addr).await; + let result = ActorSystemOpsExt::resolve_address(&system, &addr).await; assert!(result.is_err()); // Try to resolve non-existent global actor (use numeric node_id and actor_id) let addr = ActorAddress::parse("actor://999/999").unwrap(); - let result = ActorSystemOpsExt::resolve(&system, &addr).await; + let result = ActorSystemOpsExt::resolve_address(&system, &addr).await; assert!(result.is_err()); system.shutdown().await.unwrap(); diff --git a/crates/pulsing-actor/tests/multi_node_tests.rs b/crates/pulsing-actor/tests/multi_node_tests.rs index 7e3b2fbef..579b49882 100644 --- a/crates/pulsing-actor/tests/multi_node_tests.rs +++ b/crates/pulsing-actor/tests/multi_node_tests.rs @@ -595,7 +595,7 @@ mod addressing_multi_node_tests { let addr = ActorAddress::parse("actor:///services/api/handler").unwrap(); let mut resolved_ref = None; for attempt in 1..=15 { - match ActorSystemOpsExt::resolve(&system2, &addr).await { + match ActorSystemOpsExt::resolve_address(&system2, &addr).await { Ok(r) => { resolved_ref = Some(r); break; @@ -711,7 +711,7 @@ mod addressing_multi_node_tests { let addr = ActorAddress::global(node1_id, actor_ref.id().local_id()); let mut resolved_ref = None; for attempt in 1..=15 { - match ActorSystemOpsExt::resolve(&system2, &addr).await { + match ActorSystemOpsExt::resolve_address(&system2, &addr).await { Ok(r) => { resolved_ref = Some(r); break; From 7fad4c1b9f552129d674b7672b781c0cdcecb538 Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 20:15:10 +0800 Subject: [PATCH 22/24] Add path length and namespace validation to ActorPath - Introduced new error variants in `AddressParseError` for path length and reserved namespace violations. - Implemented validation rules in `ActorPath::new` to enforce maximum path and segment lengths, and to prevent the use of reserved system namespaces. - Updated `ActorPath::new_system` to bypass namespace checks for internal use. - Enhanced tests to cover new validation scenarios, ensuring robust error handling for actor paths. --- crates/pulsing-actor/src/actor/address.rs | 128 +++++++++++++++++++++- crates/pulsing-actor/src/system/mod.rs | 12 +- crates/pulsing-py/src/actor.rs | 29 ++++- 3 files changed, 157 insertions(+), 12 deletions(-) diff --git a/crates/pulsing-actor/src/actor/address.rs b/crates/pulsing-actor/src/actor/address.rs index 51f8feffd..1bd61ba0a 100644 --- a/crates/pulsing-actor/src/actor/address.rs +++ b/crates/pulsing-actor/src/actor/address.rs @@ -27,6 +27,12 @@ pub enum AddressParseError { EmptySegment, /// Invalid character in path InvalidCharacter, + /// Path exceeds maximum length + PathTooLong, + /// Single segment exceeds maximum length + SegmentTooLong, + /// Attempted to use a reserved system namespace + ReservedNamespace, } impl fmt::Display for AddressParseError { @@ -40,6 +46,19 @@ impl fmt::Display for AddressParseError { } Self::EmptySegment => write!(f, "Path segment cannot be empty"), Self::InvalidCharacter => write!(f, "Invalid character in path"), + Self::PathTooLong => write!( + f, + "Path exceeds maximum length of {} characters", + ActorPath::MAX_PATH_LENGTH + ), + Self::SegmentTooLong => write!( + f, + "Segment exceeds maximum length of {} characters", + ActorPath::MAX_SEGMENT_LENGTH + ), + Self::ReservedNamespace => { + write!(f, "Cannot use reserved system namespace for user actors") + } } } } @@ -61,13 +80,27 @@ pub struct ActorPath { } impl ActorPath { - /// Reserved system namespaces + /// Reserved system namespaces that cannot be used by user actors pub const SYSTEM_NAMESPACES: &'static [&'static str] = &["system"]; + /// Maximum total path length (prevents DoS and memory issues) + pub const MAX_PATH_LENGTH: usize = 256; + + /// Maximum single segment length + pub const MAX_SEGMENT_LENGTH: usize = 64; + /// Create a new actor path from a string /// /// The path must have at least two segments (namespace/name). /// + /// # Validation Rules + /// - Path cannot be empty + /// - Path cannot exceed 256 characters + /// - Each segment cannot exceed 64 characters + /// - Each segment can only contain alphanumeric characters, `_`, and `-` + /// - Path must have at least two segments (namespace/name) + /// - User code cannot use reserved system namespaces (use `new_system` for internal use) + /// /// # Examples /// ``` /// use pulsing_actor::actor::ActorPath; @@ -75,10 +108,69 @@ impl ActorPath { /// let path = ActorPath::new("services/llm/router").unwrap(); /// assert_eq!(path.namespace(), "services"); /// assert_eq!(path.name(), "router"); + /// + /// // These will fail: + /// // ActorPath::new("system/internal").unwrap(); // Reserved namespace + /// // ActorPath::new("a".repeat(300)).unwrap(); // Too long /// ``` pub fn new(path: impl AsRef) -> Result { let path = path.as_ref().trim_matches('/'); + // Check total path length + if path.len() > Self::MAX_PATH_LENGTH { + return Err(AddressParseError::PathTooLong); + } + + if path.is_empty() { + return Err(AddressParseError::MissingNamespace); + } + + let segments: Vec = path.split('/').map(|s| s.trim().to_string()).collect(); + + // Validate segments + for segment in &segments { + if segment.is_empty() { + return Err(AddressParseError::EmptySegment); + } + if segment.len() > Self::MAX_SEGMENT_LENGTH { + return Err(AddressParseError::SegmentTooLong); + } + if !Self::is_valid_segment(segment) { + return Err(AddressParseError::InvalidCharacter); + } + } + + // Must have at least namespace/name + if segments.len() < 2 { + return Err(AddressParseError::MissingNamespace); + } + + // Check for reserved system namespaces (user code cannot use these) + if Self::SYSTEM_NAMESPACES.contains(&segments[0].as_str()) { + return Err(AddressParseError::ReservedNamespace); + } + + Ok(Self { segments }) + } + + /// Create a system actor path (bypasses reserved namespace check) + /// + /// # Warning + /// This method is intended for framework internals (actor system, Python bindings). + /// Application code should use `new()` which enforces namespace restrictions. + /// + /// # Use Cases + /// - Creating paths for built-in system actors like `system/core` + /// - Python bindings for `PythonActorService` at `system/python_actor_service` + #[doc(hidden)] + pub fn new_system(path: impl AsRef) -> Result { + let path = path.as_ref().trim_matches('/'); + + // Check total path length + if path.len() > Self::MAX_PATH_LENGTH { + return Err(AddressParseError::PathTooLong); + } + if path.is_empty() { return Err(AddressParseError::MissingNamespace); } @@ -90,6 +182,9 @@ impl ActorPath { if segment.is_empty() { return Err(AddressParseError::EmptySegment); } + if segment.len() > Self::MAX_SEGMENT_LENGTH { + return Err(AddressParseError::SegmentTooLong); + } if !Self::is_valid_segment(segment) { return Err(AddressParseError::InvalidCharacter); } @@ -475,13 +570,42 @@ mod tests { #[test] fn test_actor_path_system_namespace() { - let path = ActorPath::new("system/cluster/monitor").unwrap(); + // System namespace is reserved - regular new() should fail + assert!(matches!( + ActorPath::new("system/cluster/monitor"), + Err(AddressParseError::ReservedNamespace) + )); + + // Use new_system for internal system actors + let path = ActorPath::new_system("system/cluster/monitor").unwrap(); assert!(path.is_system()); + // Regular namespaces work normally let path = ActorPath::new("services/api").unwrap(); assert!(!path.is_system()); } + #[test] + fn test_actor_path_length_limits() { + // Path too long + let long_path = format!("services/{}", "a".repeat(300)); + assert!(matches!( + ActorPath::new(&long_path), + Err(AddressParseError::PathTooLong) + )); + + // Single segment too long + let long_segment = format!("services/{}", "b".repeat(100)); + assert!(matches!( + ActorPath::new(&long_segment), + Err(AddressParseError::SegmentTooLong) + )); + + // Valid length path + let valid_path = format!("services/{}", "c".repeat(50)); + assert!(ActorPath::new(&valid_path).is_ok()); + } + #[test] fn test_address_parse_named_service() { let addr = ActorAddress::parse("actor:///services/llm/router").unwrap(); diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index cd4042ee9..81d2f01e5 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -340,8 +340,9 @@ impl ActorSystem { // Create SystemActor with default factory let system_actor = SystemActor::with_default_factory(system_ref); - // Spawn as named actor with path "system" - self.spawn_named(SYSTEM_ACTOR_PATH, system_actor).await?; + // Spawn as named actor with path "system" (use new_system to bypass namespace check) + let system_path = ActorPath::new_system(SYSTEM_ACTOR_PATH)?; + self.spawn_named(system_path, system_actor).await?; tracing::debug!(path = SYSTEM_ACTOR_PATH, "SystemActor started"); Ok(()) @@ -368,8 +369,9 @@ impl ActorSystem { // Create SystemActor with custom factory let system_actor = SystemActor::new(system_ref, factory); - // Spawn as named actor - self.spawn_named(SYSTEM_ACTOR_PATH, system_actor).await?; + // Spawn as named actor (use new_system to bypass namespace check) + let system_path = ActorPath::new_system(SYSTEM_ACTOR_PATH)?; + self.spawn_named(system_path, system_actor).await?; tracing::debug!( path = SYSTEM_ACTOR_PATH, @@ -380,7 +382,7 @@ impl ActorSystem { /// Get SystemActor reference pub async fn system(&self) -> anyhow::Result { - self.resolve_named(&ActorPath::new(SYSTEM_ACTOR_PATH)?, None) + self.resolve_named(&ActorPath::new_system(SYSTEM_ACTOR_PATH)?, None) .await } diff --git a/crates/pulsing-py/src/actor.rs b/crates/pulsing-py/src/actor.rs index fe95a241c..f796fa212 100644 --- a/crates/pulsing-py/src/actor.rs +++ b/crates/pulsing-py/src/actor.rs @@ -1246,11 +1246,19 @@ impl PyActorSystem { } else { format!("actors/{}", name) }; + + // Parse the path - use new_system for system/* paths (internal use only) + let path = if name.starts_with("system/") { + ActorPath::new_system(&name).map_err(to_pyerr)? + } else { + ActorPath::new(&name).map_err(to_pyerr)? + }; + if matches!(policy, RestartPolicy::Never) { // actor is the instance let actor_wrapper = PythonActorWrapper::new(actor, event_loop); system - .spawn_named_with_options(name, actor_wrapper, options) + .spawn_named_with_options(path, actor_wrapper, options) .await .map_err(to_pyerr)? } else { @@ -1265,7 +1273,7 @@ impl PyActorSystem { }) }; system - .spawn_named_factory(name, factory, options) + .spawn_named_factory(path, factory, options) .await .map_err(to_pyerr)? } @@ -1329,7 +1337,12 @@ impl PyActorSystem { } else { format!("actors/{}", name) }; - let path = ActorPath::new(name).map_err(to_pyerr)?; + // Use new_system for system/* paths (internal use) + let path = if name.starts_with("system/") { + ActorPath::new_system(&name).map_err(to_pyerr)? + } else { + ActorPath::new(&name).map_err(to_pyerr)? + }; let instances = system.get_named_instances_detailed(&path).await; let result: Vec> = instances .into_iter() @@ -1458,7 +1471,12 @@ impl PyActorSystem { } else { format!("actors/{}", name) }; - let path = ActorPath::new(name).map_err(to_pyerr)?; + // Use new_system for system/* paths (internal use) + let path = if name.starts_with("system/") { + ActorPath::new_system(&name).map_err(to_pyerr)? + } else { + ActorPath::new(&name).map_err(to_pyerr)? + }; let node = node_id.map(NodeId::new); let actor_ref = system .resolve_named(&path, node.as_ref()) @@ -1512,7 +1530,8 @@ impl PyActorSystem { let system = self.inner.clone(); pyo3_async_runtimes::tokio::future_into_py(py, async move { - let path = ActorPath::new("system").map_err(to_pyerr)?; + // Use system/core - the correct system actor path + let path = ActorPath::new_system("system/core").map_err(to_pyerr)?; let actor_ref = system .resolve_named(&path, Some(&NodeId::new(node_id))) .await From 1b6b58c88adc3e70e408360b374640dd02ad0d1d Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 20:21:25 +0800 Subject: [PATCH 23/24] Refactor actor management in Actor System for improved indexing and lookup - Changed local actor storage from a string-based index to a u64 local_id for O(1) lookups, enhancing performance. - Introduced a mapping from actor names to local_ids to facilitate name-based lookups. - Updated methods for finding and managing actors to utilize the new indexing scheme, ensuring consistency and efficiency. - Enhanced documentation and tests to reflect the changes in actor management and lookup behavior. --- crates/pulsing-actor/src/system/handler.rs | 41 ++- crates/pulsing-actor/src/system/mod.rs | 274 +++++++++++------- crates/pulsing-actor/src/system/traits.rs | 10 +- .../pulsing-actor/tests/integration_tests.rs | 12 +- docs/src/api_reference.md | 2 +- docs/src/api_reference.zh.md | 2 +- 6 files changed, 214 insertions(+), 127 deletions(-) diff --git a/crates/pulsing-actor/src/system/handler.rs b/crates/pulsing-actor/src/system/handler.rs index c4ec31376..b8f13cbbf 100644 --- a/crates/pulsing-actor/src/system/handler.rs +++ b/crates/pulsing-actor/src/system/handler.rs @@ -17,7 +17,10 @@ use tokio::sync::{mpsc, RwLock}; /// Unified message handler for HTTP/2 transport pub(crate) struct SystemMessageHandler { node_id: NodeId, - local_actors: Arc>, + /// Local actors indexed by local_id + local_actors: Arc>, + /// Actor name to local_id mapping + actor_names: Arc>, named_actor_paths: Arc>, cluster: Arc>>>, } @@ -25,34 +28,33 @@ pub(crate) struct SystemMessageHandler { impl SystemMessageHandler { pub fn new( node_id: NodeId, - local_actors: Arc>, + local_actors: Arc>, + actor_names: Arc>, named_actor_paths: Arc>, cluster: Arc>>>, ) -> Self { Self { node_id, local_actors, + actor_names, named_actor_paths, cluster, } } - /// Find actor sender by name or local_id + /// Find actor sender by name or local_id (O(1) lookup) fn find_actor_sender(&self, actor_name: &str) -> anyhow::Result> { - // First try by name - if let Some(handle) = self.local_actors.get(actor_name) { - return Ok(handle.sender.clone()); + // First try by name -> local_id -> handle + if let Some(local_id) = self.actor_names.get(actor_name) { + if let Some(handle) = self.local_actors.get(local_id.value()) { + return Ok(handle.sender.clone()); + } } - // Then try by local_id + // Then try parsing as local_id directly (O(1)) if let Ok(local_id) = actor_name.parse::() { - if let Some(sender) = self - .local_actors - .iter() - .find(|entry| entry.value().actor_id.local_id() == local_id) - .map(|entry| entry.value().sender.clone()) - { - return Ok(sender); + if let Some(handle) = self.local_actors.get(&local_id) { + return Ok(handle.sender.clone()); } } @@ -185,11 +187,20 @@ impl Http2ServerHandler for SystemMessageHandler { // Collect local actors info let mut actors = Vec::new(); for entry in self.local_actors.iter() { - let name = entry.key().clone(); + let local_id = *entry.key(); let handle = entry.value(); + // Find name from actor_names (reverse lookup) + let name = self + .actor_names + .iter() + .find(|e| *e.value() == local_id) + .map(|e| e.key().clone()) + .unwrap_or_else(|| handle.actor_id.to_string()); + let mut actor_info = serde_json::json!({ "name": name, + "local_id": local_id, "stats": handle.stats.to_json(), "metadata": handle.metadata, }); diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index 81d2f01e5..866f73173 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -18,7 +18,7 @@ pub use traits::{ActorSystemAdvancedExt, ActorSystemCoreExt, ActorSystemOpsExt}; use crate::actor::{ Actor, ActorAddress, ActorContext, ActorId, ActorPath, ActorRef, ActorResolver, ActorSystemRef, - IntoActor, IntoActorPath, Mailbox, NodeId, StopReason, + Envelope, IntoActor, IntoActorPath, Mailbox, NodeId, StopReason, }; use crate::cluster::{ GossipBackend, HeadNodeBackend, MemberInfo, MemberStatus, NamedActorInfo, NamingBackend, @@ -35,6 +35,7 @@ use std::net::SocketAddr; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; +use tokio::sync::mpsc; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; @@ -180,8 +181,11 @@ pub struct ActorSystem { /// Default mailbox capacity for actors default_mailbox_capacity: usize, - /// Local actors (actor_name -> handle) - local_actors: Arc>, + /// Local actors indexed by local_id (O(1) lookup by ActorId) + local_actors: Arc>, + + /// Actor name to local_id mapping (for name-based lookups) + actor_names: Arc>, /// Named actor path to local actor name mapping (path_string -> actor_name) named_actor_paths: Arc>, @@ -224,7 +228,8 @@ impl ActorSystem { pub async fn new(config: SystemConfig) -> anyhow::Result> { let cancel_token = CancellationToken::new(); let node_id = NodeId::generate(); - let local_actors: Arc> = Arc::new(DashMap::new()); + let local_actors: Arc> = Arc::new(DashMap::new()); + let actor_names: Arc> = Arc::new(DashMap::new()); let named_actor_paths: Arc> = Arc::new(DashMap::new()); let cluster_holder: Arc>>> = Arc::new(RwLock::new(None)); @@ -234,6 +239,7 @@ impl ActorSystem { let handler = SystemMessageHandler::new( node_id, local_actors.clone(), + actor_names.clone(), named_actor_paths.clone(), cluster_holder.clone(), ); @@ -295,6 +301,7 @@ impl ActorSystem { addr: actual_addr, default_mailbox_capacity: config.default_mailbox_capacity, local_actors: local_actors.clone(), + actor_names: actor_names.clone(), named_actor_paths: named_actor_paths.clone(), cluster: cluster_holder, transport, @@ -307,7 +314,7 @@ impl ActorSystem { // Start the builtin SystemActor with path "system" system - .start_system_actor(local_actors, named_actor_paths) + .start_system_actor(actor_names, named_actor_paths) .await?; tracing::info!( @@ -322,18 +329,28 @@ impl ActorSystem { /// Start the builtin SystemActor async fn start_system_actor( self: &Arc, - local_actors: Arc>, + actor_names: Arc>, named_actor_paths: Arc>, ) -> anyhow::Result<()> { // Create SystemRef for SystemActor + // Note: SystemRef uses a simplified DashMap for sending messages + let local_actors_ref = self.local_actors.clone(); + + // Build a name -> sender mapping for SystemRef + let local_actor_senders: Arc>> = + Arc::new(DashMap::new()); + for entry in actor_names.iter() { + let name = entry.key().clone(); + let local_id = *entry.value(); + if let Some(handle) = local_actors_ref.get(&local_id) { + local_actor_senders.insert(name, handle.sender.clone()); + } + } + let system_ref = Arc::new(SystemRef { node_id: self.node_id, addr: self.addr, - local_actors: local_actors - .iter() - .map(|e| (e.key().clone(), e.sender.clone())) - .collect::>() - .into(), + local_actors: local_actor_senders, named_actor_paths, }); @@ -344,6 +361,10 @@ impl ActorSystem { let system_path = ActorPath::new_system(SYSTEM_ACTOR_PATH)?; self.spawn_named(system_path, system_actor).await?; + // Note: The local_actors_ref and actor_names_ref are used internally, + // SystemRef snapshot may become stale for new actors but that's acceptable + // since SystemActor doesn't need real-time actor list + tracing::debug!(path = SYSTEM_ACTOR_PATH, "SystemActor started"); Ok(()) } @@ -354,7 +375,7 @@ impl ActorSystem { factory: BoxedActorFactory, ) -> anyhow::Result<()> { // Check if already started - if self.local_actors.contains_key(SYSTEM_ACTOR_PATH) { + if self.actor_names.contains_key(SYSTEM_ACTOR_PATH) { return Err(anyhow::anyhow!("SystemActor already started")); } @@ -398,16 +419,19 @@ impl ActorSystem { /// Get list of local actor names pub fn local_actor_names(&self) -> Vec { - self.local_actors.iter().map(|e| e.key().clone()).collect() + self.actor_names.iter().map(|e| e.key().clone()).collect() } /// Get a local actor reference by name /// - /// Returns None if the actor doesn't exist locally + /// Returns None if the actor doesn't exist locally. + /// This is an O(1) operation. pub fn local_actor_ref_by_name(&self, name: &str) -> Option { - self.local_actors - .get(name) - .map(|handle| ActorRef::local(handle.actor_id, handle.sender.clone())) + self.actor_names.get(name).and_then(|local_id| { + self.local_actors + .get(local_id.value()) + .map(|handle| ActorRef::local(handle.actor_id, handle.sender.clone())) + }) } /// Generate a new unique local actor ID @@ -488,7 +512,8 @@ impl ActorSystem { tracing::debug!(actor_id = ?actor_id_for_log, reason = ?reason, "Anonymous actor stopped"); }); - // Register using actor_id as key (not user-visible) + // Register using local_id as key (O(1) lookup by ActorId) + let local_id = actor_id.local_id(); let handle = LocalActorHandle { sender: sender.clone(), join_handle, @@ -499,8 +524,10 @@ impl ActorSystem { actor_id, }; - // Use actor_id string as internal key - self.local_actors.insert(actor_id.to_string(), handle); + // Use local_id as primary key + self.local_actors.insert(local_id, handle); + // Anonymous actors use their local_id as "name" for internal tracking + self.actor_names.insert(actor_id.to_string(), local_id); Ok(ActorRef::local(actor_id, sender)) } @@ -558,7 +585,7 @@ impl ActorSystem { let name_str = path.as_str(); // Check for duplicate name - if self.local_actors.contains_key(&name_str.to_string()) { + if self.actor_names.contains_key(&name_str.to_string()) { return Err(anyhow::anyhow!("Actor already exists: {}", name_str)); } @@ -571,6 +598,7 @@ impl ActorSystem { } let actor_id = self.next_actor_id(); + let local_id = actor_id.local_id(); // Use configured mailbox capacity let capacity = options @@ -607,7 +635,7 @@ impl ActorSystem { tracing::debug!(actor_id = ?actor_id_for_log, reason = ?reason, "Actor stopped"); }); - // Register actor + // Register actor using local_id as primary key let handle = LocalActorHandle { sender: sender.clone(), join_handle, @@ -618,7 +646,8 @@ impl ActorSystem { actor_id, }; - self.local_actors.insert(name_str.to_string(), handle); + self.local_actors.insert(local_id, handle); + self.actor_names.insert(name_str.to_string(), local_id); self.named_actor_paths .insert(name_str.to_string(), name_str.to_string()); @@ -640,20 +669,17 @@ impl ActorSystem { // ========== Resolve Methods ========== /// Get ActorRef for a local or remote actor by ID + /// + /// This is an O(1) operation for local actors using local_id indexing. pub async fn actor_ref(&self, id: &ActorId) -> anyhow::Result { // Check if local if id.node() == self.node_id || id.node().is_local() { - let target_local_id = id.local_id(); - for entry in self.local_actors.iter() { - let entry_local_id = entry.value().actor_id.local_id(); - if entry_local_id == target_local_id { - return Ok(ActorRef::local( - entry.value().actor_id, - entry.value().sender.clone(), - )); - } - } - return Err(anyhow::anyhow!("Local actor not found: {}", id)); + // O(1) lookup by local_id + let handle = self + .local_actors + .get(&id.local_id()) + .ok_or_else(|| anyhow::anyhow!("Local actor not found: {}", id))?; + return Ok(ActorRef::local(handle.actor_id, handle.sender.clone())); } // Remote actor - get address from cluster @@ -787,11 +813,17 @@ impl ActorSystem { .ok_or_else(|| anyhow::anyhow!("Named actor not found locally"))? .clone(); - let handle = self - .local_actors + // Look up local_id from actor_names, then get handle + let local_id = self + .actor_names .get(&actor_name) .ok_or_else(|| anyhow::anyhow!("Actor not found: {}", actor_name))?; + let handle = self + .local_actors + .get(local_id.value()) + .ok_or_else(|| anyhow::anyhow!("Actor handle not found: {}", actor_name))?; + return Ok(ActorRef::local(handle.actor_id, handle.sender.clone())); } @@ -1003,39 +1035,47 @@ impl ActorSystem { ) -> anyhow::Result<()> { let name = name.as_ref(); - if let Some((_, handle)) = self.local_actors.remove(name) { - // 1. Signal the actor to stop gracefully - handle.cancel_token.cancel(); + // Get local_id from actor_names, then remove from local_actors + if let Some((_, local_id)) = self.actor_names.remove(name) { + if let Some((_, handle)) = self.local_actors.remove(&local_id) { + // 1. Signal the actor to stop gracefully + handle.cancel_token.cancel(); - // 2. Wait for the actor to finish with timeout - match tokio::time::timeout(Self::GRACEFUL_STOP_TIMEOUT, handle.join_handle).await { - Ok(_) => { - // Actor stopped gracefully - tracing::debug!(actor = %name, "Actor stopped gracefully"); - } - Err(_) => { - // Timeout - actor didn't respond to cancel signal - // This shouldn't happen normally, but we log a warning - tracing::warn!( - actor = %name, - "Actor didn't stop gracefully within timeout, already aborted by tokio" - ); + // 2. Wait for the actor to finish with timeout + match tokio::time::timeout(Self::GRACEFUL_STOP_TIMEOUT, handle.join_handle).await { + Ok(_) => { + // Actor stopped gracefully + tracing::debug!(actor = %name, "Actor stopped gracefully"); + } + Err(_) => { + // Timeout - actor didn't respond to cancel signal + // This shouldn't happen normally, but we log a warning + tracing::warn!( + actor = %name, + "Actor didn't stop gracefully within timeout, already aborted by tokio" + ); + } } - } - // 3. Handle lifecycle cleanup - let local_actors = self.local_actors.clone(); - self.lifecycle - .handle_termination( - &handle.actor_id, - name, - handle.named_path.clone(), - reason, - &self.named_actor_paths, - &self.cluster, - |n| local_actors.get(n).map(|h| h.sender.clone()), - ) - .await; + // 3. Handle lifecycle cleanup + let actor_names = self.actor_names.clone(); + let local_actors = self.local_actors.clone(); + self.lifecycle + .handle_termination( + &handle.actor_id, + name, + handle.named_path.clone(), + reason, + &self.named_actor_paths, + &self.cluster, + |n| { + actor_names.get(n).and_then(|id| { + local_actors.get(id.value()).map(|h| h.sender.clone()) + }) + }, + ) + .await; + } } Ok(()) @@ -1059,37 +1099,47 @@ impl ActorSystem { let actor_name = actor_name_ref.clone(); drop(actor_name_ref); - if let Some((_, handle)) = self.local_actors.remove(&actor_name) { - // 1. Signal the actor to stop gracefully - handle.cancel_token.cancel(); - - // 2. Wait for the actor to finish with timeout - match tokio::time::timeout(Self::GRACEFUL_STOP_TIMEOUT, handle.join_handle).await { - Ok(_) => { - tracing::debug!(actor = %actor_name, path = %path_key, "Actor stopped gracefully"); - } - Err(_) => { - tracing::warn!( - actor = %actor_name, - path = %path_key, - "Actor didn't stop gracefully within timeout" - ); + // Get local_id from actor_names, then remove from local_actors + if let Some((_, local_id)) = self.actor_names.remove(&actor_name) { + if let Some((_, handle)) = self.local_actors.remove(&local_id) { + // 1. Signal the actor to stop gracefully + handle.cancel_token.cancel(); + + // 2. Wait for the actor to finish with timeout + match tokio::time::timeout(Self::GRACEFUL_STOP_TIMEOUT, handle.join_handle) + .await + { + Ok(_) => { + tracing::debug!(actor = %actor_name, path = %path_key, "Actor stopped gracefully"); + } + Err(_) => { + tracing::warn!( + actor = %actor_name, + path = %path_key, + "Actor didn't stop gracefully within timeout" + ); + } } - } - // 3. Handle lifecycle cleanup - let local_actors = self.local_actors.clone(); - self.lifecycle - .handle_termination( - &handle.actor_id, - &actor_name, - Some(path.clone()), - reason, - &self.named_actor_paths, - &self.cluster, - |name| local_actors.get(name).map(|h| h.sender.clone()), - ) - .await; + // 3. Handle lifecycle cleanup + let actor_names = self.actor_names.clone(); + let local_actors = self.local_actors.clone(); + self.lifecycle + .handle_termination( + &handle.actor_id, + &actor_name, + Some(path.clone()), + reason, + &self.named_actor_paths, + &self.cluster, + |name| { + actor_names.get(name).and_then(|id| { + local_actors.get(id.value()).map(|h| h.sender.clone()) + }) + }, + ) + .await; + } } } @@ -1117,24 +1167,32 @@ impl ActorSystem { // Since all actors share the same parent token, they should all start stopping tokio::time::sleep(Duration::from_millis(100)).await; - // Collect all actor info and remove them from the map - // Using drain pattern to take ownership of handles + // Collect all actor info (local_id, actor_id, name, named_path) let actor_entries: Vec<_> = self .local_actors .iter() .map(|entry| { - ( - entry.key().clone(), - entry.actor_id, - entry.named_path.clone(), - ) + let local_id = *entry.key(); + let actor_id = entry.actor_id; + let named_path = entry.named_path.clone(); + // Find name from actor_names (reverse lookup) + let name = self + .actor_names + .iter() + .find(|e| *e.value() == local_id) + .map(|e| e.key().clone()) + .unwrap_or_else(|| actor_id.to_string()); + (local_id, actor_id, name, named_path) }) .collect(); // Process each actor's termination - for (actor_name, actor_id, named_path) in actor_entries { + for (local_id, actor_id, actor_name, named_path) in actor_entries { + // Remove from actor_names first + self.actor_names.remove(&actor_name); + // Remove and get ownership of the handle - if let Some((_, handle)) = self.local_actors.remove(&actor_name) { + if let Some((_, handle)) = self.local_actors.remove(&local_id) { // Wait briefly for graceful shutdown (actor should already be stopping due to parent cancel) // Use a shorter timeout since we already signaled cancellation match tokio::time::timeout(Duration::from_secs(5), handle.join_handle).await { @@ -1151,6 +1209,7 @@ impl ActorSystem { } // Trigger lifecycle cleanup (watch notifications, cluster broadcast, routing cleanup) + let actor_names = self.actor_names.clone(); let local_actors = self.local_actors.clone(); self.lifecycle .handle_termination( @@ -1160,7 +1219,11 @@ impl ActorSystem { StopReason::SystemShutdown, &self.named_actor_paths, &self.cluster, - |name| local_actors.get(name).map(|h| h.sender.clone()), + |name| { + actor_names.get(name).and_then(|id| { + local_actors.get(id.value()).map(|h| h.sender.clone()) + }) + }, ) .await; } @@ -1168,6 +1231,7 @@ impl ActorSystem { // Clear all actors (should already be empty, but just in case) self.local_actors.clear(); + self.actor_names.clear(); // Clear node load trackers self.node_load.clear(); diff --git a/crates/pulsing-actor/src/system/traits.rs b/crates/pulsing-actor/src/system/traits.rs index e0500c0d6..15780810e 100644 --- a/crates/pulsing-actor/src/system/traits.rs +++ b/crates/pulsing-actor/src/system/traits.rs @@ -474,7 +474,10 @@ pub trait ActorSystemOpsExt { fn tracked_node_count(&self) -> usize; /// Resolve an actor address and get an ActorRef - async fn resolve_address(&self, address: &crate::actor::ActorAddress) -> anyhow::Result; + async fn resolve_address( + &self, + address: &crate::actor::ActorAddress, + ) -> anyhow::Result; /// Get all instances of a named actor across the cluster async fn get_named_instances(&self, path: &ActorPath) -> Vec; @@ -662,7 +665,10 @@ impl ActorSystemOpsExt for Arc { ActorSystem::tracked_node_count(self.as_ref()) } - async fn resolve_address(&self, address: &crate::actor::ActorAddress) -> anyhow::Result { + async fn resolve_address( + &self, + address: &crate::actor::ActorAddress, + ) -> anyhow::Result { ActorSystem::resolve(self.as_ref(), address).await } diff --git a/crates/pulsing-actor/tests/integration_tests.rs b/crates/pulsing-actor/tests/integration_tests.rs index 70cac2a9f..05c93ec52 100644 --- a/crates/pulsing-actor/tests/integration_tests.rs +++ b/crates/pulsing-actor/tests/integration_tests.rs @@ -593,7 +593,9 @@ mod addressing_tests { // Resolve by address let addr = ActorAddress::parse("actor:///services/api/handler").unwrap(); - let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr) + .await + .unwrap(); // Send message via resolved ref let response: Pong = resolved_ref.ask(Ping { value: 10 }).await.unwrap(); @@ -622,7 +624,9 @@ mod addressing_tests { let addr = ActorAddress::local(actor_ref.id().local_id()); // Resolve - let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr) + .await + .unwrap(); let response: Pong = resolved_ref.ask(Ping { value: 5 }).await.unwrap(); assert_eq!(response.result, 10); @@ -650,7 +654,9 @@ mod addressing_tests { ActorAddress::parse(&format!("actor://0/{}", actor_ref.id().local_id())).unwrap(); assert!(addr.is_local()); - let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr).await.unwrap(); + let resolved_ref = ActorSystemOpsExt::resolve_address(&system, &addr) + .await + .unwrap(); let response: Pong = resolved_ref.ask(Ping { value: 7 }).await.unwrap(); assert_eq!(response.result, 14); diff --git a/docs/src/api_reference.md b/docs/src/api_reference.md index 3c0e0163e..9804a38e2 100644 --- a/docs/src/api_reference.md +++ b/docs/src/api_reference.md @@ -134,7 +134,7 @@ class ActorSystem: ) -> ActorRef: """ Spawn a new actor. - + - With name: named actor, discoverable via resolve() - Without name: anonymous actor, only accessible via returned ActorRef """ diff --git a/docs/src/api_reference.zh.md b/docs/src/api_reference.zh.md index 4c01006ca..f3a71ae93 100644 --- a/docs/src/api_reference.zh.md +++ b/docs/src/api_reference.zh.md @@ -134,7 +134,7 @@ class ActorSystem: ) -> ActorRef: """ 生成新的 actor。 - + - 有 name: 命名 actor,可通过 resolve() 发现 - 无 name: 匿名 actor,仅通过返回的 ActorRef 访问 """ From 3f4ceed6034cac2c8fc5839b1b47e363868a269f Mon Sep 17 00:00:00 2001 From: Reiase Date: Sat, 24 Jan 2026 20:36:22 +0800 Subject: [PATCH 24/24] Enhance actor stopping mechanism with Python compatibility - Updated the `stop_with_reason` method in `ActorSystem` to support actor names without a "/" by attempting to resolve them with an "actors/" prefix for better compatibility with Python API conventions. - Improved logging to reflect the actual name used for stopping the actor, ensuring accurate debugging information. - Enhanced documentation to clarify the new behavior and its implications for actor management. --- crates/pulsing-actor/src/system/mod.rs | 26 ++++++++++++++++++++++---- python/pulsing/actor/remote.py | 7 ++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/pulsing-actor/src/system/mod.rs b/crates/pulsing-actor/src/system/mod.rs index 866f73173..6593e000a 100644 --- a/crates/pulsing-actor/src/system/mod.rs +++ b/crates/pulsing-actor/src/system/mod.rs @@ -1028,6 +1028,9 @@ impl ActorSystem { /// 2. Waits for the actor to finish (with 30s timeout) /// 3. If timeout, forcefully aborts the actor task /// 4. Handles lifecycle cleanup (watch notifications, cluster broadcast, etc.) + /// + /// Note: If the name doesn't contain a "/" and no actor is found with the exact name, + /// it will try with the "actors/" prefix (for Python compatibility). pub async fn stop_with_reason( &self, name: impl AsRef, @@ -1035,8 +1038,23 @@ impl ActorSystem { ) -> anyhow::Result<()> { let name = name.as_ref(); + // Try exact name first, then normalized name with "actors/" prefix + let actual_name = if self.actor_names.contains_key(name) { + name.to_string() + } else if !name.contains('/') { + // Try with "actors/" prefix (Python API compatibility) + let prefixed = format!("actors/{}", name); + if self.actor_names.contains_key(&prefixed) { + prefixed + } else { + name.to_string() + } + } else { + name.to_string() + }; + // Get local_id from actor_names, then remove from local_actors - if let Some((_, local_id)) = self.actor_names.remove(name) { + if let Some((_, local_id)) = self.actor_names.remove(&actual_name) { if let Some((_, handle)) = self.local_actors.remove(&local_id) { // 1. Signal the actor to stop gracefully handle.cancel_token.cancel(); @@ -1045,13 +1063,13 @@ impl ActorSystem { match tokio::time::timeout(Self::GRACEFUL_STOP_TIMEOUT, handle.join_handle).await { Ok(_) => { // Actor stopped gracefully - tracing::debug!(actor = %name, "Actor stopped gracefully"); + tracing::debug!(actor = %actual_name, "Actor stopped gracefully"); } Err(_) => { // Timeout - actor didn't respond to cancel signal // This shouldn't happen normally, but we log a warning tracing::warn!( - actor = %name, + actor = %actual_name, "Actor didn't stop gracefully within timeout, already aborted by tokio" ); } @@ -1063,7 +1081,7 @@ impl ActorSystem { self.lifecycle .handle_termination( &handle.actor_id, - name, + &actual_name, handle.named_path.clone(), reason, &self.named_actor_paths, diff --git a/python/pulsing/actor/remote.py b/python/pulsing/actor/remote.py index b26390c22..6b221b824 100644 --- a/python/pulsing/actor/remote.py +++ b/python/pulsing/actor/remote.py @@ -403,11 +403,12 @@ async def receive(self, msg) -> Any: # Regular method or not marked as async call try: result = func(*args, **kwargs) - if asyncio.iscoroutine(result): - result = await result - # Check if result is a generator (sync or async) + # Check if result is a generator (sync or async) FIRST + # This must come before the coroutine check to avoid awaiting generators if inspect.isgenerator(result) or inspect.isasyncgen(result): return self._handle_generator_result(result) + if asyncio.iscoroutine(result): + result = await result return {"__result__": result} except Exception as e: return {"__error__": str(e)}