diff --git a/docs/work-log-2026-09-22-m11b2c.md b/docs/work-log-2026-09-22-m11b2c.md new file mode 100644 index 0000000..25d4ced --- /dev/null +++ b/docs/work-log-2026-09-22-m11b2c.md @@ -0,0 +1,108 @@ +# Work Log — 2026-09-22 · M11b-2c:world_sync 单事务库函数(write_event_with_state / read_entity_state) + +- 模块:`world_sync`(宿主镜像/消费方侧,运行时事件与实体状态单事务写入器) +- 里程碑:**M11b-2**(单事务写两表);上游真源模块 `pbl_runtime_ext`(表结构与 `tx_write/tx_read` 能力权威源) +- 任务:`[M11b-2c-T2] 补 M11b-2 开发文档 dev-notes.md + code_files.md`(task_id=`kICTWazBqgIWtKNO7sz8A`,classify_task=new_dev) +- 本文按 module-development-spec「Work Logs 强制项」产出:本次改了什么、数据模型、契约接口、陷阱与裁决、验证证据。 +- 范围纪律:本任务**只补文档**,未修改任何业务代码 / `models/*.json` / `scripts/load_path.py`(后者归 T3)。 + +## 1. 数据模型 + +M11b-2 写路径涉及两张表,**结构权威源在 `pbl_runtime_ext/models/`**,`world_sync/models/` 下是**消费方内联副本**(不是引用式声明——`database-table-definition-spec` 只认 summary/fields/indexes/codes 四段式,`scripts/validate_models_json.py` 对 `key=schema` 引用式写法直接判 FAIL,QC #5 结论)。 + +### 1.1 `pbl_runtime_event`(运行时事件,append-only) + +- 真源:`modules/pbl_runtime_ext/models/pbl_runtime_event.json`;副本:`modules/world_sync/models/pbl_runtime_event.json`(8782B) +- 一致性:由 `scripts/m11b2b_column_diff.py` + `scripts/m11b2_selftest.py` 机械核对,`mirror-sha256-equal` 断言真源与 `apps/scense/pkgs/world_sync` 镜像**字节级一致**(本次重跑 PASS)。 +- 语义要点: + - **append-only**:写路径只做 INSERT,不 UPDATE / 不 DELETE(`TestScopeAndSqlContract::test_event_sql_is_insert_only` 守这条)。 + - `event_uid` 是幂等/重试定位键(默认 uuid4 hex,可由入参指定以便测试因果链);旧口径的 `event_id` 已废弃。 + - `state_version` 与状态行生效后的版本号**对齐**(同事务内第 4 步算出再写第 5 步),供 M11b-3 广播去重与 M11b-4 轮询增量拉取。 + - `seq_no` 严格单调递增(探针断言 PASS);`source` 默认 `world_sync.m11b2`;`broadcast` 标记位由 M11b-3 消费。 + - `payload` 以 JSON **文本**入库(`json.dumps` 后写 `text` 列;抽象类型归一 `json→text`、`bigint→long`,不保留物理修饰)。 + +### 1.2 `pbl_entity_state`(实体状态,服务端权威版本) + +- 真源:`modules/pbl_runtime_ext/models/pbl_entity_state.json`;副本:`modules/world_sync/models/pbl_entity_state.json`(6099B) +- 列集(真源实测 10 列,早期「8 列」口径作废):`tenant_id / id / session_id / entity_id / state_json / state_version / checksum / updated_by / created_at / updated_at` +- 定位键 = 唯一键 `uk_es (tenant_id, session_id, entity_id)`,与 `write_event_with_state` / `read_entity_state` 的 WHERE 完全一致(**没有** `world_id` 列,`world_id` 仅作历史兼容入参被忽略)。 +- `state_version` 单调 +1,**客户端禁写**;`state_json` 存完整新状态(非 diff)。 +- `id` 类型偏离:真源 `BIGINT AUTO_INCREMENT`,world_sync 副本内联为 `str/length=32`(spec 合规写法),主键由应用层生成 32 位 uuid。**写路径 INSERT 不显式提交 id**——生产 MySQL 交给真源 DDL 的 AUTO_INCREMENT,本地/测试 sqlite DDL 用应用层生成值。此偏离为有意选择(副本侧不走「登记放行 id 非 str(32)」路线,因为本任务范围禁改校验器脚本),评审勿判结构缺陷。 +- 主键只在 `summary[0].primary=["id"]` 单点声明,`indexes[]` 内不出现 `PRIMARY` 条目(spec 规则 `index_name_primary`)。 + +## 2. 契约接口 + +### 2.1 两个库函数(M11b-2 的全部对外面) + +```python +# world_sync/pbl_runtime_tx.py:351 +def write_event_with_state(event_data=None, state_update=None, conn_factory=None, + conn=None, event_uid=None, source=None, clock=None) -> dict +# world_sync/pbl_runtime_tx.py:310 +def read_entity_state(conn_factory=None, tenant_id=None, session_id=None, + entity_id=None, world_id=None, conn=None) -> dict | None +``` + +- `event_data` 必填:`tenant_id` / `entity_id` / `event_type`;可选 `payload`、`session_id`、`scene_id`、`world_id`、`actor_id`、`causation_id`、`idem_key`、`seq`/`seq_no`、`tx_group`、`source`、`broadcast`。 +- `state_update` 必填:`tenant_id` / `session_id` / `entity_id`;新状态键 `state`(或 `state_json`,dict 或 JSON 文本皆可);可选 `expected_version`(乐观锁)、`updated_by`。**传 `None` = 只写事件不改状态**(返回 `action="event_only"`)。 +- 成功返回:`{event_uid, state_version, action: updated|inserted|event_only, elapsed_ms, isolation_level_applied, dialect, ...}`。 + - `elapsed_ms` 是 P99 统计的采样来源; + - `isolation_level_applied=False` 在 sqlite 下是**正常**(无该概念),MySQL/PG 下应为 True。 +- `read_entity_state` 返回 `{"state": ..., "state_version": int, "updated_at": ...}`,查不到返回 `None`;它是**独立短事务**,不与写入事务共用(避免长事务),供上层冲突后重读版本再重试。 +- 复用 `conn` 时仍由本函数负责该连接的 commit/rollback,**调用方不要自行 commit**。 + +### 2.2 三处同步注册(module-development-spec 铁律 4,已 grep 核验) + +| 环节 | 位置 | +|---|---| +| ① 实现 | `world_sync/pbl_runtime_tx.py:310,351`(`__all__` 已导出) | +| ② 包导出 | `world_sync/__init__.py:119-120`(import)+ `:209-210`(`__all__`) | +| ③ 宿主挂载 | `world_sync/init.py:513-514` → `env.write_event_with_state` / `env.read_entity_state` | + +适配层 `world_sync/pbl_runtime_tx_sqlor.py:126,154` 提供同名薄包装(宿主连接池接线,**不重复实现事务逻辑**)。 + +### 2.3 无 HTTP 端点(重要) + +M11b-2 **不新增** `.dspy` / `.ui`,因此 `scripts/load_path.py` 中**没有**这两个函数的权限条目,也不该有——它们是 Python 库函数,经 `load_world_sync()` 挂到 `ServerEnv` 供宿主调用,不走 HTTP 路由,不存在 401/403 面。`wwwroot/api/README-N-A-M11b2.md` 是「Not Applicable」的显式留档。正式声明见 `projects/pbls/docs/02-develop/m11b2c-rbac-path-declaration.md`。 + +### 2.4 事务与冲突契约(详版在 dev-notes.md `[M11b-2c]` 章 §2) + +`BEGIN`(sqlite `BEGIN IMMEDIATE`)→ 会话级 `READ COMMITTED`(MySQL/PG;失败降级不阻断,靠乐观锁兜底并透出标志)→ 读 `state_version`(MySQL/PG 加 `FOR UPDATE`)→ `expected_version` 预检 → 状态 CAS 更新/INSERT → 事件 INSERT → `COMMIT`;任一步失败 `_rollback_quiet` + 明确异常(`ConcurrentStateConflict` / `StateWriteError` / `EventWriteError` / `TxAbortError` / `TxConfigError` / `ConnFactoryNotConfigured`),**两表 0 变更**,绝不吞错。 + +## 3. 陷阱(本模块改动前必读) + +1. **别在 world_sync 侧建表**。DDL 归 `pbl_runtime_ext`;`world_sync/models/*.json` 只是消费方契约视图。改真源必须同步本副本并重跑 `m11b2b_column_diff.py` + `m11b2_selftest.py`。 +2. **列名不许写死**。列集由 `pbl_runtime_authority` 从真源机械导出,`_assert_contract()` 在建 SQL 之前 fail-closed(`AuthorityResolutionError`)。新增列只改真源 JSON + 同步副本,代码里出现字面量列名清单 = 双写分叉隐患。 +3. **定位键是 `tenant_id + session_id + entity_id`**,不是 `world_id`(表里没这列)。传 `world_id` 只被忽略——别指望它做隔离。 +4. **`expected_version` 两级校验缺一不可**:预检给友好错误(带 expected/actual),CAS(`rowcount != 1`)防预检与写入之间的窄窗口。只留预检会丢失更新。 +5. **状态行 INSERT 不带 `id`**(见 §1.2 偏离说明),且 UNIQUE 列空串必须归一为 `None`(`''` 会互相撞唯一键)。 +6. **写序是先状态、后事件**。反过来会让事件携带一个可能被回滚的 `state_version`。 +7. **`isolation_level_applied=False` 不等于失败**(sqlite 无该概念)。测试断言要按 dialect 分支。 +8. **`conn_factory` 三级优先级**:显式注入(可传 DB URL)→ 复用 `conn` → 宿主 `ServerEnv` 连接池(`POOL_KEYS=("sqlor_pools","db_pools","pools","sqlorPools")`,库名键 `("pbl","world_sync","default")`)。拿不到池就抛 `ConnFactoryNotConfigured`,**禁止**回落到裸连或读 `env/{test,prod}.json` 凭据(project-directory-spec §8.1)。 +9. **`m11b2_selftest.py` 的 `cross-check-sql` 检查器**:M11b-2c 重构后 `EVENT_COLUMNS = columns_of(EVENT_TABLE)`(运行时派生),旧正则抓字面量元组会误报「未能解析」。已由 commit `6532d8f` 改为三级解析(A 字面量 / B1 运行时 import / B2 静态回落)。再遇到同类 FAIL 先怀疑检查器而非业务代码。 +10. **遗留测试文件是技术债不是验收依据**:`tests/test_m11b2_single_tx.py`、`tests/test_m11b2_wiring.py` 的 29 条失败源于脚手架 DDL 与真源不一致、仍用已废弃的 `event_id` 关键字、缺显式 dialect、`init.py` 相对路径等(详见 `m11b2c-tests-result.md` §4.1 逐条归因)。等价覆盖已在 `pbl_runtime_ext/tests/test_m11b2c_runtime_ext.py`(9 passed,含 2 条 P99 门禁)。重写需单列 rework 任务。 +11. **广播/轮询不在此模块的 M11b-2 路径里**:post-commit 广播由 `init.py` 的 hook 把 `write_event_with_state` 包一层(M11b-3),3s 轮询兜底在 `pbl_runtime_polling.py`(M11b-4)。摘掉这两层不影响 M11b-2 写入正确性——排查时先确认看到的是哪一层。 +12. **P99 数据是离线 sqlite/内存引擎实测**,不含网络 RTT 与真实 InnoDB 锁等待,只能作为逻辑时延下界;上线后在测试环境按 `pbl_runtime_latency.py` 口径复测。 + +## 4. 验证证据(本次实跑) + +``` +$ cd modules/world_sync && .venv/bin/python scripts/m11b2_selftest.py # exit code 0 +PASS [mirror-sha256-equal] pbl_runtime_event 真源与镜像字节级一致 -> True +PASS [mirror-sha256-equal] pbl_entity_state 真源与镜像字节级一致 -> True + +断言合计 554:PASS 554 / FAIL 0 +RESULT: PASS +``` + +- 全文落盘:`projects/pbls/docs/02-develop/m11b2c-t2-selftest-rerun.log`(64695B) +- 真源侧:`pbl_runtime_ext` pytest `9 passed in 0.41s`(0 失败);`m11b_selftest.py` `总计 110 项,PASS 110,FAIL 0 → RESULT: PASS` +- P99(`m11b2c_p99_probe.py`,n=60,SLA=200ms):`write_event_with_state[INSERT] P99=0.2414ms` / `read_entity_state P99=0.2241ms` / `write_event_with_state[UPDATE] P99=0.4984ms`,均 PASS;`seq_no` 严格递增、读回 `entity_id` 一致性 PASS +- 模块 HEAD:`world_sync` @ `5e3c48d`、`pbl_runtime_ext` @ `9a79e63`(`git status --porcelain` 本任务无代码改动) + +## 5. 关联文档 + +- 应用级开发说明(本里程碑正文):`projects/pbls/docs/02-develop/dev-notes.md` → `[M11b-2c] world_sync 库函数` 章 +- 真源侧:`dev-notes-m11b2c-runtime-ext.md`;迁移:`dev-notes-m11b2a-world-sync-migration.md`;A3 修复:`m11b2c-a3-fix-notes.md` +- RBAC 口径:`m11b2c-rbac-path-declaration.md`;实跑留证:`m11b2c-tests-result.md` 及各 `m11b2c-tests-*-raw.log` +- 模块技能:`modules/world_sync/skill/SKILL.md`;上一份工作日志:`modules/world_sync/docs/work-log-2026-09-20.md`