world_sync/tests/_m11b2_compat.py
2026-09-22 19:51:55 +08:00

450 lines
17 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-A 测试夹具层:**函数式 API 直连真源**的运行时事务引擎(仅测试使用)。
本文件的历史与本轮收敛(响应 QC #2 硬门禁)
==========================================================
M11b-2a「世界同步真源迁移」把写入实现迁到 **函数式 API**
``pbl_runtime_ext.tx_write.apply_runtime_event`` / ``read_states``
(见 ``modules/pbl_runtime_ext/pbl_runtime_ext/tx_write.py:344`` / ``:466``)。
本文件在 M11b-2c 时的形态是「类式垫片」:定义 ``RuntimeStateEventWriter`` 类,
持有 ``conn_factory`` / ``dialect`` / ``close_connection``,再把方法调用转发到
``world_sync/pbl_runtime_tx.write_event_with_state``。那是**第二条夹具路径**——
它把测试钉在「旧类式调用形态 + world_sync 本地同步实现」上,既没验证现行真源
``apply_runtime_event``,又与真源返回键(``action`` / ``states[]`` / ``stmt_count``
/ ``latency_ms``)不一致,垫片只能靠 ``_augment`` 补假键(``state_inserted`` 等)
维持旧断言,实测 22 failed(``Cannot operate on a closed database.`` /
``KeyError: 'expected_version'``)。
**本轮已删除该类别垫片**,本文件只保留一条夹具路径:
1. ``RuntimeEngine``:以 **真实 sqlite3** 为存储的事务引擎。表结构(列名/类型/
NOT NULL/DEFAULT/唯一索引)**从 ``pbl_runtime_ext/models/*.json`` 读取生成**
——不另立第二份 schema 定义,避免列名再次漂移(这正是 28 failed 的根因之一:
旧夹具里手写 DDL 用了 ``event_id`` / ``state`` 等真源不存在的列)。
2. ``install(engine)`` / ``uninstall()``:把 ``rtx_db.transaction`` /
``rtx_db.dbname`` / ``rtx_db.q_all`` 换成 sqlite 引擎实现,使
``tx_write.apply_runtime_event`` / ``read_states`` **走自身真实代码路径**
(幂等预检 → INSERT 事件 → UPSERT 状态 → 快照 → COMMIT → 提交后广播)。
与 ``modules/pbl_runtime_ext/scripts/m11b_selftest.py::install_fake`` 同一
思路,差别只在存储换成真 sqlite(可验证真实 ROLLBACK 与 DB 唯一索引兜底)。
3. ``apply(**kw)`` / ``read(**kw)``:函数式 API 的同步入口(``asyncio.run``)。
**不做任何返回键增补**——测试直接断言真源返回键。
4. ``percentile``:转发到 ``world_sync.pbl_runtime_sql.percentile``(真源唯一实现)。
边界(QC #3 允许路径):只改测试夹具层,**不改业务实现、不改 models/*.json 表定义、
不改任何 load_path.py RBAC 注册**。本文件不含任何生产能力:只有被 pytest 夹具调用时
才安装引擎补丁,``uninstall()`` 逐样还原。
"""
import asyncio
import json
import os
import sqlite3
import sys
import threading
import time
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") # modules/pbl_runtime_ext
for _p in (WS_ROOT, RTX_ROOT):
if _p not in sys.path:
sys.path.insert(0, _p)
# --- 真源导入(被测对象本体,不是副本)---------------------------------
from pbl_runtime_ext import broadcast as _bcast # noqa: E402
from pbl_runtime_ext import m11b_config as _cfg # noqa: E402
from pbl_runtime_ext import rtx_db as _rtx_db # noqa: E402
from pbl_runtime_ext import tx_write as _tx_write # noqa: E402
from world_sync import pbl_runtime_sql as ps # noqa: E402
# 旧夹具里以名字引用的常量:统一取真源,避免第二份字面量
SQLITE = ps.DIALECT_SQLITE
MYSQL = ps.DIALECT_MYSQL
percentile = ps.percentile # 真源唯一实现(world_sync.pbl_runtime_sql)
apply_runtime_event = _tx_write.apply_runtime_event # 现行函数式真源 API
read_states = _tx_write.read_states
build_row = _tx_write.build_row
assert_append_only = _tx_write.assert_append_only
RtxError = _rtx_db.RtxError
EVENT_TABLE = _cfg.EVENT_TABLE
STATE_TABLE = _cfg.STATE_TABLE
SNAPSHOT_TABLE = _cfg.SNAPSHOT_TABLE
PROTECTED_BASE_TABLES = _cfg.PROTECTED_BASE_TABLES
ERR_TENANT_MISSING = _cfg.ERR_TENANT_MISSING
ERR_BAD_PARAM = _cfg.ERR_BAD_PARAM
ERR_STATE_CONFLICT = _cfg.ERR_STATE_CONFLICT
ERR_APPEND_ONLY = _cfg.ERR_APPEND_ONLY
ERR_BROADCAST_AFTER_COMMIT = _cfg.ERR_BROADCAST_AFTER_COMMIT
# build_row 的三道门禁码(真源 tx_write.py 内以字面量抛出)
ERR_UNKNOWN_COLUMN = "PBL-RTX-0003"
ERR_MISSING_NOT_NULL = "PBL-RTX-0004"
ERR_IMMUTABLE_COLUMN = "PBL-RTX-0006"
_MODELS_DIR = os.path.join(RTX_ROOT, "models")
# ---------------------------------------------------------------- schema
def _sql_type(t):
"""抽象类型 → sqlite 存储类(sqlite 动态类型,映射只为可读性/亲和性)。"""
t = str(t or "").lower()
if t in ("long", "int", "bigint", "smallint", "short"):
return "INTEGER"
if t in ("float", "double", "ddouble", "decimal"):
return "REAL"
if t in ("text", "json"):
return "TEXT"
if t in ("date", "datetime", "time", "timestamp"):
return "TEXT"
return "TEXT"
def _not_null(fd):
if fd.get("null") is False:
return True
return str(fd.get("nullable") or "").strip().lower() in ("no", "false", "0")
def _default_clause(fd):
dv = fd.get("default")
if dv is None:
return ""
s = str(dv).strip()
if s.upper() in ("CURRENT_TIMESTAMP", "NULL", "NONE", ""):
return ""
if isinstance(dv, bool):
return " DEFAULT %d" % int(dv)
if isinstance(dv, (int, float)):
return " DEFAULT %s" % dv
return " DEFAULT '%s'" % s.replace("'", "''")
def model_fields(table):
"""表列定义(权威源 = pbl_runtime_ext/models/{table}.json)。"""
path = os.path.join(_MODELS_DIR, "%s.json" % table)
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def ddl_statements(table):
"""按 models/*.json 生成 sqlite DDL(列/NOT NULL/DEFAULT/唯一索引)。"""
spec = model_fields(table)
cols = []
for fd in spec.get("fields") or []:
name = fd.get("name")
if not name:
continue
if fd.get("auto_increment"):
cols.append('"%s" INTEGER PRIMARY KEY AUTOINCREMENT' % name)
continue
d = '"%s" %s' % (name, _sql_type(fd.get("type")))
if _not_null(fd):
d += " NOT NULL"
d += _default_clause(fd)
cols.append(d)
out = ['CREATE TABLE IF NOT EXISTS "%s" (\n %s\n)' % (table, ",\n ".join(cols))]
for idx in spec.get("indexes") or []:
fields = idx.get("idxfields") or []
if not fields:
continue
kind = "UNIQUE INDEX" if str(idx.get("idxtype")) == "unique" else "INDEX"
cols_sql = ", ".join('"%s"' % f for f in fields)
out.append('CREATE %s IF NOT EXISTS "%s" ON "%s" (%s)'
% (kind, idx.get("name") or ("%s_%d" % (table, len(out))), table, cols_sql))
return out
def _to_qmark(sql):
"""真源 SQL 用 sqlor 的 ``%s`` 占位;sqlite 用 ``?``。"""
return sql.replace("%s", "?")
# ---------------------------------------------------------------- 引擎
class RuntimeEngine(object):
"""真实 sqlite 事务引擎(测试专用),适配 ``rtx_db.Transaction`` 接口。
记录三条可断言的轨迹:
* ``trace`` 有序事务轨迹:``BEGIN`` / ``SQL`` / ``COMMIT`` / ``ROLLBACK`` /
``BROADCAST`` —— 用于「广播只在 commit 之后」「BEGIN/COMMIT 配平」
* ``sql_log`` 实际发出的 SQL(含参数),用于「SET 子句不含不可变列」「无广播/
轮询/睡眠语句」这类语句级断言
* ``broadcasts`` 提交后广播消息(hub.publish 录制)
"""
def __init__(self, path=":memory:", tables=(EVENT_TABLE, STATE_TABLE, SNAPSHOT_TABLE)):
self.path = path
self.tables = tuple(tables)
self.trace = []
self.sql_log = []
self.broadcasts = []
self.txn_depth = 0
self._lock = threading.RLock()
self._mem = None
if path == ":memory:":
self._mem = sqlite3.connect(":memory:", isolation_level=None,
check_same_thread=False)
self._mem.execute("PRAGMA busy_timeout=15000")
self._create(self._mem)
else:
c = sqlite3.connect(path, isolation_level=None)
c.execute("PRAGMA busy_timeout=15000")
self._create(c)
c.close()
# -- 建表 -------------------------------------------------------------
def _create(self, conn):
for t in self.tables:
for stmt in ddl_statements(t):
conn.execute(stmt)
conn.commit()
# -- 连接 -------------------------------------------------------------
def admin_conn(self):
"""给测试直接查库用的连接(文件库返回新连接,调用方负责 close)。"""
if self._mem is not None:
return self._mem, False
c = sqlite3.connect(self.path, isolation_level=None)
c.execute("PRAGMA busy_timeout=15000")
return c, True
def query(self, sql, params=()):
conn, own = self.admin_conn()
try:
cur = conn.execute(_to_qmark(sql), tuple(params or ()))
cols = [d[0] for d in cur.description] if cur.description else []
return [dict(zip(cols, r)) for r in cur.fetchall()]
finally:
if own:
conn.close()
def scalar(self, sql, params=(), default=None):
rows = self.query(sql, params)
if not rows:
return default
vals = list(rows[0].values())
return vals[0] if vals else default
def count(self, table):
return int(self.scalar("SELECT COUNT(*) AS c FROM \"%s\"" % table, (), 0) or 0)
def seed_state(self, tenant_id, session_id, entity_id, state_json,
state_version=0, checksum="", updated_by="seed"):
"""预置状态行(走真实 INSERT,不绕过列契约)。"""
now = time.strftime("%Y-%m-%d %H:%M:%S")
conn, own = self.admin_conn()
try:
conn.execute("DELETE FROM \"%s\" WHERE tenant_id=? AND session_id=? AND entity_id=?"
% STATE_TABLE, (str(tenant_id), str(session_id), str(entity_id)))
conn.execute(
"INSERT INTO \"%s\" (tenant_id, session_id, entity_id, state_json,"
" state_version, checksum, updated_by, created_at, updated_at)"
" VALUES (?,?,?,?,?,?,?,?,?)" % STATE_TABLE,
(str(tenant_id), str(session_id), str(entity_id), state_json,
int(state_version), checksum, updated_by, now, now))
conn.commit()
finally:
if own:
conn.close()
# -- 事务轨迹 ---------------------------------------------------------
def _note(self, kind, detail=None):
with self._lock:
self.trace.append((kind, detail))
# -- 只读查询(供 rtx_db.q_all 补丁)----------------------------------
async def q_all(self, sql, params=None, db=None):
return _rtx_db._norm_rows(self.query(sql, params))
class _Tx(object):
"""sqlite 事务句柄:与 ``rtx_db.Transaction`` 同接口(execute/insert/update/
select/commit/rollback/stmt_count),**stmt_count 只计 DML**(与真源一致:
真源 ``execute`` 不计数、``insert``/``update`` 计数)。
"""
def __init__(self, engine):
self.e = engine
self.conn = None
self.committed = False
self.rolled_back = False
self.stmt_count = 0
async def __aenter__(self):
self.conn = self.e._mem if self.e._mem is not None else sqlite3.connect(
self.e.path, isolation_level=None)
if self.e._mem is None:
self.conn.execute("PRAGMA busy_timeout=15000")
with self.e._lock:
self.conn.execute("BEGIN IMMEDIATE")
self.e.txn_depth += 1
self.e._note("BEGIN")
return self
async def __aexit__(self, exc_type, exc, tb):
if exc_type is not None or not self.committed:
await self.rollback()
self._close()
return False
def _close(self):
if self.e._mem is None and self.conn is not None:
try:
self.conn.close()
except Exception: # noqa: BLE001
pass
self.conn = None
def _run(self, sql, params):
self.e.sql_log.append((" ".join(str(sql).split()), tuple(params or ())))
cur = self.conn.cursor()
try:
cur.execute(_to_qmark(sql), tuple(params or ()))
cols = [d[0] for d in cur.description] if cur.description else []
rows = [dict(zip(cols, r)) for r in cur.fetchall()] if cols else []
return rows
finally:
try:
cur.close()
except Exception: # noqa: BLE001
pass
# -- DML / 查询 -------------------------------------------------------
async def execute(self, sql, params=None):
return self._run(sql, params)
async def insert(self, table, row):
row = dict(row)
if "tenant_id" in row and row.get("tenant_id") in (None, "", 0):
raise RtxError(ERR_TENANT_MISSING, "%s 写入缺少 tenant_id" % table)
cols = list(row.keys())
sql = 'INSERT INTO "%s" (%s) VALUES (%s)' % (
table, ", ".join('"%s"' % c for c in cols), ", ".join(["?"] * len(cols)))
self.e.sql_log.append((" ".join(sql.split()), tuple(row[c] for c in cols)))
cur = self.conn.cursor()
try:
cur.execute(sql, tuple(row[c] for c in cols))
rid = cur.lastrowid
finally:
try:
cur.close()
except Exception: # noqa: BLE001
pass
self.stmt_count += 1
return rid
async def update(self, table, values, where):
cols = list(dict(values).keys())
sql = 'UPDATE "%s" SET %s' % (table, ", ".join('"%s" = ?' % c for c in cols))
self.e.sql_log.append((" ".join(sql.split()), tuple(dict(values)[c] for c in cols)))
cur = self.conn.cursor()
try:
cur.execute(sql, tuple(dict(values)[c] for c in cols))
n = cur.rowcount
finally:
try:
cur.close()
except Exception: # noqa: BLE001
pass
self.stmt_count += 1
return n
async def select(self, table, where="", fields="*", order="", limit=200):
sql = 'SELECT %s FROM "%s"' % (fields or "*", table)
if where:
sql += " WHERE %s" % where
if order:
sql += " ORDER BY %s" % order
sql += " LIMIT %d" % int(limit or 200)
return self._run(sql, ())
# -- 事务控制 ---------------------------------------------------------
async def commit(self):
if self.committed or self.rolled_back:
return
self.conn.commit()
self.committed = True
with self.e._lock:
self.e.txn_depth -= 1
self.e._note("COMMIT")
async def rollback(self):
if self.committed or self.rolled_back:
return
try:
self.conn.rollback()
except Exception: # noqa: BLE001
pass
self.rolled_back = True
with self.e._lock:
self.e.txn_depth -= 1
self.e._note("ROLLBACK")
# ---------------------------------------------------------------- 安装 / 还原
_ORIG = {}
_INSTALLED = False
def install(engine):
"""把 ``rtx_db`` 的事务/查询入口换成 sqlite 引擎,并录制提交后广播。"""
global _INSTALLED
if _INSTALLED:
uninstall()
_ORIG["transaction"] = _rtx_db.transaction
_ORIG["dbname"] = _rtx_db.dbname
_ORIG["q_all"] = _rtx_db.q_all
_rtx_db.dbname = lambda env=None: "m11b2d_test_sqlite"
_rtx_db.transaction = lambda db=None: _Tx(engine)
_rtx_db.q_all = engine.q_all
_bcast.reset_hub()
hub = _bcast.get_hub()
original_publish = hub.publish
def _recording_publish(channel, event):
engine.broadcasts.append({"channel": channel, "event": event})
engine._note("BROADCAST", channel)
return original_publish(channel, event)
hub.publish = _recording_publish
_ORIG["hub"] = (hub, original_publish)
_INSTALLED = True
return engine
def uninstall():
global _INSTALLED
if not _INSTALLED:
return
for key in ("transaction", "dbname", "q_all"):
if key in _ORIG:
setattr(_rtx_db, key, _ORIG[key])
_INSTALLED = False
_ORIG.clear()
def apply(**kwargs):
"""函数式真源 API 的同步入口(不做返回键增补)。"""
return asyncio.run(apply_runtime_event(**kwargs))
def read(**kwargs):
return asyncio.run(read_states(**kwargs))
def trace_kinds(engine):
return [k for k, _d in engine.trace]
def update_statements(engine):
"""引擎实际发出的 UPDATE 语句(大写、压空格),用于不可变列/乐观锁断言。"""
return [(s, p) for s, p in engine.sql_log if s.upper().startswith("UPDATE ")]
def insert_statements(engine, table):
return [(s, p) for s, p in engine.sql_log
if s.upper().startswith('INSERT INTO "%s"' % table.upper())]