433 lines
16 KiB
Python
433 lines
16 KiB
Python
# -*- 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
|