world_sync/docs/work-log-2026-09-22-m11b2d-fixture-contract.md
2026-09-22 22:09:01 +08:00

21 KiB
Raw Blame History

M11b-2d-A3 登记:world_sync 测试夹具「函数式契约」(唯一权威登记文档)

  • 任务: [M11b-2d-A3] 模块仓库卫生:删一次性探针 + 登记函数式夹具契约
  • 日期: 2026-09-22
  • 响应: QC #2(登记文档缺失);同时承载 QC #1(越权回退)与 QC #3(证据错位)的说明与移交
  • 文档定位: 索引式登记。夹具层实现说明的权威正文在 tests/_m11b2_compat.py 顶部 docstring(不双写搬运);真源行为的权威定义在 modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py 源码本身。本文档只做符号清单 + 逐参数签名 + 返回键语义 + 错误码含义 + 废弃清单 + 引用规约, 全部内容由本次实读源码抄录(锚点行号见各节,行号以 2026-09-22 HEAD 为准)。

0. 一句话结论

M11b-2a 把运行时写入实现迁到函数式 API pbl_runtime_ext.tx_write.apply_runtime_event / read_states 之后,world_sync 测试层只允许存在一条夹具路径: tests/_m11b2_compat.py 的 install/uninstall/apply/read/trace_kinds/update_statements/insert_statements (真 sqlite + monkeypatch rtx_db 三入口,直连真源代码路径)。 旧的「类式垫片」第二路径(RuntimeStateEventWriter / configure_writer / build_plan / 类式 write_event_with_state 转发)已在 M11b-2d 删除,禁止复活(机械守卫见 §6)。


1. 夹具层符号清单(modules/world_sync/tests/_m11b2_compat.py)

1.1 现行唯一夹具路径(公开符号,必须存在)

