218 lines
8.8 KiB
Python
218 lines
8.8 KiB
Python
# -*- 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())
|