diff --git a/scripts/m11b3_selftest_output.txt b/scripts/m11b3_selftest_output.txt new file mode 100644 index 0000000..75d195e --- /dev/null +++ b/scripts/m11b3_selftest_output.txt @@ -0,0 +1,10 @@ +# M11b-3 事务提交后广播 —— 单元/基准测试真实执行留痕 +# 生成时间: 2026-09-21 13:32:11 +0800 +# 命令: cd modules/world_sync && .venv/bin/python -m pytest tests/test_m11b3_post_commit_broadcast.py -q -s +# 环境: Python 3.10.12 / pytest pytest 9.1.1 +---------------------------------------- +.............. +[M11b-3 延迟基准] n=200 p50=2.416ms p99=4.086ms max=4.179ms SLO=300ms +...... +20 passed in 0.39s +EXIT_CODE=0 diff --git a/scripts/m11b3_sync_check.txt b/scripts/m11b3_sync_check.txt new file mode 100644 index 0000000..62cf41f --- /dev/null +++ b/scripts/m11b3_sync_check.txt @@ -0,0 +1,37 @@ +# M11b-3 宿主镜像同步核验 (scripts/sync_code_to_app.py) +# 真源 modules/world_sync -> 镜像 apps/scense/pkgs/world_sync +$ .venv/bin/python scripts/sync_code_to_app.py +# 待镜像文件 14 个: modules/world_sync -> apps/scense/pkgs/world_sync +world_sync/__init__.py SYNCED sha256=10b53279d527 +world_sync/init.py SYNCED sha256=d3a82d48bf75 +world_sync/pbl_runtime_authority.py SYNCED sha256=78da25d8a865 +world_sync/pbl_runtime_broadcast.py SYNCED sha256=79cce145d722 +world_sync/pbl_runtime_errors.py SYNCED sha256=25f6ed554554 +world_sync/pbl_runtime_sql.py SYNCED sha256=766b20674d98 +world_sync/pbl_runtime_tx.py SYNCED sha256=bfc01fdf778f +world_sync/pbl_runtime_tx_env.py SYNCED sha256=463d9340f857 +world_sync/pbl_runtime_tx_sqlor.py SYNCED sha256=b074d64ee297 +tests/test_m11b2_single_tx.py SYNCED sha256=8523917c907b +tests/test_m11b2_wiring.py SYNCED sha256=3e6c47c3bbfb +tests/test_m11b3_post_commit_broadcast.py SYNCED sha256=37c5f7367bfa +README.md SYNCED sha256=fb856c03650b +skill/SKILL.md SYNCED sha256=186a94893205 +RESULT: PASS (镜像与真源逐文件 sha256 一致) + +$ .venv/bin/python scripts/sync_code_to_app.py --check +# 待镜像文件 14 个: modules/world_sync -> apps/scense/pkgs/world_sync +world_sync/__init__.py OK +world_sync/init.py OK +world_sync/pbl_runtime_authority.py OK +world_sync/pbl_runtime_broadcast.py OK +world_sync/pbl_runtime_errors.py OK +world_sync/pbl_runtime_sql.py OK +world_sync/pbl_runtime_tx.py OK +world_sync/pbl_runtime_tx_env.py OK +world_sync/pbl_runtime_tx_sqlor.py OK +tests/test_m11b2_single_tx.py OK +tests/test_m11b2_wiring.py OK +tests/test_m11b3_post_commit_broadcast.py OK +README.md OK +skill/SKILL.md OK +RESULT: PASS (镜像与真源逐文件 sha256 一致) diff --git a/scripts/sync_code_to_app.py b/scripts/sync_code_to_app.py index 5ec609d..0ce67d7 100644 --- a/scripts/sync_code_to_app.py +++ b/scripts/sync_code_to_app.py @@ -44,9 +44,16 @@ def sha256_of(path: str) -> str: def collect() -> list: + """返回需要镜像的相对路径列表(相对 SRC_ROOT)。 + + 注意:路径必须以 WS_ROOT 为基准拼绝对路径再判断,历史上这里用相对 + SRC_ROOT 直接 isdir,导致从模块目录内执行时恒为空列表 —— --check + 会打印「RESULT: PASS」却一个文件都没比对(假 PASS)。 + """ rels = [] + src_abs = os.path.join(WS_ROOT, SRC_ROOT) for sub in MIRROR_SUBDIRS: - base = os.path.join(SRC_ROOT, sub) + base = os.path.join(src_abs, sub) if not os.path.isdir(base): continue for name in sorted(os.listdir(base)): @@ -56,7 +63,7 @@ def collect() -> list: continue rels.append(os.path.join(sub, name)) for rel in MIRROR_FILES: - if os.path.isfile(os.path.join(SRC_ROOT, rel)): + if os.path.isfile(os.path.join(src_abs, rel)): rels.append(rel) return rels @@ -67,7 +74,13 @@ def main() -> int: args = ap.parse_args() bad = 0 - for rel in collect(): + rels = collect() + if not rels: + # 空清单说明扫描本身出错(路径基准问题),必须判 FAIL 而非静默 PASS + print("RESULT: FAIL (未扫描到任何待镜像文件,检查 WS_ROOT/SRC_ROOT)") + return 1 + print("# 待镜像文件 %d 个: %s -> %s" % (len(rels), SRC_ROOT, DST_ROOT)) + for rel in rels: src = os.path.join(WS_ROOT, SRC_ROOT, rel) dst = os.path.join(WS_ROOT, DST_ROOT, rel) src_sha = sha256_of(src) diff --git a/skill/SKILL.md b/skill/SKILL.md index 76e5d58..5b616c2 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -8,6 +8,8 @@ description: world_sync 模块技能文档。 ## 概述 世界数据同步模块,管理世界数据的导入/导出/合并同步记录与执行。依赖 world 模块(world 表)、appbase(appcodes 字典 sync_type/sync_status)。通过 load_world_sync() 挂载。 +自 M11b-2/M11b-3 起,本模块同时承载 **scense_runtime 服务端权威写入薄扩展**:单事务事件+状态写入(`pbl_runtime_authority.write_event_with_state`)与**事务提交后广播**(`pbl_runtime_broadcast`)。 + ## 数据模型 ### 表 world_sync(models/world_sync.json) | 字段 | 类型 | 说明 | @@ -24,9 +26,30 @@ description: world_sync 模块技能文档。 - 主键:["id"];索引:idx_sync_world(world_id) - 字典:world_id→world、sync_type→appcodes_kv(parentid='sync_type')、status→appcodes_kv(parentid='sync_status') +### 运行时权威表(M11b-2,models/) +`pbl_runtime_event`(append-only 事件)、`pbl_entity_state`(实体状态,state_version 服务端权威)、 +`pbl_world_state_snapshot`(世界快照)。所有读写强制 `tenant_id` 打头,缺失 fail-closed。 + ## 关键接口 - list_world_syncs / get_world_sync / create_world_sync / update_world_sync / delete_world_sync / execute_world_sync - wwwroot/api/world_sync.dspy(同步契约)、create/world_sync_update/world_sync_delete.dspy、get_search_*.dspy +- M11b-2:`write_event_with_state(event_data, state_update, ...)` —— 事件 + 状态在**同一事务**内提交 +- M11b-3 广播 API(`world_sync/pbl_runtime_broadcast.py`,已在 `__init__.py` 导出): + - `install_broadcast_hook()` / `is_broadcast_hook_installed()`:把 `write_event_with_state` 包成 COMMIT 后广播(幂等,`load_world_sync()` 已自动调用) + - `emit_post_commit_broadcast(write_result, context=...)`:手动发一条 + - `wrap_with_post_commit_broadcast(fn)`:包装任意返回事务结果的函数 + - `configure_broadcast(channels=..., webhook_urls=..., redis_url=..., queue_size=2000, retry_attempts=2, retry_backoff_s=0.02, enabled=True)`:宿主启动时配置通道 + - `subscribe_broadcast(handler)` / `unsubscribe_broadcast(handler)`:进程内订阅 + - `broadcast_stats()` / `broadcast_last_message()` / `broadcast_latency_percentile_ms(99)` / `drain_broadcast(timeout=2.0)` / `shutdown_broadcast()`:观测与优雅退出 + - 通道类:`InProcessChannel`、`WebhookChannel`、`RedisPubSubChannel`、`NoopChannel`(均继承 `BaseChannel`,`publish(message)`) + +## 事务提交后广播(M11b-3)要点 +- **触发点**:仅事务 COMMIT 成功之后(`commit_ts` 即延迟起点);事务回滚不广播。 +- **异步**:单 dispatcher 线程 + 有界 `queue.Queue(2000)`,`submit` 用 `put_nowait` 微秒返回,不阻塞事务返回;队列满降级为当前线程直发一次。 +- **SLO**:`BROADCAST_LATENCY_SLO_MS = 300.0`,口径 P99;实测 n=200 p50=2.4ms / p99=4.1ms / max=4.2ms(`scripts/m11b3_selftest_output.txt`)。 +- **失败语义**:fire-and-forget + 有限重试;通道/订阅者异常一律吞掉并 `ERROR` 日志 + `stats["failed"]` 计数,**绝不回滚已提交事务**、绝不外抛给业务调用方。 +- **无轮询**:广播路径不含 `time.sleep` 轮询、不读 DB;等待靠 `Queue.get(block=True)` 条件变量。 +- **环境变量**:`PBL_BROADCAST_DISABLED`(全局关闭)、`PBL_BROADCAST_WEBHOOK_URLS`(逗号分隔 webhook)、`PBL_BROADCAST_REDIS_ENABLED` + `PBL_BROADCAST_REDIS_URL`(Redis pub/sub)。 ## 陷阱 - 库名不硬编码:.py 用 ServerEnv().get_module_dbname('world_sync'),.dspy 用 get_sor_context(request._run_ns,'world_sync') @@ -34,6 +57,12 @@ description: world_sync 模块技能文档。 - create/execute 必须设置 created_at、sync_date,否则 sor.C 静默丢记录 - update 剔除 _text 后缀 - codes.table 引用 world 不用 module.table 点号 +- **广播 hook 幂等**:`install_broadcast_hook()` 重复调用不得重复包装(已用 `__wrapped_post_commit__` 标记),否则一条事件广播多次 +- **广播内禁止再写事务/再发广播**:dispatcher 线程只做 publish,回写 DB 会造成递归与延迟失真 +- **测试断言前用 `drain_broadcast()` 等待,禁止 sleep 轮询**(QC 会按「引入轮询」退回) +- `RedisPubSubChannel` 惰性 import `redis`:环境缺包只 `ChannelUnavailable` 跳过,不影响 InProcess/webhook 通道,不要让 Redis 成为硬依赖 +- `scripts/sync_code_to_app.py` 的 `collect()` 必须以 `WS_ROOT` 拼绝对路径 —— 用相对路径 `isdir` 会恒返回空清单,导致 `--check` 打印假 PASS(M11b-3 已修,改脚本时勿回退) ## 依赖 - world、appbase、rbac +- M11b-2/M11b-3 广播零第三方硬依赖(stdlib:threading/queue/urllib/hashlib;redis 可选)