符号 定义行 签名 职责
RuntimeEngine :166 RuntimeEngine(path=":memory:") 真 sqlite3 引擎;表结构由 ddl_statements() 从 pbl_runtime_ext/models/*.json 生成;持有 sql_log / trace / broadcasts 三条录制面
install :391 install(engine) 把 rtx_db.transaction / rtx_db.dbname / rtx_db.q_all 换成 sqlite 引擎实现,并把 broadcast.hub.publish 包成录制版;返回 engine
uninstall :418 uninstall() 逐样还原被 patch 的三入口(_ORIG 表驱动),幂等(未安装直接返回)
apply :429 apply(**kwargs) → dict asyncio.run(apply_runtime_event(**kwargs)) 的同步入口,零返回键增补
read :434 read(**kwargs) → list asyncio.run(read_states(**kwargs)) 的同步入口
trace_kinds :438 trace_kinds(engine) → list[str] 事务生命周期轨迹(BEGIN/COMMIT/ROLLBACK/BROADCAST)
update_statements :442 update_statements(engine) → list[(sql, params)] 引擎实际发出的 UPDATE 语句(大写、压空格),供乐观锁/不可变列断言
insert_statements :447 insert_statements(engine, table) → list[(sql, params)] 指定表的 INSERT 语句,供 append-only 断言

1.2 夹具层直连的真源符号(re-export,不是副本)

夹具名 来源 定义行
apply_runtime_event pbl_runtime_ext.tx_write tx_write.py:344
read_states pbl_runtime_ext.tx_write tx_write.py:466
build_row pbl_runtime_ext.tx_write tx_write.py:157
assert_append_only pbl_runtime_ext.tx_write tx_write.py:259
RtxError pbl_runtime_ext.rtx_db rtx_db.py:29
percentile world_sync.pbl_runtime_sql(真源唯一实现) —
SQLITE / MYSQL ps.DIALECT_SQLITE / ps.DIALECT_MYSQL —
EVENT_TABLE / STATE_TABLE / SNAPSHOT_TABLE / PROTECTED_BASE_TABLES pbl_runtime_ext.m11b_config m11b_config.py:15-17,24
ERR_* 常量 pbl_runtime_ext.m11b_config m11b_config.py:36-41

表名真值(禁在测试里写第二份字面量):pbl_runtime_event / pbl_entity_state / pbl_world_state_snapshot;基表只读域 world, scene, entity, scense, scense_runtime, script_engine。

1.3 夹具内部辅助(私有,测试不直接依赖)

_sql_type(:91) / _not_null(:105) / _default_clause(:111) / model_fields(:125) / ddl_statements(:132) / _to_qmark(:160) / _Tx(:262, rtx_db.transaction 的 sqlite 替身)。

⚠️ 已知夹具渲染口径(写测试前必读):_default_clause(:111) 把 models 里 default: "" (空串)当「无默认值」跳过,于是 event_code / idem_key 等「NOT NULL DEFAULT ''」列在 sqlite 侧变成无默认值的 NOT NULL 列,真源按语义不填这些列时会撞约束。 现行做法(test_m11b2_single_tx.py 与 test_m11b2d_fixture_contract.py 一致): 仅在构造期临时替换 _default_clause 补正空串默认值,建完表立即还原, 不改夹具既有定义与行为语义(夹具属本任务禁改清单)。


2. 真源 apply_runtime_event 逐参数签名(实读抄录)

modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py:344-348,原文:

async def apply_runtime_event(tenant_id=None, world_id=None, session_id=None,
                              event_type=None, payload=None, state_updates=None,
                              client_event_id=None, idem_key=None, snapshot=None,
                              base_version=None, actor_id=None, causation_id=None,
                              occurred_at=None, env=None):
# 参数 默认 语义(真源行为,非推测)
1 tenant_id None 强制打头。_require_tenant()(tx_write.py:246) 缺失即抛 PBL-TENANT-0001,fail-closed 拒绝读写
2 world_id None 必填;in (None, "") → PBL-PARAM-0001(tx_write.py:355-356)。同时参与广播通道名 channel_of(world_id, tenant_id)
3 session_id None 必填;同上。会话内 seq 单调序号的作用域(_next_seq,tx_write.py:282)
4 event_type None 必填(假值即拒);写入时截断 str(event_type)[:64]
5 payload None 事件负载 dict;入库列 payload_json = _dumps(payload or {});参与幂等键计算
6 state_updates None 必须是数组(None→[];非 list/tuple 抛 PBL-PARAM-0001,tx_write.py:358-359)。逐项交 _apply_state(),每项须含 entity_id(或 state_key)+ state;客户端禁写 state_version
7 client_event_id None 幂等键来源之一:idem_key_of(tenant_id, session_id, event_type, payload, client_event_id or idem_key)(tx_write.py:232)
8 idem_key None 幂等键显式覆盖(优先级低于 client_event_id:client_event_id or idem_key)
9 snapshot None 传入则同事务写 pbl_world_state_snapshot 一行
10 base_version None 乐观锁期望版本。None = 不做冲突校验;不匹配 → PBL-STATE-CONFLICT 整体回滚(tx_write.py:313-318)。不在 SQL 层做 version CAS,冲突在 Python 侧裁决
11 actor_id None 缺省 "server",截断 [:32],写 updated_by
12 causation_id None 缺省 "",截断 [:32]
13 occurred_at None 缺省 time.strftime("%Y-%m-%d %H:%M:%S");真源把它同时写进 created_at/updated_at/occurred_at
14 env None 宿主 ServerEnv;测试路径由夹具 patch rtx_db.dbname 消化,不传

全部 14 个形参默认值均为 None(守卫用例 test_true_source_signature 钉死参数名/顺序/默认值, 与本文档同源——任一处漂移即红)。

2.1 state_updates 单项形状

_apply_state()(tx_write.py:293) 逐项校验:

  • 非 dict → PBL-PARAM-0001「state_updates 每项必须是对象」
  • 缺 entity_id(且缺 state_key)→ PBL-PARAM-0001「state_updates 每项必须含 entity_id(客户端禁写 state_version)」;entity_id 截断 [:64]
  • 无 state(且无 state_value)或值为 None → PBL-PARAM-0001「state_updates 每项必须含 state」

2.2 read_states 签名(tx_write.py:466-467)

async def read_states(tenant_id=None, session_id=None, entity_ids=None,
                      world_id=None, env=None):

tenant_id 强制;session_id 可选过滤;entity_ids 可选 IN 过滤;固定 ORDER BY entity_id ASC LIMIT 500。


3. 真源返回键语义(逐键,源码行号锚定)

3.1 成功写分支(tx_write.py:456-462,14 键)

键 语义
ok 恒 True(异常一律走 RtxError,不返回 ok=False)
dedup 恒 False(未命中幂等)
event_id 事件行主键(tx.insert 返回)
event_uid 幂等键值(idem_key_of 产物)
seq / seq_no 会话内单调序号;seq_no 是对外响应别名,两者恒等
cursor 广播游标(取 evt.get("cursor"),夹具路径下可为 None)
state_rows len(states)(本次写入的状态条数)
states _apply_state() 返回的逐项列表(键见 §3.4)
latency_ms 事务全程裁决耗时(perf_counter 差值,round(...,3));SLA 阈值 SLA_ADJUDICATE_MS=200
broadcast_ms 提交后广播扇出耗时;SLA 阈值 SLA_BROADCAST_MS=300
channel broadcast.channel_of(world_id, tenant_id) → pbl.world.<tenant>.<world>(断言取真源产物,不写字面量)
sla_breach _lat.record("adjudicate", ms) 的返回值(超阈值 True)
stmt_count tx.stmt_count:仅统计本事务实际发出的 DML(幂等命中分支为 1,即只有那次 SELECT 后的 commit,见守卫实测)

3.2 幂等命中分支(tx_write.py:380-389 + :441-445,10 键)

键集合 = {ok, dedup, event_id, event_uid, seq, cursor, state_rows, latency_ms, channel, stmt_count} ——没有 states / seq_no / broadcast_ms / sla_breach。

  • dedup=True,event_id/seq 复用既有事件行(不新写、不重复广播,state_rows=0)
  • ⚠️ latency_ms 初值为 None,但真源在返回前统一回填 dedup_result["latency_ms"] = ms (tx_write.py:441-442),故实际返回数值。旧断言「幂等分支 latency_ms is None」与实现不符, 已在守卫用例改为「数值且 ≥ 0」(本次 QC #4 修正点之一)。

3.3 广播与提交次序(不可变契约)

await tx.commit() 在 with 块内(tx_write.py:428),hub.publish 在块外(tx_write.py:449)—— 广播严格晚于提交,杜绝幽灵更新;广播失败抛 PBL-BROAD-0001(数据已提交不回滚,客户端 3s 轮询兜底 POLL_FALLBACK_MS)。轨迹断言:trace_kinds 中 index("BROADCAST") > index("COMMIT")。

3.4 states[] 单项键(_apply_state 返回,tx_write.py:339-340)

{entity_id, state_version, action, state_key, state_value, seq} 其中 action ∈ {"insert","update"},state_version 由服务端单调递增(新建=1,命中则 cur+1), state_key/state_value 是 entity_id/入参状态的别名(列名映射 STATE_COL_KEY="entity_id"、 STATE_COL_VALUE="state_json",tx_write.py:61-62)。

3.5 read_states 行形状

SELECT 列 entity_id, state_json, state_version, checksum, updated_by, updated_at (tx_write.py:471-472)+ 出参加工:state(_loads(state_json, {}))、state_value(同值别名)、 entity_id(列名对齐)。


4. RtxError.code 各码含义

RtxError(code, msg) 定义在 pbl_runtime_ext/rtx_db.py:29,属性 .code / .msg, str() 形如 [PBL-PARAM-0001] xxx。常量集中在 m11b_config.py:36-41。

code 常量名 抛出点 含义与后果
PBL-TENANT-0001 ERR_TENANT_MISSING _require_tenant tx_write.py:255 tenant_id 缺失,fail-closed 拒绝读写(读写两侧都校验)
PBL-PARAM-0001 ERR_BAD_PARAM tx_write.py:356/359/297/300/305 必填缺失 / state_updates 非数组 / 逐项缺 entity_id 或缺 state
PBL-STATE-CONFLICT ERR_STATE_CONFLICT _apply_state tx_write.py:316 base_version 与库中 state_version 不符 → 整个事务 ROLLBACK,两表零增量
PBL-RTX-0001-APPEND ERR_APPEND_ONLY assert_append_only tx_write.py:265/268 对 pbl_runtime_event 发非 INSERT/SELECT,或对基表只读域(PROTECTED_BASE_TABLES)写入
PBL-BROAD-0001 ERR_BROADCAST_AFTER_COMMIT tx_write.py:454 数据已提交但广播失败——不回滚事实,由 3s 轮询兜底补齐
PBL-RTX-0002 ERR_TX_UNSUPPORTED 仅 m11b_config.py:39 定义,tx_write 路径当前未抛出 预留给「驱动不支持事务」分支;写测试时不要假设它会出现
PBL-RTX-0003 字面量(tx_write.py:179/184) build_row models/<table>.json 不可读,或传入未知列(防静默丢列写空行)
PBL-RTX-0004 字面量(tx_write.py:191) build_row(full_row=True) INSERT 缺 NOT NULL 且无 DB 默认值的列(required_columns() 已排除自增列/NOT NULL DEFAULT 列)
PBL-RTX-0006 字面量(tx_write.py:196) build_row(full_row=False) UPDATE SET 了不可变列(tenant_id / id / created_at)

夹具层同名 re-export:ERR_UNKNOWN_COLUMN="PBL-RTX-0003"、ERR_MISSING_NOT_NULL="PBL-RTX-0004"、 ERR_IMMUTABLE_COLUMN="PBL-RTX-0006"(真源以字面量抛出,夹具补常量名供断言,避免测试写魔法字符串)。


5. 已废弃旧 API 清单与替代

废弃符号 曾存在位置 状态 替代(现行唯一路径)
RuntimeStateEventWriter(类式垫片) tests/_m11b2_compat.py(M11b-2c 形态) 已删除,禁止复活 compat.install(RuntimeEngine()) + compat.apply(**kw)
configure_writer 同上 已删除 同上(无全局配置态,夹具按用例 setUp/tearDown 安装/还原)
build_plan 同上 已删除 直接传 state_updates=[...] 给 apply_runtime_event;需要看 SQL 用 update_statements / insert_statements
类式 write_event_with_state(夹具转发路径) 垫片方法 → world_sync/pbl_runtime_tx.write_event_with_state 夹具层禁止使用 compat.apply(...) → 真源 apply_runtime_event
_augment 补假键(state_inserted / expected_version 等) 旧垫片 已删除 直接断言真源返回键(§3),缺键=真源没给,不再由夹具编造

边界说明(避免误读):write_event_with_state 作为业务实现函数仍存在于 world_sync/pbl_runtime_tx.py:351 与 world_sync/pbl_runtime_tx_sqlor.py:126(宿主连接池版), 本任务禁改业务实现,未做任何删除。废弃的是**「把它当测试夹具第二条路径」**这一用法。 tests/test_m11b2_wiring.py(并行任务独占文件)仍 import 该函数,属其自身范围,本任务不碰。

废弃原因(实测,非推测):旧类式垫片把测试钉在「已删除的调用形态 + world_sync 本地同步实现」上, 既没验证现行真源,又与真源返回键不一致,垫片只能靠 _augment 补假键维持旧断言, 实测 22 failed(Cannot operate on a closed database. / KeyError: 'expected_version')。


6. 机械守卫(旧符号不得复活)

modules/world_sync/tests/test_m11b2d_fixture_contract.py(本任务交付物,由一次性探针 .m11b2d_probe.py 升级而来,探针已由 git rm 移除,删除提交 81fdb86):

组 用例 守卫内容
FixtureSurfaceCase test_dead_class_style_symbols_absent 4 个旧类式符号不得 hasattr(compat, ...)
test_live_function_style_symbols_present 函数式入口 + 真源 re-export 必须齐备
test_fixture_apply_is_thin_wrapper_on_true_source compat.apply 源码内不得出现 update(/setdefault/_augment(零增补)
test_true_source_signature §2 的 14 形参名/顺序/默认值
test_read_states_signature §2.2 的 5 形参
ReturnKeysCase test_success_return_keys_exact §3.1 键集合逐键 + states[0] 键集合(§3.4)
test_dedup_return_keys_exact_and_no_second_write §3.2 键集合 + 不重复写/不重复广播
test_read_states_rows_shape §3.5 行形状
test_snapshot_writes_snapshot_table snapshot 参数落 pbl_world_state_snapshot
test_broadcast_strictly_after_commit §3.3 次序契约
test_event_table_is_append_only UPDATE 语句不得出现事件表名
GateErrorCase test_missing_tenant_is_fail_closed / test_missing_required_params / test_bad_state_updates / test_state_conflict_rolls_back §4 各码 + 冲突分支回滚不留脏行

执行证据:tests/evidence/m11b2d_a3_fixture_contract_pytest.txt(15 passed)、 tests/evidence/m11b2d_a3_fixture_contract_collect_only.txt(collection error = 0)。


7. QC #1 越权变更的回退记录与移交

事实:本任务上一轮收口提交 32db6aa(引擎代收口)实际改动了并行任务独占文件 tests/test_m11b2_single_tx.py(+23/-,两处:state_updates 包装成数组、乐观锁 SET/WHERE 断言口径), 与交付件正文「不改 test_m11b2_single_tx.py」的承诺矛盾。QC 判定成立。

处置:本轮以 git apply -R(逆向应用 32db6aa 中该文件的 diff)精确剔除本任务的 23 行改动, 不用 git restore HEAD^ 之类粗粒度动作——因此并行任务 A1 自己的 16 行改动(提交 11aac66)完整保留, 未受任何影响。剔除后该文件相对 81fdb86(本任务前状态)的差异只剩 A1 的改动,可复核:

git diff 81fdb86 -- tests/test_m11b2_single_tx.py   # 仅 A1 的 3 处外观修正

遗留(移交,不夹带):回退后 tests/test_m11b2_single_tx.py 实测 8 failed, 32 passed, 8 xfailed (证据 tests/evidence/m11b2d_a3_single_tx_after_revert.txt)。失败根因是该文件仍按旧类式夹具口径 写断言,与真源不符,需两处修正(本任务无权改,移交该文件独占方 / 另开任务):

  1. TxCase.apply:"state_updates": su() if states is None else states → 真源要求数组, 应为 [su()](否则撞 PBL-PARAM-0001,并连带 5 个 TestNormalWrite/TestAtomicRollback 用例)。
  2. test_update_path_bumps_version_server_side:真源 UPDATE 把新版本写在 SET 子句 (state_version=%s),WHERE 只按 tenant_id/session_id/entity_id 定位,不做 SQL 层 CAS; 旧断言「SET 不含 state_version」与实现相反,且参数序为 (state_json, state_version, checksum, updated_by, updated_at, tenant_id, session_id, entity_id)。

本任务只登记不修,避免再次越权。


8. 后续任务引用规约(硬性)

  1. 新测试写运行时事务:一律 import _m11b2_compat as compat → compat.install(compat.RuntimeEngine()) → compat.apply(...) / compat.read(...);addCleanup(compat.uninstall)。不得新建类式垫片、 不得 import world_sync.pbl_runtime_tx.write_event_with_state 作为夹具入口。
  2. 表名 / 错误码 / 通道名:只从 compat 或真源常量取(compat.EVENT_TABLE、compat.ERR_BAD_PARAM、 rtx_broadcast.channel_of(...)),禁止第二份字面量。
  3. 返回键断言:以本文档 §3 为准;新增键须先改真源、再同步 §3 与守卫用例(三者不一致即红)。
  4. occurred_at 必须逐事件唯一:事件表两条唯一索引含分区列 created_at,同秒两条会撞 DB 唯一索引 (真源不填 idem_key,DEFAULT '')。用各测试文件自带的 stamp() 取互不相同时间戳。
  5. 乐观锁:只在 Python 侧用 base_version 表达期望,不要在 SQL 里找 version CAS。
  6. 广播:断言 BROADCAST 轨迹晚于 COMMIT;广播失败期望 PBL-BROAD-0001 且数据仍在库。
  7. 夹具不可改:tests/_m11b2_compat.py 既有符号只读;需要新能力时在本文件追加并同步本文档 §1, 不改既有签名与语义。
  8. 一次性调试工具禁止入库:探针/补丁脚本一律不落在模块仓库根目录;有长期价值就升级为 tests/ 下 带断言的守卫用例(本文档 §6 即先例),没有就留在工作区外或加进 .gitignore。

证据索引(消 QC #3「张冠李戴」)

文件 跑的是什么 归属
tests/evidence/m11b2d_a3_fixture_contract_pytest.txt test_m11b2d_fixture_contract.py -v,15 passed 本任务交付物执行证据
tests/evidence/m11b2d_a3_fixture_contract_collect_only.txt 同上 --collect-only -q 本任务可收集性证明
tests/evidence/m11b2d_a3_single_tx_after_revert.txt test_m11b2_single_tx.py -q --tb=no,8 failed 本任务越权回退后的真实状态(§7 移交依据)
tests/evidence/m11b2d_a1_pytest.txt / m11b2d_a1_collect_only.txt test_m11b2_single_tx.py(48 项) 并行任务 M11b-2d-A1 的产物证据,非本任务交付物;本任务未改名(改名会二次触碰 A1 归档),在此登记归属