world_sync/tests/test_m11b2_single_tx.py
2026-09-20 16:09:34 +08:00

659 lines
26 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 单元测试:单事务写入(事件 + 实体状态)原子性 / 并发 / 时延。
运行方式(无需 appPublic/sqlor/ahserver纯标准库 + sqlite3::
cd apps/scense/pkgs/world_sync && python3 -m pytest tests -q
或 python3 tests/test_m11b2_single_tx.py # 无 pytest 时自检
被测代码world_sync/pbl_runtime_tx.py事务与原子性
world_sync/pbl_runtime_sql.py列契约与 SQL 构造)、
world_sync/pbl_runtime_errors.py异常族
方言说明:生产为 MySQLREAD COMMITTED + SELECT ... FOR UPDATE测试用 sqlite
SQLITE 方言锁子句为空串BEGIN IMMEDIATE 提供写串行化),因此并发正确性在
测试里主要由「乐观锁版本护栏」证明——这与 MySQL 下的第二重保险完全一致。
"""
import importlib.util
import os
import sqlite3
import sys
import threading
import time
import types
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
PKG_DIR = os.path.join(os.path.dirname(HERE), "world_sync")
_PKG = "_ws_m11b2"
def _load(name):
"""以合成包方式加载被测模块(绕开 world_sync/__init__.py 的运行时依赖)。"""
if _PKG not in sys.modules:
pkg = types.ModuleType(_PKG)
pkg.__path__ = [PKG_DIR]
sys.modules[_PKG] = pkg
full = "%s.%s" % (_PKG, name)
if full in sys.modules:
return sys.modules[full]
spec = importlib.util.spec_from_file_location(full, os.path.join(PKG_DIR, name + ".py"))
mod = importlib.util.module_from_spec(spec)
sys.modules[full] = mod
spec.loader.exec_module(mod)
return mod
tx = _load("pbl_runtime_tx")
sqlmod = _load("pbl_runtime_sql")
errs = _load("pbl_runtime_errors")
RuntimeStateEventWriter = tx.RuntimeStateEventWriter
write_event_with_state = tx.write_event_with_state
percentile = tx.percentile
build_plan = tx.build_plan
SQLITE = sqlmod.SQLITE
MYSQL = sqlmod.MYSQL
RuntimeWriteError = errs.RuntimeWriteError
InvalidWriteRequest = errs.InvalidWriteRequest
EventWriteError = errs.EventWriteError
StateWriteError = errs.StateWriteError
ConcurrentStateConflict = errs.ConcurrentStateConflict
TransactionAborted = errs.TransactionAborted
DDL_EVENT = """
CREATE TABLE IF NOT EXISTS pbl_runtime_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id TEXT NOT NULL,
event_uid TEXT NOT NULL,
event_code TEXT,
idem_key TEXT,
world_id TEXT,
scene_id TEXT,
session_id TEXT,
entity_id TEXT NOT NULL,
actor_id TEXT,
event_type TEXT NOT NULL,
payload TEXT,
payload_json TEXT,
causation_id TEXT,
seq INTEGER,
seq_no INTEGER,
source TEXT,
state TEXT,
state_version INTEGER,
tx_group TEXT,
broadcast TEXT,
created_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT,
occurred_at TEXT,
UNIQUE (tenant_id, event_uid)
)
"""
DDL_STATE = """
CREATE TABLE IF NOT EXISTS pbl_entity_state (
id TEXT,
tenant_id TEXT NOT NULL,
session_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
state_json TEXT,
state_version INTEGER NOT NULL DEFAULT 0,
checksum TEXT,
updated_by TEXT,
created_at TEXT,
updated_at TEXT,
PRIMARY KEY (tenant_id, session_id, entity_id)
)
"""
def make_db(path=":memory:"):
conn = sqlite3.connect(path, timeout=15.0)
conn.isolation_level = None # 由写入器显式 BEGIN/COMMIT/ROLLBACK
conn.execute(DDL_EVENT)
conn.execute(DDL_STATE)
return conn
def seed_state(conn, tenant="t1", session="s1", entity="e1", state_json='{"hp":1}',
version=0, checksum=None, updated_by=None):
conn.execute("DELETE FROM pbl_entity_state WHERE tenant_id=? AND session_id=? AND entity_id=?",
(tenant, session, entity))
conn.execute(
"INSERT INTO pbl_entity_state (id,tenant_id,session_id,entity_id,state_json,"
"state_version,checksum,updated_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
("row-" + entity, tenant, session, entity, state_json, version, checksum,
updated_by, "2026-01-01 00:00:00", "2026-01-01 00:00:00"))
def event(uid="ev-1", tenant="t1", session="s1", entity="e1", **kw):
data = {"tenant_id": tenant, "event_uid": uid, "session_id": session,
"entity_id": entity, "event_type": "unit.move", "world_id": "w1",
"scene_id": "sc1", "actor_id": "u1", "payload": {"dx": 1},
"seq_no": 7, "seq": 7}
data.update(kw)
return data
def state_update(**kw):
data = {"session_id": "s1", "entity_id": "e1", "state_json": {"hp": 2},
"updated_by": "u1"}
data.update(kw)
return data
class SingleConnProxy(object):
"""包装连接,可对指定阶段的 SQL 注入失败(验证原子回滚)。"""
def __init__(self, conn, fail_when):
self._conn = conn
self._fail_when = fail_when
def cursor(self):
return _ProxyCursor(self._conn.cursor(), self._fail_when)
def commit(self):
self._conn.commit()
def rollback(self):
self._conn.rollback()
def close(self):
pass
class _ProxyCursor(object):
def __init__(self, cur, fail_when):
self._cur = cur
self._fail_when = fail_when
@property
def rowcount(self):
return self._cur.rowcount
def execute(self, sql, params=None):
if self._fail_when(sql):
raise sqlite3.IntegrityError("injected failure: %s" % " ".join(sql.split())[:60])
return self._cur.execute(sql, params or [])
def fetchone(self):
return self._cur.fetchone()
def close(self):
self._cur.close()
def is_event_insert(sql):
head = " ".join(sql.split()).upper()
return head.startswith("INSERT INTO PBL_RUNTIME_EVENT")
# --------------------------------------------------------------------------
# 1. 正常写入:单事务内事件 + 状态同时落库
# --------------------------------------------------------------------------
class TestNormalWrite(unittest.TestCase):
def setUp(self):
self.conn = make_db()
seed_state(self.conn, version=3, state_json='{"hp":10}')
self.writer = RuntimeStateEventWriter(lambda: self.conn, dialect=SQLITE, close_connection=False)
def tearDown(self):
self.conn.close()
def test_writes_both_rows_in_one_transaction(self):
res = self.writer.write_event_with_state(event("ev-ok"), state_update())
self.assertEqual("ev-ok", res["event_uid"])
self.assertEqual(4, res["state_version"]) # 3 -> 4
self.assertEqual(3, res["expected_version"])
self.assertFalse(res["state_inserted"])
# 事务内语句清单:幂等预检 + 状态锁读 + 状态更新 + 事件插入(无广播/轮询语句)
self.assertEqual(4, res["statement_count"])
joined = " ; ".join(res["statements"]).upper()
self.assertNotIn("NOTIFY", joined)
self.assertNotIn("SLEEP", joined)
rows = self.conn.execute("SELECT count(*) FROM pbl_runtime_event").fetchone()[0]
self.assertEqual(1, rows)
st = self.conn.execute(
"SELECT state_json, state_version, updated_by FROM pbl_entity_state").fetchone()
self.assertEqual('{"hp":2}', st[0])
self.assertEqual(4, st[1])
self.assertEqual("u1", st[2])
def test_event_row_is_append_only_and_carries_aligned_version(self):
self.writer.write_event_with_state(event("ev-a"), state_update())
row = self.conn.execute(
"SELECT event_uid, state_version, entity_id, session_id, seq_no, source, "
"created_at FROM pbl_runtime_event").fetchone()
self.assertEqual("ev-a", row[0])
self.assertEqual(4, row[1]) # 状态 3 -> 4事件版本对齐
self.assertEqual("e1", row[2])
self.assertEqual("s1", row[3])
self.assertEqual(7, row[4])
self.assertEqual("runtime", row[5])
self.assertTrue(row[6]) # 分区键非空
def test_state_row_inserted_when_absent(self):
self.conn.execute("DELETE FROM pbl_entity_state")
res = self.writer.write_event_with_state(event("ev-new"), state_update())
self.assertTrue(res["state_inserted"])
self.assertEqual(1, res["state_version"])
self.assertEqual(1, self.conn.execute(
"SELECT count(*) FROM pbl_entity_state").fetchone()[0])
def test_module_level_helper_uses_configured_writer(self):
tx.configure_writer(lambda: self.conn, dialect=SQLITE, close_connection=False)
res = write_event_with_state(event("ev-helper"), state_update())
self.assertEqual(4, res["state_version"])
self.assertEqual(1, self.conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
def test_payload_dict_serialised_to_json_text(self):
self.writer.write_event_with_state(event("ev-p", payload={"a": 1, "b": [1, 2]}),
state_update())
payload = self.conn.execute("SELECT payload FROM pbl_runtime_event").fetchone()[0]
self.assertIn('"a":1', payload)
# --------------------------------------------------------------------------
# 2. 原子性:任一步失败 → 全部回滚 + 明确异常
# --------------------------------------------------------------------------
class TestAtomicRollback(unittest.TestCase):
def setUp(self):
self.conn = make_db()
seed_state(self.conn, version=5, state_json='{"hp":99}')
self.snapshot = self.conn.execute(
"SELECT state_json, state_version FROM pbl_entity_state").fetchone()
def tearDown(self):
self.conn.close()
def _writer(self, **kw):
return RuntimeStateEventWriter(lambda: self.conn, dialect=SQLITE, close_connection=False, **kw)
def test_event_insert_failure_rolls_back_state_update(self):
"""事件插入失败(中途失败)⇒ 状态更新必须一并回滚。"""
proxy = SingleConnProxy(self.conn, fail_when=is_event_insert)
writer = RuntimeStateEventWriter(lambda: proxy, dialect=SQLITE, close_connection=False)
with self.assertRaises(RuntimeWriteError) as cm:
writer.write_event_with_state(event("ev-fail"), state_update())
self.assertIsInstance(cm.exception, EventWriteError)
self.assertEqual("ev-fail", cm.exception.detail.get("event_uid"))
# 状态未被改写(回滚生效)
now = self.conn.execute("SELECT state_json, state_version FROM pbl_entity_state").fetchone()
self.assertEqual(self.snapshot, now)
self.assertEqual(0, self.conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
def test_before_commit_hook_failure_rolls_back_everything(self):
def boom(plan):
raise RuntimeError("boom before commit")
writer = self._writer(before_commit=boom)
with self.assertRaises(TransactionAborted):
writer.write_event_with_state(event("ev-hook"), state_update())
now = self.conn.execute("SELECT state_json, state_version FROM pbl_entity_state").fetchone()
self.assertEqual(self.snapshot, now)
self.assertEqual(0, self.conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
def test_state_update_failure_rolls_back_and_is_explicit(self):
def fail_state(sql):
head = " ".join(sql.split()).upper()
return head.startswith("UPDATE PBL_ENTITY_STATE")
proxy = SingleConnProxy(self.conn, fail_when=fail_state)
writer = RuntimeStateEventWriter(lambda: proxy, dialect=SQLITE, close_connection=False)
with self.assertRaises(RuntimeWriteError) as cm:
writer.write_event_with_state(event("ev-sf"), state_update())
self.assertIsInstance(cm.exception, StateWriteError)
self.assertEqual(0, self.conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
self.assertEqual(self.snapshot, self.conn.execute(
"SELECT state_json, state_version FROM pbl_entity_state").fetchone())
def test_errors_are_never_swallowed(self):
"""任何失败都必须抛 RuntimeWriteError 子类,且 detail 可定位。"""
writer = self._writer(before_commit=lambda p: (_ for _ in ()).throw(ValueError("x")))
try:
writer.write_event_with_state(event("ev-e"), state_update())
except RuntimeWriteError as exc:
self.assertIn("reason", exc.detail)
self.assertIn("event_uid", exc.detail)
self.assertTrue(str(exc))
else:
self.fail("未抛出明确异常,属吞错误")
def test_duplicate_event_is_rejected_and_state_untouched(self):
writer = self._writer()
writer.write_event_with_state(event("ev-dup"), state_update())
with self.assertRaises(EventWriteError) as cm:
writer.write_event_with_state(event("ev-dup"), state_update())
self.assertEqual("DUPLICATE_EVENT", cm.exception.code)
self.assertEqual(1, self.conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
self.assertEqual(6, self.conn.execute(
"SELECT state_version FROM pbl_entity_state").fetchone()[0])
# --------------------------------------------------------------------------
# 3. 并发:不出现脏写 / 丢失更新
# --------------------------------------------------------------------------
class TestConcurrency(unittest.TestCase):
def setUp(self):
self.path = os.path.join(HERE, "_m11b2_concurrent.sqlite")
if os.path.exists(self.path):
os.remove(self.path)
self.conn = make_db(self.path)
seed_state(self.conn, version=0, state_json='{"hp":0}')
self.conn.close()
def tearDown(self):
if os.path.exists(self.path):
os.remove(self.path)
def _factory(self):
def f():
return make_db(self.path)
return f
def test_stale_expected_version_raises_conflict(self):
"""乐观锁护栏:过期版本写入必须失败且不留痕(防丢失更新)。"""
conn = make_db(self.path)
seed_state(conn, version=8, state_json='{"hp":8}')
writer = RuntimeStateEventWriter(lambda: conn, dialect=SQLITE, close_connection=False)
with self.assertRaises(ConcurrentStateConflict) as cm:
writer.write_event_with_state(event("ev-stale"),
state_update(expected_version=7))
self.assertEqual(7, cm.exception.detail.get("expected_version")) # 调用方声明的过期基线
self.assertEqual({"tenant_id": "t1", "session_id": "s1", "entity_id": "e1"},
cm.exception.detail.get("key"))
self.assertEqual(8, conn.execute(
"SELECT state_version FROM pbl_entity_state").fetchone()[0])
self.assertEqual(0, conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
conn.close()
def test_two_concurrent_writers_same_entity_no_lost_update(self):
"""两个并发事务写同一实体:全部成功、版本连续递增、事件数与成功数一致。"""
n_each = 6
errors = []
results = []
lock = threading.Lock()
def worker(tag):
conn = make_db(self.path)
writer = RuntimeStateEventWriter(lambda: conn, dialect=SQLITE, close_connection=False)
for i in range(n_each):
try:
res = writer.write_event_with_state(
event("ev-%s-%d" % (tag, i)), state_update())
with lock:
results.append(res)
except RuntimeWriteError as exc:
with lock:
errors.append(exc)
conn.close()
threads = [threading.Thread(target=worker, args=(t,)) for t in ("A", "B")]
for t in threads:
t.start()
for t in threads:
t.join()
conn = sqlite3.connect(self.path)
conn.isolation_level = None
ok = len(results)
self.assertEqual(0, len(errors), "并发写入不应有未恢复错误: %s" % errors)
self.assertEqual(2 * n_each, ok)
self.assertEqual(2 * n_each, conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
final = conn.execute("SELECT state_version FROM pbl_entity_state").fetchone()[0]
self.assertEqual(2 * n_each, final) # 无丢失更新
versions = sorted(r["state_version"] for r in results)
self.assertEqual(list(range(1, 2 * n_each + 1)), versions) # 版本连续且互不重复 ⇒ 无脏写
conn.close()
def test_concurrent_duplicate_event_only_one_wins(self):
"""同一 event_uid 并发提交:只落一条事件,另一次被幂等拒绝。"""
conn = make_db(self.path)
outcomes = []
lock = threading.Lock()
def worker():
c = make_db(self.path)
writer = RuntimeStateEventWriter(lambda: c, dialect=SQLITE)
try:
writer.write_event_with_state(event("ev-race"), state_update())
with lock:
outcomes.append("ok")
except RuntimeWriteError as exc:
with lock:
outcomes.append(type(exc).__name__)
c.close()
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
self.assertEqual(1, conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
self.assertEqual(1, outcomes.count("ok"))
conn.close()
# --------------------------------------------------------------------------
# 4. 入参校验fail-closed
# --------------------------------------------------------------------------
class TestValidation(unittest.TestCase):
def setUp(self):
self.conn = make_db()
self.writer = RuntimeStateEventWriter(lambda: self.conn, dialect=SQLITE, close_connection=False)
def tearDown(self):
self.conn.close()
def test_missing_required_event_fields(self):
with self.assertRaises(InvalidWriteRequest) as cm:
self.writer.write_event_with_state({"tenant_id": "t1"}, state_update())
self.assertIn("event_uid", cm.exception.detail["missing"])
self.assertEqual(0, self.conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
def test_missing_state_json_rejected(self):
with self.assertRaises(InvalidWriteRequest):
self.writer.write_event_with_state(event("ev-x"),
{"session_id": "s1", "entity_id": "e1"})
def test_key_mismatch_between_event_and_state_rejected(self):
with self.assertRaises(InvalidWriteRequest) as cm:
self.writer.write_event_with_state(event("ev-y"), state_update(entity_id="other"))
self.assertEqual("entity_id", cm.exception.detail["field"])
def test_unserialisable_payload_rejected(self):
with self.assertRaises(InvalidWriteRequest):
self.writer.write_event_with_state(event("ev-z", payload=object()), state_update())
# --------------------------------------------------------------------------
# 5. 时延单次事务不含广播P99 ≤ 200ms
# --------------------------------------------------------------------------
class TestLatency(unittest.TestCase):
def setUp(self):
self.path = os.path.join(HERE, "_m11b2_latency.sqlite")
if os.path.exists(self.path):
os.remove(self.path)
conn = make_db(self.path)
seed_state(conn, version=0)
conn.close()
def tearDown(self):
if os.path.exists(self.path):
os.remove(self.path)
def test_p99_under_200ms(self):
samples = []
conn = make_db(self.path)
writer = RuntimeStateEventWriter(lambda: conn, dialect=SQLITE, close_connection=False)
for i in range(100):
res = writer.write_event_with_state(event("ev-l-%d" % i), state_update())
samples.append(res["elapsed_ms"])
conn.close()
p50 = percentile(samples, 50)
p99 = percentile(samples, 99)
print("[M11b-2 latency] n=%d p50=%.3fms p99=%.3fms max=%.3fms"
% (len(samples), p50, p99, max(samples)))
self.assertLessEqual(p99, 200.0)
def test_percentile_helper(self):
self.assertEqual(None, percentile([], 99))
self.assertEqual(5, percentile([5], 99))
p99 = percentile(list(range(100)), 99)
self.assertGreaterEqual(p99, 98.0)
self.assertLessEqual(p99, 99.0)
# --------------------------------------------------------------------------
# 6. 范围约束:不引入广播 / 轮询SQL 构造契约
# --------------------------------------------------------------------------
class TestScopeAndSqlContract(unittest.TestCase):
def test_no_broadcast_or_polling_in_production_sources(self):
"""范围约束:本模块不得出现广播/轮询/睡眠的可执行调用AST 级检查,不看散文)。"""
import ast
banned_calls = {"sleep", "poll", "long_poll", "publish", "subscribe",
"broadcast", "notify", "websocket", "push", "fetch_events"}
banned_imports = {"asyncio", "socket", "threading", "queue"}
for name in ("pbl_runtime_tx.py", "pbl_runtime_sql.py", "pbl_runtime_errors.py"):
path = os.path.join(PKG_DIR, 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:
root = (mod or "").split(".")[0]
self.assertNotIn(root, banned_imports,
"%s 不应导入 %s" % (name, root))
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_event_sql_is_insert_only(self):
cols = list(sqlmod.EventSchema.columns)
stmt = sqlmod.build_insert_event(MYSQL, ["tenant_id", "event_uid", "created_at"])
self.assertTrue(stmt.startswith("INSERT INTO pbl_runtime_event"))
self.assertNotIn("UPDATE", stmt.upper())
self.assertIn("%s", stmt)
self.assertEqual(len(cols), len(set(cols)))
self.assertIn("created_at", cols) # 分区键在契约内
def test_mysql_select_for_update_present(self):
stmt = sqlmod.build_select_state_for_update(MYSQL)
self.assertTrue(stmt.endswith("FOR UPDATE"))
self.assertIn("pbl_entity_state", stmt)
self.assertNotIn("?", stmt)
def test_sqlite_lock_clause_degrades_gracefully(self):
stmt = sqlmod.build_select_state_for_update(SQLITE)
self.assertNotIn("FOR UPDATE", stmt)
def test_update_carries_version_guard_and_increment(self):
stmt, set_cols, count = sqlmod.build_update_state(
MYSQL, ["state_json", "updated_at"])
self.assertIn("state_version = state_version + 1", stmt)
self.assertIn("AND state_version = %s", stmt)
self.assertEqual(("state_json", "updated_at"), set_cols)
self.assertEqual(2 + 3 + 1, count)
def test_plan_assembly_is_side_effect_free(self):
conn = make_db()
plan = build_plan(event("ev-plan"), state_update(expected_version=11))
self.assertEqual(11, plan.expected_version)
self.assertEqual("ev-plan", plan.event_uid)
self.assertEqual({"tenant_id": "t1", "session_id": "s1", "entity_id": "e1"}, plan.key)
self.assertEqual("created_at", sqlmod.EventSchema.partition_key)
self.assertEqual(0, conn.execute(
"SELECT count(*) FROM pbl_runtime_event").fetchone()[0])
conn.close()
def test_transaction_lifecycle_is_balanced(self):
"""BEGIN / COMMIT 各一次;失败路径必须 ROLLBACK。"""
conn = make_db()
seed_state(conn, version=0)
calls = []
class Spy(sqlite3.Connection):
pass
class Traced(object):
def __init__(self, real):
self._real = real
def cursor(self):
cur = self._real.cursor()
outer = self
class C(object):
@property
def rowcount(self):
return cur.rowcount
def execute(self, sql, params=None):
head = " ".join(sql.split()).upper()
if head.startswith("BEGIN"):
calls.append("BEGIN")
elif head.startswith("COMMIT"):
calls.append("COMMIT")
elif head.startswith("ROLLBACK"):
calls.append("ROLLBACK")
if "INSERT INTO PBL_RUNTIME_EVENT" in head:
raise sqlite3.OperationalError("injected")
return cur.execute(sql, params or [])
def fetchone(self):
return cur.fetchone()
def close(self):
cur.close()
return C()
def commit(self):
self._real.commit()
def rollback(self):
calls.append("ROLLBACK")
self._real.rollback()
def close(self):
pass
traced = Traced(conn)
writer = RuntimeStateEventWriter(lambda: traced, dialect=SQLITE)
with self.assertRaises(EventWriteError):
writer.write_event_with_state(event("ev-trace"), state_update())
self.assertIn("BEGIN", calls)
self.assertIn("ROLLBACK", calls)
self.assertNotIn("COMMIT", calls)
conn.close()
if __name__ == "__main__":
unittest.main(verbosity=2)