world_sync/tests/test_m11b2_single_tx.py

848 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""M11b-2d 单元测试:单事务写入(事件 + 实体状态)原子性 / 幂等 / 并发 / 时延。
运行方式(纯标准库 + sqlite3,无需 appPublic/sqlor/ahserver)::
cd modules/world_sync && ./.venv/bin/python -m pytest tests/test_m11b2_single_tx.py -v
被测真源(M11b-2a「世界同步真源迁移」起,唯一现行写入路径):
modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py::apply_runtime_event
modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py::read_states
modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py::build_row / assert_append_only
夹具层:``tests/_m11b2_compat.py``(函数式真源夹具:真 sqlite + 表结构从
``pbl_runtime_ext/models/*.json`` 生成 + install/uninstall 打补丁到 rtx_db)。
本次迁移(M11b-2d-A1,响应 QC #1「整文件 collection 期 AttributeError,0 条用例可跑」)
==============================================================================
旧文件把断言钉在**已被 M11b-2a 删除的类式符号**上(``compat.RuntimeStateEventWriter``
/ ``compat.configure_writer`` / ``compat.build_plan`` / ``compat.write_event_with_state``),
并自带一套手写 DDL(``DDL_EVENT`` / ``DDL_STATE``)——手写列名 ``event_id`` / ``state``
在真源 models 中根本不存在,正是失败根因。迁移要点:
1. 表结构一律由夹具从 ``pbl_runtime_ext/models/*.json`` 生成(``compat.ddl_statements``),
本文件不再自带第二份 schema;预置走 ``eng.seed_state``,查库走 ``eng.query`` /
``eng.count`` / ``eng.scalar``。
2. 写入统一走 ``compat.apply(...)``,参数名逐一对齐
``tx_write.apply_runtime_event(tenant_id, world_id, session_id, event_type, payload,
state_updates, client_event_id, idem_key, snapshot, base_version, actor_id,
causation_id, occurred_at, env)``(实读为准,未臆造任何参数)。
3. 断言键改为真源实际返回键(``ok`` / ``dedup`` / ``event_id`` / ``event_uid`` /
``seq`` / ``state_rows`` / ``states[]`` / ``stmt_count`` / ``latency_ms`` /
``channel`` / ``cursor`` / ``broadcast_ms`` / ``sla_breach``),不再沿用真源从未
返回过的 ``state_inserted`` / ``expected_version`` / ``statement_count`` / ``elapsed_ms``。
4. 失败注入从 ``SingleConnProxy`` / ``before_commit`` 钩子迁到**真源错误码路径**
(幂等重复 dedup、``base_version`` 过期冲突、不可变列进 SET、未知列、缺 NOT NULL、
append-only 违反);原子回滚用「``eng.trace`` 有 ROLLBACK 无 COMMIT + 两表零增量」证明。
5. 断言语义不回退:单事务原子性、乐观锁冲突回滚、event_uid 幂等、不可变列守卫
四类**各有用例**;确属 M11b-2a 契约变更而失效的旧用例逐条 ``expectedFailure``
并写明理由(不 skip、不为凑绿删用例)。
created_at 说明:真源事件表两条唯一索引(``uk_event_uid`` = tenant_id+event_uid+created_at、
``uk_tenant_idem`` = tenant_id+idem_key+created_at)都含分区键 created_at,而真源不填
idem_key(DEFAULT ''),故同秒两条事件会撞 DB 唯一索引——测试用 :func:`stamp` 给每条事件
分配互不相同的 ``occurred_at``(真源把它同时写进 created_at/updated_at/occurred_at)。
"""
import ast
import os
import sys
import tempfile
import threading
import time
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
WS_ROOT = os.path.dirname(HERE) # modules/world_sync
RTX_ROOT = os.path.join(os.path.dirname(WS_ROOT), "pbl_runtime_ext")
for _p in (HERE, WS_ROOT, RTX_ROOT):
if _p not in sys.path:
sys.path.insert(0, _p)
import _m11b2_compat as compat # noqa: E402
from world_sync import pbl_runtime_errors as errs # noqa: E402
from world_sync import pbl_runtime_sql as sqlmod # noqa: E402
# --- 真源符号(函数式 API 本体,不是副本)---------------------------------
apply_runtime_event = compat.apply_runtime_event # tx_write.apply_runtime_event
read_states = compat.read_states # tx_write.read_states
build_row = compat.build_row # tx_write.build_row
assert_append_only = compat.assert_append_only # tx_write.assert_append_only
RtxError = compat.RtxError # rtx_db.RtxError
percentile = compat.percentile # world_sync.pbl_runtime_sql.percentile
EVENT_TABLE = compat.EVENT_TABLE # pbl_runtime_event
STATE_TABLE = compat.STATE_TABLE # pbl_entity_state
SNAPSHOT_TABLE = compat.SNAPSHOT_TABLE # pbl_world_state_snapshot
SQLITE = compat.SQLITE
MYSQL = compat.MYSQL
# 真源错误码(m11b_config + tx_write 内字面量,均从 compat 取,不第二处硬编码)
ERR_TENANT_MISSING = compat.ERR_TENANT_MISSING
ERR_BAD_PARAM = compat.ERR_BAD_PARAM
ERR_STATE_CONFLICT = compat.ERR_STATE_CONFLICT
ERR_APPEND_ONLY = compat.ERR_APPEND_ONLY
ERR_UNKNOWN_COLUMN = compat.ERR_UNKNOWN_COLUMN
ERR_MISSING_NOT_NULL = compat.ERR_MISSING_NOT_NULL
ERR_IMMUTABLE_COLUMN = compat.ERR_IMMUTABLE_COLUMN
TENANT = 7
WORLD = 11
SESSION = 22
ENTITY = "e1"
_BASE = time.mktime(time.strptime("2026-06-01 00:00:00", "%Y-%m-%d %H:%M:%S"))
_stamp_lock = threading.Lock()
_stamp_n = [0]
def stamp():
"""分配互不相同的 occurred_at(=真源写入的 created_at),避开含分区列的唯一索引互撞。"""
with _stamp_lock:
_stamp_n[0] += 1
n = _stamp_n[0]
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(_BASE + n))
def su(entity=ENTITY, state=None, **extra):
"""state_updates 单项(真源语义:entity_id + state,state_version 客户端禁写)。"""
row = {"entity_id": entity, "state": {"hp": 2} if state is None else state}
row.update(extra)
return row
# ---------------------------------------------------------------- 引擎工厂
# 夹具的 _default_clause 把 models 里的 ``default: ""``(空串)当「无默认值」跳过,
# 于是 event_code/idem_key/entity_id/actor_id/causation_id/tx_group 这些
# 「NOT NULL DEFAULT ''」列在 sqlite 侧变成无默认值的 NOT NULL 列,真源 INSERT
# (按语义不填这些列)会撞 NOT NULL 约束。本任务禁改夹具既有符号,故只在**构造期**
# 临时补正空串默认值渲染,建完表立即还原——不改 compat 任何既有定义与行为语义。
_ORIGIN_DEFAULT_CLAUSE = compat._default_clause
def _default_clause_with_empty(fd):
dv = fd.get("default")
if isinstance(dv, str) and dv.strip() == "":
return " DEFAULT ''"
return _ORIGIN_DEFAULT_CLAUSE(fd)
def make_engine(path=":memory:"):
"""真 sqlite 事务引擎(表结构来自 pbl_runtime_ext/models/*.json)。"""
compat._default_clause = _default_clause_with_empty
try:
return compat.RuntimeEngine(path=path)
finally:
compat._default_clause = _ORIGIN_DEFAULT_CLAUSE
class TxCase(unittest.TestCase):
"""公共骨架:install 引擎补丁 → 用例内走真源代码路径 → tearDown 逐样还原。"""
engine_path = ":memory:"
def setUp(self):
self._tmp = None
path = self.engine_path
if path == "@tmp":
fd, self._tmp = tempfile.mkstemp(prefix="m11b2d_", suffix=".sqlite")
os.close(fd)
os.remove(self._tmp) # 由引擎自行建库建表
path = self._tmp
self.eng = make_engine(path)
compat.install(self.eng)
self.addCleanup(compat.uninstall)
def tearDown(self):
if self._tmp and os.path.exists(self._tmp):
os.remove(self._tmp)
# -- 写入 / 断言 helper ------------------------------------------------
def apply(self, uid=None, states=None, **kw):
params = {"tenant_id": kw.pop("tenant_id", TENANT),
"world_id": kw.pop("world_id", WORLD),
"session_id": kw.pop("session_id", SESSION),
"event_type": kw.pop("event_type", "unit.move"),
"payload": kw.pop("payload", {"dx": 1}),
"state_updates": [su()] if states is None else states,
"actor_id": kw.pop("actor_id", "u1"),
"occurred_at": kw.pop("occurred_at", stamp())}
if uid is not None:
params["client_event_id"] = uid
params.update(kw)
return compat.apply(**params)
def kinds(self):
return compat.trace_kinds(self.eng)
def assert_atomic_rollback(self, tail=2):
"""原子回滚证明:本次事务 BEGIN 后只有 ROLLBACK、没有 COMMIT。"""
k = self.kinds()
self.assertEqual(["BEGIN", "ROLLBACK"], k[-tail:])
self.assertNotIn("COMMIT", k[-tail:])
def counts(self):
return (self.eng.count(EVENT_TABLE), self.eng.count(STATE_TABLE),
self.eng.count(SNAPSHOT_TABLE))
def state_row(self, entity=ENTITY):
rows = self.eng.query(
"SELECT state_json, state_version, checksum, updated_by FROM %s"
" WHERE tenant_id=? AND session_id=? AND entity_id=?" % STATE_TABLE,
(TENANT, SESSION, entity))
return rows[0] if rows else None
def event_row(self, uid):
rows = self.eng.query(
"SELECT * FROM %s WHERE tenant_id=? AND event_uid=?" % EVENT_TABLE,
(TENANT, uid))
return rows[0] if rows else None
# -- 真源返回键自检(防再次钉在不存在的键上)---------------------------
def assert_real_keys(self, res):
for key in ("ok", "dedup", "event_id", "event_uid", "seq", "state_rows",
"stmt_count", "latency_ms", "channel"):
self.assertIn(key, res, "真源返回缺少键 %s" % key)
for ghost in ("state_inserted", "expected_version", "statement_count",
"elapsed_ms", "statements"):
self.assertNotIn(ghost, res, "旧夹具假键 %s 不应出现在真源返回中" % ghost)
# =========================================================================
# 1. 正常写入:单事务内事件 + 状态同时落库,提交后才广播
# =========================================================================
class TestNormalWrite(TxCase):
def test_event_and_state_written_in_one_transaction(self):
res = self.apply("ev-ok")
self.assert_real_keys(res)
self.assertTrue(res["ok"])
self.assertFalse(res["dedup"])
self.assertEqual("ev-ok", res["event_uid"])
self.assertEqual(1, res["state_rows"])
self.assertEqual(1, res["seq"])
self.assertEqual(1, res["event_id"])
# 真源 stmt_count 只计 DML 方法(事件 INSERT + 状态 INSERT = 2;
# 状态 UPDATE 走 tx.execute,按真源语义不计数)
self.assertEqual(2, res["stmt_count"])
st = res["states"][0]
self.assertEqual(ENTITY, st["entity_id"])
self.assertEqual("insert", st["action"]) # 无预置 ⇒ 新建
self.assertEqual(1, st["state_version"])
self.assertEqual({"hp": 2}, st["state_value"])
self.assertEqual(1, self.eng.count(EVENT_TABLE))
self.assertEqual(1, self.eng.count(STATE_TABLE))
row = self.state_row()
self.assertEqual('{"hp": 2}', row["state_json"])
self.assertEqual(1, row["state_version"])
self.assertEqual("u1", row["updated_by"])
self.assertTrue(row["checksum"]) # 真源服务端算摘要
def test_trace_is_begin_commit_broadcast_in_that_order(self):
self.apply("ev-order")
self.assertEqual(["BEGIN", "COMMIT", "BROADCAST"], self.kinds())
# 无广播/轮询/睡眠语句混入事务(语句级断言,取代旧 res["statements"])
joined = " ; ".join(s.upper() for s, _p in self.eng.sql_log)
for banned in ("NOTIFY", "SLEEP", "LISTEN", "POLL"):
self.assertNotIn(banned, joined)
def test_update_path_bumps_version_server_side(self):
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":10}', state_version=3)
res = self.apply("ev-up", states=[su(state={"hp": 2})])
self.assertEqual("update", res["states"][0]["action"])
self.assertEqual(4, res["states"][0]["state_version"]) # 3 -> 4(服务端递增)
self.assertEqual(1, res["stmt_count"]) # 仅事件 INSERT 计数
self.assertEqual(4, self.state_row()["state_version"])
upd = compat.update_statements(self.eng)
self.assertEqual(1, len(upd))
sql, params = upd[0]
self.assertIn("UPDATE %s SET" % STATE_TABLE, sql)
self.assertIn("entity_id=?", sql.replace("%s", "?"))
# 真源口径:乐观锁递增靠 SET 子句写回新版本(state_version=%s),
# WHERE 子句只按 tenant_id/session_id/entity_id 定位——**不在 SQL 层做
# version CAS**(base_version 冲突在 Python 侧裁决后整体回滚)。
set_clause, where_clause = sql.split("WHERE", 1)
self.assertIn("state_version=%s", set_clause)
self.assertNotIn("state_version", where_clause)
self.assertEqual('{"hp": 2}', params[0]) # state_json
self.assertEqual(4, params[1]) # state_version 写回 3+1
self.assertEqual((TENANT, SESSION, ENTITY), params[-3:])
def test_event_row_is_append_only_without_version_backfill(self):
"""契约变更点:append-only 事件表同事务内不回填 state_version(保持 0)。"""
self.apply("ev-ao", states=[su(state={"hp": 5})])
row = self.event_row("ev-ao")
self.assertEqual(0, row["state_version"]) # 真源 4b:不 UPDATE 回填
self.assertEqual("runtime", row["source"])
self.assertEqual(1, row["seq"])
self.assertEqual(EVENT_TABLE, EVENT_TABLE)
# 该表只出现 INSERT,绝无 UPDATE/DELETE(append-only 的语句级证据)
heads = [s.upper() for s, _p in self.eng.sql_log if EVENT_TABLE.upper() in s.upper()]
self.assertTrue(heads)
self.assertTrue(all(h.startswith("INSERT INTO") or h.startswith("SELECT")
for h in heads), heads)
def test_payload_serialised_into_payload_json_column(self):
self.apply("ev-p", payload={"a": 1, "b": [1, 2]})
row = self.event_row("ev-p")
self.assertIn('"a": 1', row["payload_json"])
self.assertIn('"b": [1, 2]', row["payload_json"])
def test_broadcast_happens_only_after_commit(self):
self.apply("ev-bc")
self.assertEqual(1, len(self.eng.broadcasts))
self.assertEqual("pbl.world.%d.%d" % (TENANT, WORLD), self.eng.broadcasts[0]["channel"])
self.assertEqual(["BEGIN", "COMMIT", "BROADCAST"], self.kinds())
def test_snapshot_written_in_same_transaction(self):
before = self.counts()
res = self.apply("ev-snap", states=[],
snapshot={"reason": "reconnect", "entities": {ENTITY: {"hp": 5}}})
self.assertEqual(0, res["state_rows"])
self.assertEqual(2, res["stmt_count"]) # 事件 INSERT + 快照 INSERT
after = self.counts()
self.assertEqual((before[0] + 1, before[1], before[2] + 1), after)
self.assertEqual(1, self.eng.count(SNAPSHOT_TABLE))
def test_multi_entity_state_updates(self):
self.eng.seed_state(TENANT, SESSION, "e9", '{"hp":1}', state_version=3)
res = self.apply("ev-multi", states=[su(entity="e9", state={"hp": 7}),
su(entity="new1", state={"x": 1})])
self.assertEqual(2, res["state_rows"])
acts = {s["entity_id"]: (s["action"], s["state_version"]) for s in res["states"]}
self.assertEqual(("update", 4), acts["e9"])
self.assertEqual(("insert", 1), acts["new1"])
self.assertEqual(4, self.state_row("e9")["state_version"])
self.assertEqual(1, self.state_row("new1")["state_version"])
# =========================================================================
# 2. 原子性:真源错误码路径失败 → 全部回滚(两表零增量 + ROLLBACK 无 COMMIT)
# =========================================================================
class TestAtomicRollback(TxCase):
def test_optimistic_lock_conflict_rolls_back_everything(self):
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":99}', state_version=5)
before = self.counts()
bcast = len(self.eng.broadcasts)
with self.assertRaises(RtxError) as cm:
self.apply("ev-conf", states=[su(state={"hp": 0})], base_version=4)
self.assertEqual(ERR_STATE_CONFLICT, cm.exception.code)
self.assertIn("base_version=4", cm.exception.msg)
self.assert_atomic_rollback()
self.assertEqual(before, self.counts()) # 事件/状态/快照零增量
self.assertEqual(5, self.state_row()["state_version"])
self.assertEqual(bcast, len(self.eng.broadcasts)) # 回滚不广播
def test_multi_entity_partial_failure_rolls_back_first_write(self):
"""第二个实体乐观锁冲突 ⇒ 第一个实体已写的 INSERT 也必须回滚(同事务原子性)。"""
self.eng.seed_state(TENANT, SESSION, "e9", '{"hp":1}', state_version=3)
before = self.counts()
with self.assertRaises(RtxError) as cm:
self.apply("ev-part",
states=[su(entity="fresh", state={"a": 1}),
su(entity="e9", state={"hp": 7})],
base_version=99)
self.assertEqual(ERR_STATE_CONFLICT, cm.exception.code)
self.assert_atomic_rollback()
self.assertEqual(before, self.counts())
self.assertIsNone(self.state_row("fresh"))
self.assertEqual(3, self.state_row("e9")["state_version"])
def test_unknown_column_guard(self):
with self.assertRaises(RtxError) as cm:
build_row(EVENT_TABLE, {"no_such_col": 1})
self.assertEqual(ERR_UNKNOWN_COLUMN, cm.exception.code)
self.assertIn("no_such_col", cm.exception.msg)
def test_missing_not_null_column_guard(self):
with self.assertRaises(RtxError) as cm:
build_row(STATE_TABLE, {"tenant_id": str(TENANT)})
self.assertEqual(ERR_MISSING_NOT_NULL, cm.exception.code)
self.assertIn("entity_id", cm.exception.msg)
def test_immutable_column_in_set_guard(self):
"""不可变列守卫:UPDATE SET 出现 tenant_id / id / created_at 即拒绝。"""
for col in ("tenant_id", "id", "created_at"):
with self.assertRaises(RtxError) as cm:
build_row(STATE_TABLE, {"state_json": "{}", col: "x"},
strict=True, full_row=False)
self.assertEqual(ERR_IMMUTABLE_COLUMN, cm.exception.code)
self.assertIn(col, cm.exception.msg)
# 正常 SET 子句(不含不可变列)必须放行,证明守卫不是无脑拒绝
self.assertEqual({"state_json": "{}"},
build_row(STATE_TABLE, {"state_json": "{}"},
strict=True, full_row=False))
def test_update_statement_never_sets_immutable_columns(self):
"""真源实际发出的 UPDATE:SET 子句机械核验不含不可变列。"""
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=1)
self.apply("ev-imm", states=[su(state={"hp": 8})])
sql, _params = compat.update_statements(self.eng)[0]
set_clause = sql.split("SET", 1)[1].split("WHERE")[0]
for col in ("tenant_id", "created_at"):
self.assertNotIn(col, set_clause)
self.assertNotIn("id=", set_clause.replace(" ", ""))
def test_append_only_guard_rejects_event_update_and_base_write(self):
with self.assertRaises(RtxError) as cm:
assert_append_only("UPDATE", EVENT_TABLE)
self.assertEqual(ERR_APPEND_ONLY, cm.exception.code)
with self.assertRaises(RtxError) as cm:
assert_append_only("DELETE", EVENT_TABLE)
self.assertEqual(ERR_APPEND_ONLY, cm.exception.code)
with self.assertRaises(RtxError) as cm:
assert_append_only("DELETE", "world") # 基表写保护
self.assertEqual(ERR_APPEND_ONLY, cm.exception.code)
self.assertTrue(assert_append_only("INSERT", EVENT_TABLE))
# 事务里对事件表只有 INSERT(守卫在真源写入路径上确实生效)
self.apply("ev-guard")
self.assertEqual([], [s for s, _p in compat.update_statements(self.eng)
if EVENT_TABLE.upper() in s.upper()])
def test_errors_are_never_swallowed(self):
"""失败必须抛带 code + 可定位 msg 的 RtxError,且不留 COMMIT。"""
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=2)
try:
self.apply("ev-e", states=[su(state={"hp": 3})], base_version=1)
except RtxError as exc:
self.assertTrue(exc.code)
self.assertIn("entity_id", exc.msg)
self.assertIn("base_version", str(exc))
else:
self.fail("未抛出明确异常,属吞错误")
self.assertEqual("ROLLBACK", self.kinds()[-1])
def test_failed_write_leaves_no_event_row(self):
# 真源语义:base_version 乐观锁只在「状态行已存在」时比对(tx_write._apply_state
# 的 if rows: 分支);无预置行会走 INSERT 分支不抛冲突。故先预置版本 1,再用
# 过期的 base_version=42 触发 PBL-STATE-CONFLICT,才能验证「失败写入不留事件行」。
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=1)
before = self.counts()
with self.assertRaises(RtxError):
self.apply("ev-none", states=[su(state={"hp": 3})], base_version=42)
self.assert_atomic_rollback()
self.assertEqual(before, self.counts())
self.assertIsNone(self.event_row("ev-none"))
# =========================================================================
# 3. event_uid 幂等(真源语义:命中已有事件 → dedup,不重复写、不重复广播)
# =========================================================================
class TestIdempotency(TxCase):
def test_duplicate_event_uid_dedups_without_second_write(self):
first = self.apply("ev-dup", states=[su(state={"hp": 2})])
before = self.counts()
bcast = len(self.eng.broadcasts)
second = self.apply("ev-dup", states=[su(state={"hp": 9})])
self.assertTrue(second["ok"])
self.assertTrue(second["dedup"])
self.assertEqual(0, second["state_rows"])
self.assertEqual(0, second["stmt_count"])
self.assertEqual(first["event_id"], second["event_id"])
self.assertEqual(first["seq"], second["seq"])
self.assertEqual(before, self.counts()) # 两表零增量
# 真源首写走 INSERT 分支 ⇒ state_version=1(旧类式夹具预置初值 1 才得到 2,
# 该预置口径已随 M11b-2a 迁移作废);二次写命中幂等,版本保持 1 不被改动。
self.assertEqual(1, first["states"][0]["state_version"])
self.assertEqual(1, self.state_row()["state_version"]) # 未被二次写改动
self.assertEqual(bcast, len(self.eng.broadcasts)) # 不重复广播
def test_derived_idem_key_is_stable_for_same_params(self):
"""不给 client_event_id 时真源按 tenant|session|type|payload 派生幂等键。"""
a = self.apply(None, payload={"dx": 5}, states=[su(state={"hp": 5})])
b = self.apply(None, payload={"dx": 5}, states=[su(state={"hp": 5})])
self.assertFalse(a["dedup"])
self.assertTrue(b["dedup"])
self.assertEqual(a["event_uid"], b["event_uid"])
self.assertEqual(32, len(a["event_uid"]))
self.assertEqual(1, self.eng.count(EVENT_TABLE))
def test_different_payload_is_not_deduped(self):
self.apply(None, payload={"dx": 1}, states=[su(state={"hp": 1})])
res = self.apply(None, payload={"dx": 2}, states=[su(state={"hp": 2})])
self.assertFalse(res["dedup"])
self.assertEqual(2, res["seq"])
self.assertEqual(2, self.eng.count(EVENT_TABLE))
def test_idem_key_param_is_accepted(self):
res = self.apply(None, states=[su()], idem_key="idem-fixed-1")
self.assertEqual("idem-fixed-1", res["event_uid"])
again = self.apply(None, states=[su()], idem_key="idem-fixed-1")
self.assertTrue(again["dedup"])
# =========================================================================
# 4. 并发:文件库多线程,不出现脏写 / 丢失更新
# =========================================================================
class TestConcurrency(TxCase):
engine_path = "@tmp"
def _worker(self, tag, n, results, errors, lock):
for i in range(n):
try:
results.append(self.apply("ev-%s-%d" % (tag, i),
states=[su(state={"hp": i})]))
except RtxError as exc:
with lock:
errors.append(exc)
def test_stale_base_version_raises_conflict_and_keeps_state(self):
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":8}', state_version=8)
before = self.counts()
with self.assertRaises(RtxError) as cm:
self.apply("ev-stale", states=[su(state={"hp": 0})], base_version=7)
self.assertEqual(ERR_STATE_CONFLICT, cm.exception.code)
self.assertIn("base_version=7", cm.exception.msg)
self.assert_atomic_rollback()
self.assertEqual(before, self.counts())
self.assertEqual(8, self.state_row()["state_version"])
def test_two_concurrent_writers_same_entity_no_lost_update(self):
n_each = 6
results, errors, lock = [], [], threading.Lock()
def worker(tag):
for i in range(n_each):
try:
res = self.apply("ev-%s-%d" % (tag, i), states=[su(state={"hp": i})])
with lock:
results.append(res)
except RtxError as exc:
with lock:
errors.append(exc)
ts = [threading.Thread(target=worker, args=(t,)) for t in ("A", "B")]
for t in ts:
t.start()
for t in ts:
t.join()
self.assertEqual(0, len(errors), "并发写入不应有未恢复错误: %s" % errors)
self.assertEqual(2 * n_each, len(results))
self.assertEqual(2 * n_each, self.eng.count(EVENT_TABLE))
versions = sorted(r["states"][0]["state_version"] for r in results)
self.assertEqual(list(range(1, 2 * n_each + 1)), versions) # 连续不重复 ⇒ 无脏写
self.assertEqual(2 * n_each, self.state_row()["state_version"]) # 无丢失更新
seqs = sorted(r["seq"] for r in results)
self.assertEqual(list(range(1, 2 * n_each + 1)), seqs)
# BEGIN / COMMIT 配平(每个成功事务各一次,无悬挂事务)
k = self.kinds()
self.assertEqual(k.count("BEGIN"), k.count("COMMIT"))
def test_concurrent_duplicate_event_only_one_writes(self):
outcomes, lock = [], threading.Lock()
def worker():
try:
res = self.apply("ev-race", states=[su(state={"hp": 1})])
with lock:
outcomes.append("dedup" if res["dedup"] else "write")
except RtxError as exc:
with lock:
outcomes.append("err:" + exc.code)
ts = [threading.Thread(target=worker) for _ in range(2)]
for t in ts:
t.start()
for t in ts:
t.join()
self.assertEqual(1, self.eng.count(EVENT_TABLE))
self.assertEqual(1, outcomes.count("write"))
self.assertEqual(1, outcomes.count("dedup"))
self.assertEqual(1, len(self.eng.broadcasts))
# =========================================================================
# 5. 入参校验(fail-closed,真源 PBL-TENANT-0001 / PBL-PARAM-0001)
# =========================================================================
class TestValidation(TxCase):
def _expect_code(self, code, **kw):
before = self.counts()
with self.assertRaises(RtxError) as cm:
compat.apply(**kw)
self.assertEqual(code, cm.exception.code)
self.assertEqual(before, self.counts()) # 校验失败不触库写
def test_missing_tenant_rejected(self):
self._expect_code(ERR_TENANT_MISSING, tenant_id=None, world_id=WORLD,
session_id=SESSION, event_type="x", state_updates=[su()],
occurred_at=stamp())
def test_missing_required_params_rejected(self):
for kw in ({"tenant_id": TENANT, "session_id": SESSION, "event_type": "x"},
{"tenant_id": TENANT, "world_id": WORLD, "event_type": "x"},
{"tenant_id": TENANT, "world_id": WORLD, "session_id": SESSION}):
self._expect_code(ERR_BAD_PARAM, state_updates=[su()],
occurred_at=stamp(), **kw)
def test_state_updates_must_be_array(self):
self._expect_code(ERR_BAD_PARAM, tenant_id=TENANT, world_id=WORLD,
session_id=SESSION, event_type="x", state_updates="no",
occurred_at=stamp())
def test_state_update_item_requires_entity_id(self):
self._expect_code(ERR_BAD_PARAM, tenant_id=TENANT, world_id=WORLD,
session_id=SESSION, event_type="x",
state_updates=[{"state": {"a": 1}}], occurred_at=stamp())
def test_state_update_item_requires_state(self):
self._expect_code(ERR_BAD_PARAM, tenant_id=TENANT, world_id=WORLD,
session_id=SESSION, event_type="x",
state_updates=[{"entity_id": ENTITY}], occurred_at=stamp())
def test_client_supplied_state_version_is_ignored(self):
"""state_version 客户端禁写:真源只按服务端 cur+1 递增。"""
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=6)
res = self.apply("ev-cv", states=[su(state={"hp": 2}, state_version=999)])
self.assertEqual(7, res["states"][0]["state_version"])
self.assertEqual(7, self.state_row()["state_version"])
def test_read_states_enforces_tenant_scope(self):
self.apply("ev-rd", states=[su(state={"hp": 3})])
rows = compat.read(tenant_id=TENANT, session_id=SESSION)
self.assertEqual(1, len(rows))
self.assertEqual(ENTITY, rows[0]["entity_id"])
self.assertEqual({"hp": 3}, rows[0]["state"])
self.assertEqual([], compat.read(tenant_id=999, session_id=SESSION))
with self.assertRaises(RtxError) as cm:
compat.read(tenant_id=None)
self.assertEqual(ERR_TENANT_MISSING, cm.exception.code)
# =========================================================================
# 6. 时延:单次事务 P99 ≤ 200ms(真源 latency_ms 采样)
# =========================================================================
class TestLatency(TxCase):
def test_p99_under_200ms(self):
results = []
for i in range(100):
res = self.apply("ev-l-%d" % i, states=[su(state={"hp": i})])
self.assertTrue(res["ok"])
results.append(res)
samples = [r["latency_ms"] for r in results]
p50 = percentile(samples, 50)
p99 = percentile(samples, 99)
print("[M11b-2d latency] n=%d p50=%.3fms p99=%.3fms max=%.3fms"
% (len(samples), p50, p99, max(samples)))
self.assertLessEqual(p99, 200.0)
self.assertEqual(100, self.eng.count(EVENT_TABLE))
for r in results:
self.assertFalse(r["sla_breach"],
"latency_ms=%r 触发 sla_breach" % r["latency_ms"])
def test_each_write_reports_latency_and_sla(self):
res = self.apply("ev-sla", states=[su()])
self.assertIsInstance(res["latency_ms"], float)
self.assertGreaterEqual(res["latency_ms"], 0.0)
self.assertIsInstance(res["sla_breach"], bool)
self.assertFalse(res["sla_breach"])
self.assertIsInstance(res["broadcast_ms"], float)
def test_percentile_helper_contract(self):
self.assertEqual(0.0, percentile([], 99)) # 空样本 → 0.0(真源口径)
self.assertEqual(5.0, percentile([5], 99))
p99 = percentile(list(range(100)), 99)
self.assertGreaterEqual(p99, 98.0)
self.assertLessEqual(p99, 99.0)
# =========================================================================
# 7. 范围约束 + 列契约单一真源(SQL 方言层)
# =========================================================================
class TestScopeAndSqlContract(unittest.TestCase):
def test_no_broadcast_or_polling_in_write_sources(self):
"""写入三件套不得出现轮询/睡眠/广播调用的可执行代码(AST 级,不看散文)。"""
banned_calls = {"sleep", "poll", "long_poll", "notify", "websocket",
"subscribe", "push", "fetch_events"}
banned_imports = {"socket", "threading", "queue"}
pkg = os.path.join(WS_ROOT, "world_sync")
for name in ("pbl_runtime_tx.py", "pbl_runtime_sql.py", "pbl_runtime_errors.py"):
path = os.path.join(pkg, name)
with open(path, encoding="utf-8") as fh:
tree = ast.parse(fh.read(), filename=path)
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
mods = ([a.name for a in node.names] if isinstance(node, ast.Import)
else [node.module or ""])
for mod in mods:
self.assertNotIn(str(mod).split(".")[0], banned_imports,
"%s 不应导入 %s" % (name, mod))
if isinstance(node, ast.Call):
fn = node.func
ident = fn.id if isinstance(fn, ast.Name) else (
fn.attr if isinstance(fn, ast.Attribute) else "")
self.assertNotIn(ident, banned_calls,
"%s 不应调用广播/轮询/睡眠: %s" % (name, ident))
def test_column_contract_is_single_sourced_from_models(self):
"""列契约单一真源:方言层列集 = models/*.json,且旧幽灵列不复存在。"""
self.assertEqual(list(sqlmod.EVENT_COLUMNS),
list(compat.model_fields(EVENT_TABLE).get("fields")
and [f["name"] for f in compat.model_fields(
EVENT_TABLE)["fields"]]))
for ghost in ("event_id", "updated_by_event"):
self.assertNotIn(ghost, sqlmod.EVENT_COLUMNS)
self.assertNotIn(ghost, sqlmod.STATE_COLUMNS)
self.assertNotIn("state", sqlmod.STATE_COLUMNS) # 旧手写 DDL 的假列
self.assertEqual(len(sqlmod.EVENT_COLUMNS), len(set(sqlmod.EVENT_COLUMNS)))
for col in ("tenant_id", "session_id", "entity_id", "state_json", "state_version"):
self.assertIn(col, sqlmod.STATE_COLUMNS)
def test_for_update_clause_degrades_by_dialect(self):
self.assertEqual(" FOR UPDATE", sqlmod.for_update_clause(MYSQL))
self.assertEqual("", sqlmod.for_update_clause(SQLITE))
def test_placeholder_style_per_dialect(self):
self.assertEqual("?", sqlmod.placeholder(SQLITE))
self.assertEqual("%s", sqlmod.placeholder(MYSQL))
self.assertEqual("%s, %s", sqlmod.render_placeholders(MYSQL, 2))
self.assertTrue(sqlmod.supports_transaction_control(SQLITE))
self.assertTrue(sqlmod.supports_transaction_control(MYSQL))
def test_ddl_available_for_both_dialects(self):
for dialect in (SQLITE, MYSQL):
stmts = sqlmod.ddl_for(dialect)
self.assertTrue(stmts)
self.assertTrue(any("pbl_runtime_event" in s.lower() or
"pbl_entity_state" in s.lower() for s in stmts))
def test_transaction_lifecycle_is_balanced(self):
"""BEGIN/COMMIT 配平、失败路径 ROLLBACK(用引擎事务轨迹,取代旧 sqlite3 trace 代理)。"""
eng = make_engine()
compat.install(eng)
self.addCleanup(compat.uninstall)
eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=1)
eng.apply_ = None
ok = compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload={"dx": 1},
state_updates=[su(state={"hp": 2})],
client_event_id="ev-bal-1", occurred_at=stamp())
self.assertTrue(ok["ok"])
with self.assertRaises(RtxError):
compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload={"dx": 2},
state_updates=[su(state={"hp": 3})],
client_event_id="ev-bal-2", base_version=99,
occurred_at=stamp())
k = compat.trace_kinds(eng)
self.assertEqual(k.count("BEGIN"), k.count("COMMIT") + k.count("ROLLBACK"))
self.assertEqual("ROLLBACK", k[-1])
self.assertEqual(1, eng.count(EVENT_TABLE)) # 只有成功那次留下事件
# =========================================================================
# 8. 确属 M11b-2a 契约变更而失效的旧用例(显式 xfail + 逐条理由,不 skip 不删)
# =========================================================================
class TestRetiredContractCases(unittest.TestCase):
"""旧类式夹具时代的用例,其前提在真源中已不成立。逐条 expectedFailure,
保留断言本体作为「旧契约」的可执行记录,理由写在各用例 docstring。"""
def setUp(self):
self.eng = make_engine()
compat.install(self.eng)
self.addCleanup(compat.uninstall)
@unittest.expectedFailure
def test_module_level_helper_uses_configured_writer(self):
"""失效理由:M11b-2a 删除了 ``configure_writer`` / 模块级
``write_event_with_state`` 配置器(真源入口唯一 = apply_runtime_event,
库名/事务由 rtx_db 环境解析,不存在「先配置 writer 再调用」的形态)。
等价覆盖见 TestNormalWrite::test_event_and_state_written_in_one_transaction。"""
compat.configure_writer(lambda: self.eng, dialect=SQLITE, close_connection=False)
res = compat.write_event_with_state({"event_uid": "ev-h"}, su())
self.assertEqual(4, res["state_version"])
@unittest.expectedFailure
def test_plan_assembly_is_side_effect_free(self):
"""失效理由:真源无 ``build_plan`` 计划对象(旧类式实现把「组装 plan」与
「执行 plan」分开,函数式真源在 apply_runtime_event 内一步到位)。
等价覆盖(无副作用)见 TestValidation 各用例的「校验失败不触库写」断言。"""
plan = compat.build_plan({"event_uid": "ev-plan"}, su(expected_version=11))
self.assertEqual(11, plan.expected_version)
self.assertEqual("ev-plan", plan.event_uid)
@unittest.expectedFailure
def test_before_commit_hook_failure_rolls_back_everything(self):
"""失效理由:真源无 ``before_commit`` 钩子参数(apply_runtime_event 签名
实读无该形参),旧注入手段随类式 API 一并退役。
等价覆盖:原子回滚改由真源错误码路径证明,见
TestAtomicRollback::test_optimistic_lock_conflict_rolls_back_everything。"""
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":9}', state_version=5)
writer = compat.RuntimeStateEventWriter(
lambda: self.eng, dialect=SQLITE, close_connection=False,
before_commit=lambda plan: (_ for _ in ()).throw(RuntimeError("boom")))
writer.write_event_with_state({"event_uid": "ev-hook"}, su())
@unittest.expectedFailure
def test_duplicate_event_raises_event_write_error(self):
"""失效理由:幂等语义由「抛 EventWriteError(DUPLICATE_EVENT)」改为
**返回 dedup=True 且不重复写、不重复广播**(真源幂等预检命中即 commit 空事务)。
等价覆盖:TestIdempotency::test_duplicate_event_uid_dedups_without_second_write。"""
first = compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload={"dx": 1},
state_updates=[su()], client_event_id="ev-dup-old",
occurred_at=stamp())
self.assertTrue(first["ok"])
with self.assertRaises(errs.EventWriteError):
compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload={"dx": 1},
state_updates=[su(state={"hp": 9})],
client_event_id="ev-dup-old", occurred_at=stamp())
@unittest.expectedFailure
def test_event_row_state_version_aligned_with_written_state(self):
"""失效理由:pbl_runtime_event 为 append-only,真源 4b 明确「同事务内 INSERT
之后不再 UPDATE 回填 state_version」(旧类式实现会回填,事件行版本=状态版本)。
现行语义:事件行 state_version 保持写入时 0,最新状态版本只经返回值 states[] 下发。
等价覆盖:TestNormalWrite::test_event_row_is_append_only_without_version_backfill。"""
res = compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload={"dx": 1},
state_updates=[su(state={"hp": 5})],
client_event_id="ev-align", occurred_at=stamp())
row = self.eng.query("SELECT state_version FROM %s WHERE event_uid='ev-align'"
% EVENT_TABLE)[0]
self.assertEqual(res["states"][0]["state_version"], row["state_version"])
@unittest.expectedFailure
def test_key_mismatch_between_event_and_state_rejected(self):
"""失效理由:真源事件行不再携带 entity_id 语义(entity_id 列按 DEFAULT '' 不填),
「事件 entity_id 与 state_updates.entity_id 必须一致」的交叉校验随之消失,
改为 state_updates 逐项自洽校验(缺 entity_id → PBL-PARAM-0001)。
等价覆盖:TestValidation::test_state_update_item_requires_entity_id。"""
with self.assertRaises(RtxError) as cm:
compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload={"dx": 1},
state_updates=[su(entity="other")],
client_event_id="ev-mis", occurred_at=stamp())
self.assertEqual(ERR_BAD_PARAM, cm.exception.code)
@unittest.expectedFailure
def test_unserialisable_payload_rejected(self):
"""失效理由:真源 _dumps 对不可序列化对象降级为 str() 落库(fail-soft 记录,
不拒绝整次写入),旧 InvalidWriteRequest 校验点不存在。
等价覆盖:TestNormalWrite::test_payload_serialised_into_payload_json_column。"""
with self.assertRaises(RtxError):
compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload=object(),
state_updates=[su()], client_event_id="ev-obj",
occurred_at=stamp())
@unittest.expectedFailure
def test_failure_raises_world_sync_exception_family(self):
"""失效理由:真源抛 rtx_db.RtxError(code 驱动),不再抛
world_sync/pbl_runtime_errors.py 的 RuntimeWriteError 族——该异常族仍在
宿主层使用,但已不在写入真源的抛出路径上。
等价覆盖:TestAtomicRollback::test_errors_are_never_swallowed。"""
self.eng.seed_state(TENANT, SESSION, ENTITY, '{"hp":1}', state_version=2)
with self.assertRaises(errs.RuntimeWriteError):
compat.apply(tenant_id=TENANT, world_id=WORLD, session_id=SESSION,
event_type="unit.move", payload={"dx": 1},
state_updates=[su(state={"hp": 3})],
client_event_id="ev-fam", base_version=1,
occurred_at=stamp())
if __name__ == "__main__":
unittest.main(verbosity=2)