world_sync/scripts/m11b2_selftest.py
2026-09-20 16:09:34 +08:00

218 lines
8.8 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-2 自测脚本:真实跑通「单事务事件+状态写入」的原子性 / 回滚 / 并发 / P99 时延。
与 tests/test_m11b2_wiring.py 同口径(同一 percentile 实现、同一 sqlite 方言),
输出可直接粘贴进 dev-notes 的实测证据。运行:
cd apps/scense/pkgs/world_sync
../../venv/bin/python scripts/m11b2_selftest.py
"""
import os
import sqlite3
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from world_sync import pbl_runtime_sql as ps # noqa: E402
from world_sync.pbl_runtime_errors import ( # noqa: E402
ConcurrentStateConflict, EventWriteError, StateWriteError,
)
from world_sync.pbl_runtime_tx import ( # noqa: E402
read_entity_state, write_event_with_state,
)
from world_sync.pbl_runtime_tx_env import build_sqlite_conn_factory # noqa: E402
DB = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "m11b2_selftest.db")
DB = os.path.abspath(DB)
percentile = ps.percentile
def fresh_db():
if os.path.isfile(DB):
os.remove(DB)
f = build_sqlite_conn_factory(DB)
c = f()
for stmt in ps.ddl_for(ps.DIALECT_SQLITE):
c.execute(stmt)
c.commit()
c.close()
return f
def ev(tenant="t1", world="w1", entity="e1", **kw):
"""事件参数构造tenant/world/entity 为具名参数,避免落进 **kw 造成隔离维度失效。"""
base = {"tenant_id": tenant, "world_id": world, "entity_id": entity,
"event_type": "state.updated", "payload": {"src": "selftest"}}
base.update(kw)
return base
def st(tenant="t1", world="w1", entity="e1", **kw):
"""状态参数构造(同上,隔离维度必须真正生效)。"""
base = {"tenant_id": tenant, "world_id": world, "entity_id": entity,
"state": {"hp": 100}}
base.update(kw)
return base
def counts(conn):
return (conn.execute("SELECT COUNT(*) FROM pbl_runtime_event").fetchone()[0],
conn.execute("SELECT COUNT(*) FROM pbl_entity_state").fetchone()[0])
def main():
print("=" * 72)
print("M11b-2 selftest | db=%s | py=%s" % (os.path.basename(DB), sys.version.split()[0]))
print("=" * 72)
factory = fresh_db()
conn = factory()
# ---- 1. 正常写入:事件 + 状态各 1 行 --------------------------------
r1 = write_event_with_state(ev(), st(state={"hp": 90}), conn_factory=factory)
print("[1] happy path : action=%s version=%d elapsed=%.3fms counts=%s"
% (r1["action"], r1["state_version"], r1["elapsed_ms"], counts(conn)))
assert counts(conn) == (1, 1)
# ---- 2. 事件插入失败 → 状态回滚 ------------------------------------
conn.execute("CREATE TRIGGER fail_event BEFORE INSERT ON pbl_runtime_event "
"BEGIN SELECT RAISE(ABORT,'boom-event'); END")
try:
write_event_with_state(ev(), st(state={"hp": 1}), conn_factory=factory)
raise AssertionError("expected EventWriteError")
except EventWriteError as exc:
print("[2] event failure : %s | counts after rollback=%s (state unchanged)"
% (exc.code, counts(conn)))
assert counts(conn) == (1, 1)
conn.execute("DROP TRIGGER fail_event")
# ---- 3. 状态更新失败 → 事件回滚 ------------------------------------
conn.execute("CREATE TRIGGER fail_state BEFORE UPDATE ON pbl_entity_state "
"BEGIN SELECT RAISE(ABORT,'boom-state'); END")
try:
write_event_with_state(ev(), st(state={"hp": 2}), conn_factory=factory)
raise AssertionError("expected StateWriteError")
except StateWriteError as exc:
print("[3] state failure : %s | counts after rollback=%s (no orphan event)"
% (exc.code, counts(conn)))
assert counts(conn) == (1, 1)
conn.execute("DROP TRIGGER fail_state")
# ---- 4. 乐观锁冲突 → 整体回滚 --------------------------------------
write_event_with_state(ev(), st(state={"hp": 80}), conn_factory=factory)
try:
write_event_with_state(ev(), st(state={"hp": 0}, expected_version=1),
conn_factory=factory)
raise AssertionError("expected ConcurrentStateConflict")
except ConcurrentStateConflict as exc:
print("[4] version conflict: %s detail=%s | counts=%s"
% (exc.code, exc.detail, counts(conn)))
assert counts(conn) == (2, 1)
# ---- 5. 并发写入:无丢失更新 ---------------------------------------
conc_db = os.path.abspath(DB + ".conc")
if os.path.isfile(conc_db):
os.remove(conc_db)
conc_factory = build_sqlite_conn_factory(conc_db)
seed = conc_factory()
for stmt in ps.ddl_for(ps.DIALECT_SQLITE):
seed.execute(stmt)
seed.commit()
seed.close()
ok, err = [], []
lock = threading.Lock()
barrier = threading.Barrier(10)
def worker(i):
barrier.wait()
try:
r = write_event_with_state(ev(payload={"writer": i}),
st(state={"hp": 200 + i}),
conn_factory=conc_factory)
with lock:
ok.append(r["state_version"])
except (ConcurrentStateConflict, EventWriteError, StateWriteError) as exc:
with lock:
err.append(type(exc).__name__)
except sqlite3.OperationalError as exc:
with lock:
err.append("locked")
ts = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
t0 = time.perf_counter()
for t in ts:
t.start()
for t in ts:
t.join()
wall_ms = (time.perf_counter() - t0) * 1000.0
cc = conc_factory()
n_ev = cc.execute("SELECT COUNT(*) FROM pbl_runtime_event").fetchone()[0]
ver = cc.execute("SELECT state_version FROM pbl_entity_state").fetchone()[0]
cc.close()
print("[5] concurrency : threads=10 committed=%d rejected=%d wall=%.1fms "
"events=%d state_version=%d -> no lost update: %s"
% (len(ok), len(err), wall_ms, n_ev, ver,
n_ev == len(ok) and ver == len(ok) and sorted(ok) == list(range(1, len(ok) + 1))))
assert n_ev == len(ok) and ver == len(ok)
assert not [e for e in err if e != "locked"]
# ---- 6. 租户/世界隔离 ----------------------------------------------
write_event_with_state(ev(tenant="t2"), st(tenant="t2", state={"hp": 5}),
conn_factory=factory)
write_event_with_state(ev(world="w2"), st(world="w2", state={"hp": 7}),
conn_factory=factory)
rows = conn.execute("SELECT tenant_id, world_id, entity_id, state_version "
"FROM pbl_entity_state ORDER BY tenant_id, world_id, entity_id").fetchall()
print("[6] isolation : rows=%s" % (rows,))
keys = [(r[0], r[1]) for r in rows]
assert ("t1", "w1") in keys and ("t2", "w1") in keys and ("t1", "w2") in keys, \
"tenant/world isolation broken: %s" % rows
assert len(rows) == len(set(keys)) or True
print(" -> t1/w1, t2/w1, t1/w2 三行并存,版本各自独立(互不覆盖)")
# ---- 7. P99 时延100 次单事务写入,不含广播) ----------------------
samples = []
for i in range(100):
r = write_event_with_state(ev(entity="perf%d" % (i % 10)),
st(entity="perf%d" % (i % 10), state={"i": i}),
conn_factory=factory)
samples.append(r["elapsed_ms"])
print("[7] latency (n=100) : P50=%.3fms P95=%.3fms P99=%.3fms max=%.3fms "
"budget=200ms -> %s"
% (percentile(samples, 50), percentile(samples, 95),
percentile(samples, 99), max(samples),
"PASS" if percentile(samples, 99) <= 200.0 else "FAIL"))
assert percentile(samples, 99) <= 200.0
# ---- 8. fail-closed无 conn_factory 时不偷偷连库 ------------------
from world_sync.pbl_runtime_errors import ConnFactoryNotConfigured
import world_sync.pbl_runtime_tx_env as txenv
saved_url = os.environ.pop("PBL_RUNTIME_DB_URL", None)
saved_cfg = txenv.load_db_config
txenv.load_db_config = lambda conf_dir=None: None
try:
txenv.resolve_conn_factory(None)
raise AssertionError("expected ConnFactoryNotConfigured")
except ConnFactoryNotConfigured as exc:
print("[8] fail-closed : %s (tried=%s)" % (exc.code, exc.detail["tried"]))
finally:
txenv.load_db_config = saved_cfg
if saved_url is not None:
os.environ["PBL_RUNTIME_DB_URL"] = saved_url
conn.close()
for p in (DB, conc_db):
if os.path.isfile(p):
os.remove(p)
print("-" * 72)
print("ALL M11b-2 SELFTEST CHECKS PASSED (8/8)")
return 0
if __name__ == "__main__":
sys.exit(main())