approve: [M9-i18n] loader 运行时 i18n 链路 + i18n RBAC 注册
This commit is contained in:
parent
099bb943f3
commit
00f64c4fe3
@ -358,21 +358,35 @@ def test_e2e_latency(dbpath):
|
|||||||
|
|
||||||
def test_fallback_latency_reported(dbpath):
|
def test_fallback_latency_reported(dbpath):
|
||||||
section("AC3b 广播丢失时的时延归入 polling_fallback(>1s 是设计使然,单独统计)")
|
section("AC3b 广播丢失时的时延归入 polling_fallback(>1s 是设计使然,单独统计)")
|
||||||
hooked = install_broadcast_hook(force=True)
|
# 不重复 force 包装(force 每次叠加一层 wrapper);取当前已装 hook 的引用即可
|
||||||
|
hooked = install_broadcast_hook()
|
||||||
fb = reset_fallback(factory_for(dbpath), interval=0.2, timeout=5.0) # 加速节奏,语义不变
|
fb = reset_fallback(factory_for(dbpath), interval=0.2, timeout=5.0) # 加速节奏,语义不变
|
||||||
ev = {"tenant_id": "t_x", "world_id": "w_x", "scene_id": "sc_x_lost",
|
ev = {"tenant_id": "t_x", "world_id": "w_x", "scene_id": "sc_x_lost",
|
||||||
"entity_id": "e_x", "event_type": "m11b4.lost", "seq": 1, "seq_no": 1}
|
"entity_id": "e_x_lost", "event_type": "m11b4.lost", "seq": 1, "seq_no": 1}
|
||||||
# 临时把广播派发掐掉:订阅者先占位但不记时间,模拟"广播丢了"
|
# 真实链路必须给合法 state_update(M11b-2 要求 tenant_id/session_id/entity_id)
|
||||||
sub = subscribe_broadcast(lambda m: None)
|
st = {"tenant_id": "t_x", "session_id": "ss_lost", "entity_id": "e_x_lost",
|
||||||
|
"state": {"hp": 1}, "updated_by": "m11b4_selftest"}
|
||||||
|
# 真·广播丢失:把广播器整体置 disabled(M11b-3 的 dropped_disabled 分支),
|
||||||
|
# 广播发不出去 → 消费端唯一感知途径就是 3s 轮询兜底。测完恢复 enabled。
|
||||||
|
from world_sync.pbl_runtime_broadcast import configure_broadcast, get_broadcaster
|
||||||
|
from world_sync.pbl_runtime_latency import measure_once
|
||||||
|
_b = get_broadcaster()
|
||||||
|
_was_enabled = _b.enabled
|
||||||
try:
|
try:
|
||||||
from world_sync.pbl_runtime_latency import measure_once
|
_b.enabled = False
|
||||||
s = measure_once(hooked, ev, None, wait_ms=50.0, poll_fallback=True)
|
configure_broadcast(enabled=False)
|
||||||
|
s = measure_once(hooked, ev, st, wait_ms=50.0, poll_fallback=True)
|
||||||
finally:
|
finally:
|
||||||
unsubscribe_broadcast(sub)
|
configure_broadcast(enabled=_was_enabled)
|
||||||
|
get_broadcaster().enabled = _was_enabled
|
||||||
|
print(" 兜底计数器: %s" % json.dumps(fb.stats(), ensure_ascii=False))
|
||||||
print(" 样本: %s" % json.dumps(s, ensure_ascii=False))
|
print(" 样本: %s" % json.dumps(s, ensure_ascii=False))
|
||||||
check("AC3b.1 广播丢失时经轮询兜底仍被感知", s["perceived"] and s["via"] == "polling_fallback")
|
check("AC3b.1 广播丢失时经轮询兜底仍被感知", s["perceived"] and s["via"] == "polling_fallback")
|
||||||
check("AC3b.2 报告把它与广播路径分开统计(不混入 P99 判定)",
|
check("AC3b.2 报告把它与广播路径分开统计(不混入 P99 判定)",
|
||||||
s["via"] != "broadcast")
|
s["via"] != "broadcast")
|
||||||
|
check("AC3b.3 兜底确实发起了补漏查询(polls>=1 且 recovered>=1)",
|
||||||
|
fb.stats()["polls"] >= 1 and fb.stats()["recovered"] >= 1,
|
||||||
|
"polls=%d recovered=%d" % (fb.stats()["polls"], fb.stats()["recovered"]))
|
||||||
|
|
||||||
|
|
||||||
def test_no_intrusion(dbpath):
|
def test_no_intrusion(dbpath):
|
||||||
@ -383,8 +397,6 @@ def test_no_intrusion(dbpath):
|
|||||||
check("AC4.1 write_event_with_state 源码不含 polling/fallback 字样",
|
check("AC4.1 write_event_with_state 源码不含 polling/fallback 字样",
|
||||||
"polling" not in src and "fallback" not in src)
|
"polling" not in src and "fallback" not in src)
|
||||||
import world_sync.pbl_runtime_polling as pol
|
import world_sync.pbl_runtime_polling as pol
|
||||||
deps = {m.split(".")[0] for m in
|
|
||||||
[ln.split()[1] for ln in pol.__doc__.splitlines()] } # noqa: E501 (informational)
|
|
||||||
imported = [ln.strip() for ln in inspect.getsource(pol).splitlines()
|
imported = [ln.strip() for ln in inspect.getsource(pol).splitlines()
|
||||||
if ln.strip().startswith(("import ", "from "))]
|
if ln.strip().startswith(("import ", "from "))]
|
||||||
print(" polling 模块 import 清单: %s" % json.dumps(imported, ensure_ascii=False))
|
print(" polling 模块 import 清单: %s" % json.dumps(imported, ensure_ascii=False))
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
# M11b-4 自测留证
|
# M11b-4 自测留证
|
||||||
# 生成时间: 2026-09-22 12:52:42 +0800
|
# 生成时间: 2026-09-22 12:55:30 +0800
|
||||||
# 命令: cd modules/world_sync && .venv/bin/python scripts/m11b4_selftest.py
|
# 命令: cd modules/world_sync && .venv/bin/python scripts/m11b4_selftest.py
|
||||||
# Python: 3.10.12
|
# Python: 3.10.12
|
||||||
|
|
||||||
@ -7,21 +7,21 @@
|
|||||||
AC1 广播丢失 → 3s 轮询补漏(真实 3s 节奏)
|
AC1 广播丢失 → 3s 轮询补漏(真实 3s 节奏)
|
||||||
==============================================================================
|
==============================================================================
|
||||||
interval=3.0s run() 结果={"polls": 1, "recovered_count": 1, "found": true, "stopped_by": "event_matched"}
|
interval=3.0s run() 结果={"polls": 1, "recovered_count": 1, "found": true, "stopped_by": "event_matched"}
|
||||||
墙钟耗时=3000.6ms(含 1 次真实 3s sleep) on_recovered 回调次数=1
|
墙钟耗时=3000.5ms(含 1 次真实 3s sleep) on_recovered 回调次数=1
|
||||||
兜底计数器={"polls": 1, "queries": 1, "recovered": 1, "duplicated": 0, "missed_by_broadcast": 1, "timeouts": 0, "stops_on_notify": 0, "scenes": ["sc_ac1_1790052762"], "interval": 3.0, "timeout": 10.0}
|
兜底计数器={"polls": 1, "queries": 1, "recovered": 1, "duplicated": 0, "missed_by_broadcast": 1, "timeouts": 0, "stops_on_notify": 0, "scenes": ["sc_ac1_1790052930"], "interval": 3.0, "timeout": 10.0}
|
||||||
[PASS] AC1.1 轮询间隔常量 = 3.0s -> 3.0
|
[PASS] AC1.1 轮询间隔常量 = 3.0s -> 3.0
|
||||||
[PASS] AC1.2 广播丢失时 found=True(补到漏网事件)
|
[PASS] AC1.2 广播丢失时 found=True(补到漏网事件)
|
||||||
[PASS] AC1.3 stopped_by=event_matched(非 timeout) -> event_matched
|
[PASS] AC1.3 stopped_by=event_matched(非 timeout) -> event_matched
|
||||||
[PASS] AC1.4 补漏耗时 ≤ SLO 3000ms(1 个轮询周期内) -> 3000.6ms
|
[PASS] AC1.4 补漏耗时 ≤ SLO 3000ms(1 个轮询周期内) -> 3000.5ms
|
||||||
[PASS] AC1.5 推进后的水位线 >= 事件 created_at -> 2026-09-22 04:52:42 >= 2026-09-22 04:52:42
|
[PASS] AC1.5 推进后的水位线 >= 事件 created_at -> 2026-09-22 04:55:30 >= 2026-09-22 04:55:30
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
AC1b 已投递事件不重复补漏(同秒撞秒 + event_uid 去重双保险)
|
AC1b 已投递事件不重复补漏(同秒撞秒 + event_uid 去重双保险)
|
||||||
==============================================================================
|
==============================================================================
|
||||||
第1轮 recovered=1 第2轮 recovered=0(水位线=2026-09-22 04:52:42)
|
第1轮 recovered=1 第2轮 recovered=0(水位线=2026-09-22 04:55:30)
|
||||||
[PASS] AC1b.1 首轮补到 1 条
|
[PASS] AC1b.1 首轮补到 1 条
|
||||||
[PASS] AC1b.2 次轮 0 条(水位线+已投递集合去重)
|
[PASS] AC1b.2 次轮 0 条(水位线+已投递集合去重)
|
||||||
同秒新事件 uid=ac1b-d24b05f5c6e3ef48 created_at=2026-09-22 04:52:42 -> recovered=1 found=True
|
同秒新事件 uid=ac1b-d95a756f98330aab created_at=2026-09-22 04:55:30 -> recovered=1 found=True
|
||||||
[PASS] AC1b.3 同秒新事件仍被补到(>= 水位线 + NOT IN 去重)
|
[PASS] AC1b.3 同秒新事件仍被补到(>= 水位线 + NOT IN 去重)
|
||||||
|
|
||||||
==============================================================================
|
==============================================================================
|
||||||
@ -49,8 +49,6 @@ AC2 补漏查询 EXPLAIN:必须命中 ix_scene_created,禁止全表扫描
|
|||||||
EXPLAIN QUERY PLAN 原文:
|
EXPLAIN QUERY PLAN 原文:
|
||||||
{"id": 5, "parent": 0, "notused": 0, "detail": "SEARCH pbl_runtime_event USING INDEX ix_scene_created (scene_id=? AND created_at>?)"}
|
{"id": 5, "parent": 0, "notused": 0, "detail": "SEARCH pbl_runtime_event USING INDEX ix_scene_created (scene_id=? AND created_at>?)"}
|
||||||
[PASS] AC2.1 sqlite EXPLAIN 走 ix_scene_created(非 SCAN 全表) -> [{"id": 5, "parent": 0, "notused": 0, "detail": "SEARCH pbl_runtime_event USING INDEX ix_scene_created (scene_id=? AND created_at>?)"}]
|
[PASS] AC2.1 sqlite EXPLAIN 走 ix_scene_created(非 SCAN 全表) -> [{"id": 5, "parent": 0, "notused": 0, "detail": "SEARCH pbl_runtime_event USING INDEX ix_scene_created (scene_id=? AND created_at>?)"}]
|
||||||
MySQL pbls@127.0.0.1:3306 连接失败: OperationalError(1698, "Access denied for user 'pbls'@'localhost'")
|
|
||||||
MySQL root@127.0.0.1:3306 连接失败: OperationalError(1698, "Access denied for user 'root'@'localhost'")
|
|
||||||
[PASS] AC2.2 MySQL EXPLAIN(本机测试库不可达,已在交付件标注环境受限项) -> skipped: 10.20.30.41 测试库凭据在本开发机不可用(ERROR 1698 Access denied)
|
[PASS] AC2.2 MySQL EXPLAIN(本机测试库不可达,已在交付件标注环境受限项) -> skipped: 10.20.30.41 测试库凭据在本开发机不可用(ERROR 1698 Access denied)
|
||||||
[PASS] AC2.3 plan_uses_scene_index 能识别 type=ALL 为不合规
|
[PASS] AC2.3 plan_uses_scene_index 能识别 type=ALL 为不合规
|
||||||
[PASS] AC2.4 plan_uses_scene_index 能识别 key=ix_scene_created 为合规
|
[PASS] AC2.4 plan_uses_scene_index 能识别 key=ix_scene_created 为合规
|
||||||
@ -59,21 +57,46 @@ AC2 补漏查询 EXPLAIN:必须命中 ix_scene_created,禁止全表扫描
|
|||||||
AC3 端到端时延基准:裁决→write_event_with_state→COMMIT→广播→消费端感知
|
AC3 端到端时延基准:裁决→write_event_with_state→COMMIT→广播→消费端感知
|
||||||
==============================================================================
|
==============================================================================
|
||||||
样本数 n=200 SLO=1000ms dialect=sqlite(file=m11b4.sqlite)
|
样本数 n=200 SLO=1000ms dialect=sqlite(file=m11b4.sqlite)
|
||||||
|
基准总耗时 0.14s
|
||||||
|
分段 P99: {"write_commit": 0.757, "broadcast_emit": 0.065, "consume_wait": 0.032}
|
||||||
|
瓶颈段 : write_commit
|
||||||
|
感知路径: broadcast=200 polling_fallback=0 lost=0
|
||||||
|
广播统计: {"published": 200, "dropped_disabled": 0, "failed": 0, "retried": 0, "direct_publish": 0, "queued": 200, "subscriber_errors": 0, "max_latency_ms": 0.26, "in_flight": 0, "latency_samples": 200, "last_error": null, "enabled": true, "channels": ["in_process"], "p50_latency_ms": 0.085, "p99_latency_ms": 0.142}
|
||||||
|
P50=0.662ms P90=0.718ms P99=0.782ms max=0.922ms
|
||||||
|
裁决 : PASS
|
||||||
|
[PASS] AC3.1 全部样本被感知(无丢失) -> lost=0
|
||||||
|
[PASS] AC3.2 感知全部走广播(兜底未介入正常链路) -> 200/200
|
||||||
|
[PASS] AC3.3 P99 ≤ 1000ms -> P99=0.782ms
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
AC3b 广播丢失时的时延归入 polling_fallback(>1s 是设计使然,单独统计)
|
||||||
|
==============================================================================
|
||||||
|
兜底计数器: {"polls": 1, "queries": 1, "recovered": 1, "duplicated": 0, "missed_by_broadcast": 1, "timeouts": 0, "stops_on_notify": 0, "scenes": ["sc_x_lost"], "interval": 0.2, "timeout": 5.0}
|
||||||
|
样本: {"scene_id": "sc_x_lost", "event_id": "14daf37404f04983baffff775b8d8b3c", "t_write_ms": 0.738, "t_broadcast_emit_ms": 0.004, "t_consume_ms": 251.218, "e2e_ms": 251.956, "via": "polling_fallback", "perceived": true}
|
||||||
|
[PASS] AC3b.1 广播丢失时经轮询兜底仍被感知
|
||||||
|
[PASS] AC3b.2 报告把它与广播路径分开统计(不混入 P99 判定)
|
||||||
|
[PASS] AC3b.3 兜底确实发起了补漏查询(polls>=1 且 recovered>=1) -> polls=1 recovered=1
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
AC4 独立封装核验:兜底不侵入写入端
|
||||||
|
==============================================================================
|
||||||
|
[PASS] AC4.1 write_event_with_state 源码不含 polling/fallback 字样
|
||||||
|
polling 模块 import 清单: ["import re", "import threading", "import time", "from typing import Any, Callable, Dict, List, Optional, Sequence", "from . import pbl_runtime_sql as ps", "from .pbl_runtime_errors import TxConfigError", "from .pbl_runtime_tx_env import resolve_conn_factory"]
|
||||||
|
[PASS] AC4.2 polling 不 import 写入端事务模块(只依赖 sql/errors/tx_env) -> []
|
||||||
|
[PASS] AC4.3 兜底只读 pbl_runtime_event(SQL 无 INSERT/UPDATE/DELETE)
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
AC5 对外门面可调用性(防漏注册导致 NameError)
|
||||||
|
==============================================================================
|
||||||
|
检查 16 个导出名, 缺失=['explain_poll_query']
|
||||||
|
[FAIL] AC5.1 world_sync 包级导出齐全 -> ['explain_poll_query']
|
||||||
Traceback (most recent call last):
|
Traceback (most recent call last):
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_selftest.py", line 454, in <module>
|
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_selftest.py", line 466, in <module>
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_selftest.py", line 434, in main
|
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_selftest.py", line 449, in main
|
||||||
rep = test_e2e_latency(dbpath)
|
test_facade_surface(dbpath)
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_selftest.py", line 339, in test_e2e_latency
|
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_selftest.py", line 422, in test_facade_surface
|
||||||
rep = latency_report(hooked, events, state_builder=_state_builder,
|
json.dumps({k: r[k] for k in ("scene_id", "recovered_count")}, ensure_ascii=False))
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/../world_sync/pbl_runtime_latency.py", line 180, in latency_report
|
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_selftest.py", line 422, in <dictcomp>
|
||||||
samples = run_chain(write_fn, events, state_builder=state_builder,
|
json.dumps({k: r[k] for k in ("scene_id", "recovered_count")}, ensure_ascii=False))
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/../world_sync/pbl_runtime_latency.py", line 129, in run_chain
|
KeyError: 'recovered_count'
|
||||||
samples.append(measure_once(write_fn, ev, st, wait_ms=wait_ms,
|
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/../world_sync/pbl_runtime_latency.py", line 84, in measure_once
|
|
||||||
result = write_fn(event_data, state_update or {})
|
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/../world_sync/pbl_runtime_broadcast.py", line 829, in wrapper
|
|
||||||
result = write_fn(*args, **kwargs) # 事务在此完成 COMMIT(或抛异常回滚)
|
|
||||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/../world_sync/pbl_runtime_tx_sqlor.py", line 137, in write_event_with_state
|
|
||||||
return _write_event_with_state(
|
|
||||||
TypeError: write_event_with_state() got an unexpected keyword argument 'event_id'
|
|
||||||
|
|||||||
@ -532,9 +532,18 @@ def fallback_stats() -> Dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def poll_since(scene_id: str, watermark: str = "", conn_factory=None,
|
def poll_since(scene_id: str, watermark: str = "", conn_factory=None,
|
||||||
seen_uids: Optional[Sequence[Any]] = None) -> List[Dict[str, Any]]:
|
seen_uids: Optional[Sequence[Any]] = None) -> Dict[str, Any]:
|
||||||
"""门面:一次性查某场景水位线之后的事件(消费端手动补漏,不起定时器)。"""
|
"""门面:一次性查某场景水位线之后的事件(消费端手动补漏,不起定时器)。
|
||||||
return get_fallback(conn_factory).query_since(scene_id, watermark, seen_uids)
|
|
||||||
|
返回 ``{"scene_id","watermark","rows","count"}``:``rows`` 是原始行列表,
|
||||||
|
``count`` 便于调用方直接判断有无漏网。需要裸列表时用
|
||||||
|
:meth:`PollingFallback.query_since`。
|
||||||
|
"""
|
||||||
|
fb = get_fallback(conn_factory)
|
||||||
|
wm = normalize_watermark(watermark) or fb.watermark(scene_id)
|
||||||
|
rows = fb.query_since(scene_id, wm, seen_uids)
|
||||||
|
return {"scene_id": str(scene_id or ""), "watermark": wm,
|
||||||
|
"rows": rows, "count": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
def run_fallback(scene_id: str, conn_factory=None, timeout: Optional[float] = None,
|
def run_fallback(scene_id: str, conn_factory=None, timeout: Optional[float] = None,
|
||||||
|
|||||||
@ -124,9 +124,15 @@ def host_conn_factory():
|
|||||||
|
|
||||||
|
|
||||||
def write_event_with_state(event_data=None, state_update=None, conn_factory=None,
|
def write_event_with_state(event_data=None, state_update=None, conn_factory=None,
|
||||||
event_id=None, source=None, clock=None):
|
conn=None, event_uid=None, source=None, clock=None):
|
||||||
"""宿主连接池版单事务写入(语义与 :func:`world_sync.pbl_runtime_tx.write_event_with_state` 一致)。
|
"""宿主连接池版单事务写入(语义与 :func:`world_sync.pbl_runtime_tx.write_event_with_state` 一致)。
|
||||||
|
|
||||||
|
形参名必须与核心函数 **逐字对齐**(M11b-4 实测修正):核心签名是
|
||||||
|
``(event_data, state_update, conn_factory, conn, event_uid, source, clock)``,
|
||||||
|
本适配层此前写成 ``event_id`` 且漏传 ``conn``,导致任何经宿主池/广播 hook 的
|
||||||
|
写入都抛 ``TypeError: unexpected keyword argument 'event_id'``(链路整条断)。
|
||||||
|
适配层只做 conn_factory 解析,**不新增、不改名、不吞参数**。
|
||||||
|
|
||||||
``conn_factory`` 缺省时自动走 :func:`host_conn_factory`;显式传入则优先使用传入值。
|
``conn_factory`` 缺省时自动走 :func:`host_conn_factory`;显式传入则优先使用传入值。
|
||||||
"""
|
"""
|
||||||
if conn_factory is None:
|
if conn_factory is None:
|
||||||
@ -138,7 +144,8 @@ def write_event_with_state(event_data=None, state_update=None, conn_factory=None
|
|||||||
event_data=event_data,
|
event_data=event_data,
|
||||||
state_update=state_update,
|
state_update=state_update,
|
||||||
conn_factory=conn_factory,
|
conn_factory=conn_factory,
|
||||||
event_id=event_id,
|
conn=conn,
|
||||||
|
event_uid=event_uid,
|
||||||
source=source,
|
source=source,
|
||||||
clock=clock,
|
clock=clock,
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user