450 lines
17 KiB
Python
450 lines
17 KiB
Python
# -*- 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())]
|