world_sync/tests/test_m11b3_post_commit_broadcast.py
2026-09-21 13:25:24 +08:00

433 lines
16 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-3 事务提交后广播机制 —— 单元测试。
覆盖验收标准:
1. 事务 COMMIT 后广播消息在 ≤300ms 内发出(P99 基准);
2. 广播失败(通道不可用)不影响已提交数据,且有日志记录;
3. 广播不阻塞 write_event_with_state 的返回(异步);
4. 广播内容至少含 event_id / scene_id / entity_id / event_type / payload 摘要;
5. 事务未提交(抛异常回滚)时**不广播**;
6. 不引入轮询兜底(源码静态审计:无 sleep 轮询循环、无 DB 读取)。
测试用内存假事务(不依赖真实 DB / 网络),因此在无数据库的 CI 环境同样可跑。
"""
from __future__ import annotations
import json
import logging
import os
import re
import threading
import time
import pytest
import world_sync as ws
from world_sync.pbl_runtime_broadcast import (
BROADCAST_LATENCY_SLO_MS,
BaseChannel,
Broadcaster,
ChannelUnavailable,
InProcessChannel,
WebhookChannel,
build_broadcast_message,
emit_post_commit_broadcast,
install_broadcast_hook,
reset_broadcaster,
subscribe_broadcast,
unsubscribe_broadcast,
wrap_with_post_commit_broadcast,
)
# ---------------------------------------------------------------------------
# fixtures / helpers
# ---------------------------------------------------------------------------
class RecordingChannel(BaseChannel):
"""记录型通道:保存收到的消息,可配置为持续失败。"""
name = "recording"
def __init__(self, fail: bool = False, delay_s: float = 0.0) -> None:
self.messages = []
self.fail = fail
self.delay_s = delay_s
self.calls = 0
self._lock = threading.Lock()
def publish(self, message):
self.calls += 1
if self.delay_s:
time.sleep(self.delay_s)
if self.fail:
raise ChannelUnavailable("channel down (test)")
with self._lock:
self.messages.append(message)
class BlockingChannel(BaseChannel):
"""首次 publish 阻塞,直到事件被 set —— 用于证明广播不阻塞事务返回。"""
name = "blocking"
def __init__(self) -> None:
self.entered = threading.Event()
self.release = threading.Event()
self.messages = []
def publish(self, message):
self.entered.set()
self.release.wait(timeout=5.0)
self.messages.append(message)
def fake_write_result(**over):
"""模拟 M11b-2 write_event_with_state 提交成功后的返回值。"""
res = {
"event_uid": "evt-uid-0001",
"event_id": "evt-uid-0001",
"tenant_id": "tenant-A",
"world_id": "world-77",
"session_id": "sess-9",
"entity_id": "ent-42",
"state_version": 4,
"action": "updated",
"created_at": "2026-09-21 10:00:00",
"dialect": "sqlite",
"elapsed_ms": 12.3,
}
res.update(over)
return res
def fake_event_data(**over):
ev = {
"tenant_id": "tenant-A",
"world_id": "world-77",
"scene_id": "scene-77",
"session_id": "sess-9",
"entity_id": "ent-42",
"event_type": "state.updated",
"payload": {"hp": 100, "pos": [1, 2, 3]},
}
ev.update(over)
return ev
@pytest.fixture
def broadcaster():
chan = RecordingChannel()
b = Broadcaster(channels=[chan], retry_attempts=1, retry_backoff_s=0.001)
yield b, chan
b.shutdown(timeout=1.0)
@pytest.fixture(autouse=True)
def _clean_global():
yield
reset_broadcaster(None, shutdown_old=True)
def wait_until(predicate, timeout=3.0, interval=0.005):
"""等待条件成立(仅测试同步用,非生产轮询)。"""
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(interval)
return predicate()
# ---------------------------------------------------------------------------
# 1. 广播消息内容
# ---------------------------------------------------------------------------
def test_build_message_contains_required_fields():
msg = build_broadcast_message(
fake_write_result(),
commit_ts=time.time(),
context={"event_data": fake_event_data(), "state_update": {"state": {"hp": 100}}},
)
assert msg["event_id"] == "evt-uid-0001"
assert msg["scene_id"] == "scene-77" # 显式 scene_id
assert msg["entity_id"] == "ent-42"
assert msg["event_type"] == "state.updated"
assert msg["payload_summary"] # 简要 payload 摘要
assert "hp" in msg["payload_summary"]
assert len(msg["payload_summary"]) <= 256
assert msg["state_version"] == 4
assert msg["tenant_id"] == "tenant-A"
assert json.dumps(msg, ensure_ascii=False) # 可序列化(webhook/redis 通道要求)
def test_scene_id_falls_back_to_world_id():
ev = fake_event_data()
del ev["scene_id"]
msg = build_broadcast_message(fake_write_result(), context={"event_data": ev})
assert msg["scene_id"] == "world-77"
def test_payload_summary_is_truncated():
big = {"blob": "x" * 5000}
msg = build_broadcast_message(fake_write_result(), context={"event_data": {"payload": big}})
assert len(msg["payload_summary"]) <= 256
assert msg["payload_summary"].endswith("...")
def test_build_message_never_raises_on_garbage():
for junk in (None, 123, "str", object(), [1, 2]):
msg = build_broadcast_message(junk)
assert "event_id" in msg and "payload_summary" in msg
# ---------------------------------------------------------------------------
# 2. COMMIT 后广播被触发(post-commit hook)
# ---------------------------------------------------------------------------
def test_hook_fires_only_after_commit(broadcaster):
b, chan = broadcaster
calls = {"n": 0}
def write_fn(event_data=None, state_update=None):
calls["n"] += 1
return fake_write_result()
wrapped = wrap_with_post_commit_broadcast(write_fn, broadcaster=b)
res = wrapped(event_data=fake_event_data(), state_update={"state": {}})
assert res["event_id"] == "evt-uid-0001" # 原返回值透传
assert calls["n"] == 1
assert wait_until(lambda: len(chan.messages) == 1)
msg = chan.messages[0]
assert msg["event_id"] == "evt-uid-0001"
assert msg["entity_id"] == "ent-42"
assert msg["commit_ts"] <= msg["submit_ts"]
def test_no_broadcast_when_transaction_raises(broadcaster):
"""事务回滚(抛异常)→ 绝不广播,异常原样上抛。"""
b, chan = broadcaster
def write_fn(*a, **kw):
raise RuntimeError("deadlock, transaction rolled back")
wrapped = wrap_with_post_commit_broadcast(write_fn, broadcaster=b)
with pytest.raises(RuntimeError):
wrapped(event_data=fake_event_data())
time.sleep(0.15)
assert chan.messages == []
assert chan.calls == 0
def test_install_hook_wraps_module_symbol_and_is_idempotent():
import world_sync.pbl_runtime_tx_sqlor as txs
original = txs.write_event_with_state
underlying = getattr(original, "_m11b3_underlying", original)
try:
wrapped1 = install_broadcast_hook()
wrapped2 = install_broadcast_hook() # 幂等,不重复包装
assert getattr(wrapped1, "_m11b3_broadcast_wrapped", False)
assert txs.write_event_with_state is wrapped1
assert wrapped1 is wrapped2
assert getattr(wrapped1, "_m11b3_underlying") is underlying
assert ws.is_broadcast_hook_installed() is True
finally:
txs.write_event_with_state = underlying
reset_broadcaster(None, shutdown_old=True)
# ---------------------------------------------------------------------------
# 3. 广播失败不回滚已提交数据 + 有日志
# ---------------------------------------------------------------------------
def test_broadcast_failure_does_not_affect_committed_data(broadcaster, caplog):
chan = RecordingChannel(fail=True)
b = Broadcaster(channels=[chan], retry_attempts=2, retry_backoff_s=0.001)
try:
def write_fn(*a, **kw):
return fake_write_result() # 事务已成功提交
wrapped = wrap_with_post_commit_broadcast(write_fn, broadcaster=b)
with caplog.at_level(logging.ERROR, logger="world_sync.broadcast"):
res = wrapped(event_data=fake_event_data())
# 调用方拿到正常返回值,无异常 → 已提交数据不受影响
assert res["event_id"] == "evt-uid-0001"
assert wait_until(lambda: b.stats()["failed"] >= 1)
assert b.stats()["published"] == 0
assert b.stats()["last_error"]
text = "\n".join(r.getMessage() for r in caplog.records)
assert "广播失败" in text and "事务已提交" in text
finally:
b.shutdown(timeout=1.0)
def test_failing_channel_does_not_propagate_to_caller():
"""emit_post_commit_broadcast 自身永不抛异常(即使消息构造/通道全炸)。"""
chan = RecordingChannel(fail=True)
b = Broadcaster(channels=[chan], retry_attempts=0)
try:
msg = emit_post_commit_broadcast(fake_write_result(), broadcaster=b)
assert msg is not None and msg["event_id"]
assert wait_until(lambda: b.stats()["failed"] >= 1)
finally:
b.shutdown(timeout=1.0)
def test_partial_channel_failure_keeps_good_channel():
good = RecordingChannel()
bad = RecordingChannel(fail=True)
b = Broadcaster(channels=[bad, good], retry_attempts=1, retry_backoff_s=0.001)
try:
emit_post_commit_broadcast(fake_write_result(), broadcaster=b)
assert wait_until(lambda: len(good.messages) == 1)
assert b.stats()["published"] == 1
finally:
b.shutdown(timeout=1.0)
def test_subscriber_exception_is_swallowed():
chan = InProcessChannel()
def boom(message):
raise ValueError("subscriber bug")
chan.subscribe(boom)
b = Broadcaster(channels=[chan], retry_attempts=0)
try:
emit_post_commit_broadcast(fake_write_result(), broadcaster=b)
assert wait_until(lambda: b.stats()["published"] >= 1)
assert b.stats()["failed"] == 0
finally:
b.shutdown(timeout=1.0)
# ---------------------------------------------------------------------------
# 4. 异步:不阻塞事务返回
# ---------------------------------------------------------------------------
def test_broadcast_does_not_block_write_return():
slow = BlockingChannel()
b = Broadcaster(channels=[slow], retry_attempts=0)
try:
def write_fn(*a, **kw):
return fake_write_result()
wrapped = wrap_with_post_commit_broadcast(write_fn, broadcaster=b)
t0 = time.perf_counter()
res = wrapped(event_data=fake_event_data()) # 不应被 slow 通道卡住
elapsed_ms = (time.perf_counter() - t0) * 1000.0
assert res["event_id"]
assert elapsed_ms < 100.0, "广播阻塞了事务返回: %.1fms" % elapsed_ms
# 稍后 worker 线程仍会发出
slow.release.set()
assert wait_until(lambda: len(slow.messages) == 1)
finally:
slow.release.set()
b.shutdown(timeout=1.5)
def test_submit_returns_immediately_for_many_messages():
chan = RecordingChannel()
b = Broadcaster(channels=[chan], queue_size=100, retry_attempts=0)
try:
t0 = time.perf_counter()
for i in range(100):
b.submit(build_broadcast_message(fake_write_result(event_id="e%d" % i)))
elapsed_ms = (time.perf_counter() - t0) * 1000.0
assert elapsed_ms < 100.0
assert b.drain(timeout=5.0)
assert len(chan.messages) == 100
finally:
b.shutdown(timeout=1.0)
def test_queue_full_falls_back_to_direct_publish_without_blocking():
"""队列满时不阻塞调用方(退化为同步直发),消息不丢。"""
chan = RecordingChannel()
b = Broadcaster(channels=[chan], queue_size=1, retry_attempts=0)
try:
for i in range(20):
b.submit(build_broadcast_message(fake_write_result(event_id="e%d" % i)))
assert b.drain(timeout=5.0)
assert len(chan.messages) == 20
assert b.stats()["published"] == 20
finally:
b.shutdown(timeout=1.0)
# ---------------------------------------------------------------------------
# 5. 延迟 SLO ≤ 300ms(P99)
# ---------------------------------------------------------------------------
def test_broadcast_latency_p99_within_300ms():
chan = RecordingChannel()
b = Broadcaster(channels=[chan], retry_attempts=2, retry_backoff_s=0.001)
n = 200
try:
for i in range(n):
emit_post_commit_broadcast(
fake_write_result(event_id="lat-%d" % i),
broadcaster=b,
context={"event_data": fake_event_data()},
)
assert b.drain(timeout=15.0), "广播未在超时内全部发出"
assert len(chan.messages) == n
p99 = b.latency_percentile_ms(99.0)
p50 = b.latency_percentile_ms(50.0)
stats = b.stats()
print("\n[M11b-3 延迟基准] n=%d p50=%.3fms p99=%.3fms max=%.3fms SLO=%.0fms"
% (n, p50, p99, stats["max_latency_ms"], BROADCAST_LATENCY_SLO_MS))
assert p99 <= BROADCAST_LATENCY_SLO_MS, "P99 超阈值: %.3fms" % p99
assert stats["failed"] == 0
finally:
b.shutdown(timeout=1.0)
def test_latency_slo_constant_matches_spec():
assert BROADCAST_LATENCY_SLO_MS == 300.0
assert ws.BROADCAST_LATENCY_SLO_MS == 300.0
# ---------------------------------------------------------------------------
# 6. 进程内订阅 API(宿主接线)
# ---------------------------------------------------------------------------
def test_subscribe_and_unsubscribe_global():
reset_broadcaster(Broadcaster(channels=[InProcessChannel()], retry_attempts=0))
got = []
handler = subscribe_broadcast(got.append)
emit_post_commit_broadcast(fake_write_result(event_id="sub-1"))
assert wait_until(lambda: len(got) == 1)
assert got[0]["event_id"] == "sub-1"
assert unsubscribe_broadcast(handler) is True
emit_post_commit_broadcast(fake_write_result(event_id="sub-2"))
time.sleep(0.15)
assert len(got) == 1 # 取消后不再收到
assert ws.broadcast_stats()["published"] >= 2
def test_configure_broadcast_replaces_channels():
chan = RecordingChannel()
b = ws.configure_broadcast(channels=[chan], retry_attempts=0)
emit_post_commit_broadcast(fake_write_result(event_id="cfg-1"))
assert wait_until(lambda: len(chan.messages) == 1)
assert b.stats()["channels"] == ["recording"]
# ---------------------------------------------------------------------------
# 7. 无轮询兜底(静态审计)
# ---------------------------------------------------------------------------
def test_no_polling_loop_in_broadcast_source():
src_path = os.path.join(os.path.dirname(ws.__file__), "pbl_runtime_broadcast.py")
with open(src_path, encoding="utf-8") as fh:
lines = fh.readlines()
code = "".join(l for l in lines if not l.lstrip().startswith("#"))
# 生产代码里不得出现 sleep 轮询(time.sleep 只允许出现在重试退避一处)
sleeps = [i for i, l in enumerate(code.splitlines(), 1) if "time.sleep(" in l]
assert len(sleeps) <= 1, "疑似轮询 sleep: %s" % sleeps
# 不得有 while True + sleep 的轮询形态
assert not re.search(r"while\s+(True|1)\s*:\s*(#.*)?\n\s*(try:\s*\n\s*)?time\.sleep", code), \
"存在 while True + sleep 轮询兜底"
# 不得读数据库(无 SQL 关键字 / 无 conn_factory / 无 sor 调用)
for token in ("SELECT ", "UPDATE ", "INSERT INTO", "conn_factory", "sqlorContext", "DBPools"):
assert token not in code, "广播层不应触碰 DB: %s" % token
def test_broadcast_channel_name_exported():
assert ws.BROADCAST_CHANNEL == "pbl.runtime.event"
assert ws.PAYLOAD_SUMMARY_MAX_CHARS == 256