fix(m11b4-4a): SLO_TOLERANCE_MS 提为模块源码常量 + poll_since 返回键口径统一为 count + 重跑留证

- QC #17: SLO_TOLERANCE_MS=500.0 由 scripts/m11b4_selftest.py 本地定义上提为
  world_sync/pbl_runtime_polling.py 模块常量(与 FALLBACK_SLO_MS 同段),
  selftest 改为 import 引用;按三处同步补 __init__.py 导入+__all__ 与
  init.py env.SLO_TOLERANCE_MS / env.SEEN_IDS_IN_SQL 登记
- QC #15/#16: selftest AC5.2 断言改为核验 poll_since 真实返回键 rows/count
  (recovered_count 属 poll_once/run 口径),并显式断言不含 recovered_count
- 重跑全量 selftest(真实 time.sleep(3.0),无打桩):RESULT: PASS FAILURES=0,31/31;
  AC5.1 缺失名 -> [];AC1.4 -> 3000.5ms <= 3500.0ms
- 重新落盘 scripts/m11b4_selftest_output.txt 与 scripts/m11b4_latency_report.json
  (run_at=2026-09-22 13:56:47 +0800,samples=200,含 segment_p99_ms/broadcast_stats/verdict)
- skill/SKILL.md 补两条陷阱:SLO_TOLERANCE_MS 属模块源码常量禁止测试侧重定义;
  poll_since 返回键为 count

不改 M11b-1/2/3 核心逻辑(write_event_with_state / emit_post_commit_broadcast / 表结构索引)
This commit is contained in:
agent.develop 2026-09-22 13:58:28 +08:00
parent d56f516966
commit aed40d3491
7 changed files with 1880 additions and 1862 deletions

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@ from world_sync.pbl_runtime_latency import (
from world_sync.pbl_runtime_polling import ( # noqa: E402 from world_sync.pbl_runtime_polling import ( # noqa: E402
FALLBACK_SLO_MS, FALLBACK_SLO_MS,
POLL_INTERVAL_S, POLL_INTERVAL_S,
SLO_TOLERANCE_MS,
PollingFallback, PollingFallback,
build_poll_sql, build_poll_sql,
explain_poll_query, explain_poll_query,
@ -44,9 +45,8 @@ from world_sync.pbl_runtime_polling import (
reset_fallback, reset_fallback,
) )
#: AC1.4 判定容差(毫秒):真实 3s sleep 的唤醒抖动 + 单次周期边界, # 注:AC1.4 判定容差 SLO_TOLERANCE_MS 不再在测试侧本地定义,
#: 断言口径为 wall_ms <= FALLBACK_SLO_MS + SLO_TOLERANCE_MS(QC #13 要求显式披露) # 统一从 world_sync.pbl_runtime_polling 源码常量 import(QC #17 源码-文档一致性)。
SLO_TOLERANCE_MS = 500.0
RESULTS = [] RESULTS = []
@ -424,7 +424,11 @@ def test_facade_surface(dbpath):
print(" 检查 %d 个导出名, 缺失=%s" % (len(names), missing)) print(" 检查 %d 个导出名, 缺失=%s" % (len(names), missing))
check("AC5.1 world_sync 包级导出齐全", not missing, str(missing)) check("AC5.1 world_sync 包级导出齐全", not missing, str(missing))
r = poll_since("sc_facade", "1970-01-01 00:00:00", conn_factory=factory_for(dbpath)) r = poll_since("sc_facade", "1970-01-01 00:00:00", conn_factory=factory_for(dbpath))
check("AC5.2 poll_since 门面可直调", isinstance(r, dict) and "rows" in r, # poll_since 门面的真实返回键是 count(不是 recovered_count,后者属
# PollingFallback.poll_once/run 的返回)——口径与源码/文档统一(QC #15)。
check("AC5.2 poll_since 门面可直调,返回键为 rows/count",
isinstance(r, dict) and "rows" in r and "count" in r
and "recovered_count" not in r and r["count"] == len(r["rows"]),
json.dumps({k: r[k] for k in ("scene_id", "count")}, ensure_ascii=False)) json.dumps({k: r[k] for k in ("scene_id", "count")}, ensure_ascii=False))
ex = explain_poll_query("sc_facade", "1970-01-01 00:00:00", ex = explain_poll_query("sc_facade", "1970-01-01 00:00:00",
conn_factory=factory_for(dbpath)) conn_factory=factory_for(dbpath))

View File

@ -1,5 +1,5 @@
# M11b-4 自测留证 # M11b-4 自测留证
# 生成时间: 2026-09-22 13:23:19 +0800 # 生成时间: 2026-09-22 13:56:43 +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
@ -8,20 +8,20 @@ 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.5ms(含 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_1790054599"], "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_1790056603"], "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 + 500ms 调度容差(1 个轮询周期内) -> 3000.5ms <= 3500.0ms [PASS] AC1.4 补漏耗时 ≤ SLO 3000ms + 500ms 调度容差(1 个轮询周期内) -> 3000.5ms <= 3500.0ms
[PASS] AC1.5 推进后的水位线 >= 事件 created_at -> 2026-09-22 05:23:19 >= 2026-09-22 05:23:19 [PASS] AC1.5 推进后的水位线 >= 事件 created_at -> 2026-09-22 05:56:43 >= 2026-09-22 05:56:43
============================================================================== ==============================================================================
AC1b 已投递事件不重复补漏(同秒撞秒 + event_uid 去重双保险) AC1b 已投递事件不重复补漏(同秒撞秒 + event_uid 去重双保险)
============================================================================== ==============================================================================
第1轮 recovered=1 第2轮 recovered=0(水位线=2026-09-22 05:23:19) 第1轮 recovered=1 第2轮 recovered=0(水位线=2026-09-22 05:56:43)
[PASS] AC1b.1 首轮补到 1 条 [PASS] AC1b.1 首轮补到 1 条
[PASS] AC1b.2 次轮 0 条(水位线+已投递集合去重) [PASS] AC1b.2 次轮 0 条(水位线+已投递集合去重)
同秒新事件 uid=ac1b-a6ba55a386728eb7 created_at=2026-09-22 05:23:19 -> recovered=1 found=True 同秒新事件 uid=ac1b-a50a12b3debf921e created_at=2026-09-22 05:56:43 -> recovered=1 found=True
[PASS] AC1b.3 同秒新事件仍被补到(>= 水位线 + NOT IN 去重) [PASS] AC1b.3 同秒新事件仍被补到(>= 水位线 + NOT IN 去重)
============================================================================== ==============================================================================
@ -36,9 +36,9 @@ AC1c 水位线格式守卫(ISO 格式必须被拒绝,否则静默丢事件
============================================================================== ==============================================================================
AC1d 广播正常 → 轮询立即停止(stopped_by=broadcast_received) AC1d 广播正常 → 轮询立即停止(stopped_by=broadcast_received)
============================================================================== ==============================================================================
run()={"polls": 1, "found": false, "stopped_by": "broadcast_received"} 墙钟=200.8ms(远小于 3s 周期) run()={"polls": 1, "found": false, "stopped_by": "broadcast_received"} 墙钟=200.9ms(远小于 3s 周期)
[PASS] AC1d.1 收到广播后停止 [PASS] AC1d.1 收到广播后停止
[PASS] AC1d.2 未走满轮询周期 -> 200.8ms [PASS] AC1d.2 未走满轮询周期 -> 200.9ms
[PASS] AC1d.3 notified 短路后 polls 计数=1 -> 1 [PASS] AC1d.3 notified 短路后 polls 计数=1 -> 1
============================================================================== ==============================================================================
@ -57,22 +57,22 @@ 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 基准总耗时 0.22s
分段 P99: {"write_commit": 0.826, "broadcast_emit": 0.053, "consume_wait": 0.025} 分段 P99: {"write_commit": 1.971, "broadcast_emit": 0.354, "consume_wait": 0.176}
瓶颈段 : write_commit 瓶颈段 : write_commit
感知路径: broadcast=200 polling_fallback=0 lost=0 感知路径: 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.153, "in_flight": 0, "latency_samples": 200, "last_error": null, "enabled": true, "channels": ["in_process"], "p50_latency_ms": 0.077, "p99_latency_ms": 0.127} 广播统计: {"published": 200, "dropped_disabled": 0, "failed": 0, "retried": 0, "direct_publish": 0, "queued": 200, "subscriber_errors": 0, "max_latency_ms": 0.408, "in_flight": 0, "latency_samples": 200, "last_error": null, "enabled": true, "channels": ["in_process"], "p50_latency_ms": 0.136, "p99_latency_ms": 0.292}
P50=0.650ms P90=0.699ms P99=0.843ms max=0.969ms P50=1.042ms P90=1.232ms P99=1.997ms max=3.124ms
裁决 : PASS 裁决 : PASS
[PASS] AC3.1 全部样本被感知(无丢失) -> lost=0 [PASS] AC3.1 全部样本被感知(无丢失) -> lost=0
[PASS] AC3.2 感知全部走广播(兜底未介入正常链路) -> 200/200 [PASS] AC3.2 感知全部走广播(兜底未介入正常链路) -> 200/200
[PASS] AC3.3 P99 ≤ 1000ms -> P99=0.843ms [PASS] AC3.3 P99 ≤ 1000ms -> P99=1.997ms
============================================================================== ==============================================================================
AC3b 广播丢失时的时延归入 polling_fallback(>1s 是设计使然,单独统计) 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} 兜底计数器: {"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": "d23e1c4c7ac647d7bfec4072f5bfbeed", "t_write_ms": 0.705, "t_broadcast_emit_ms": 0.004, "t_consume_ms": 251.333, "e2e_ms": 252.038, "via": "polling_fallback", "perceived": true} 样本: {"scene_id": "sc_x_lost", "event_id": "f3de147964fb4f879b5342139e6d7e88", "t_write_ms": 0.718, "t_broadcast_emit_ms": 0.004, "t_consume_ms": 250.654, "e2e_ms": 251.373, "via": "polling_fallback", "perceived": true}
[PASS] AC3b.1 广播丢失时经轮询兜底仍被感知 [PASS] AC3b.1 广播丢失时经轮询兜底仍被感知
[PASS] AC3b.2 报告把它与广播路径分开统计(不混入 P99 判定) [PASS] AC3b.2 报告把它与广播路径分开统计(不混入 P99 判定)
[PASS] AC3b.3 兜底确实发起了补漏查询(polls>=1 且 recovered>=1) -> polls=1 recovered=1 [PASS] AC3b.3 兜底确实发起了补漏查询(polls>=1 且 recovered>=1) -> polls=1 recovered=1
@ -90,7 +90,7 @@ AC5 对外门面可调用性(防漏注册导致 NameError)
============================================================================== ==============================================================================
检查 16 个导出名, 缺失=[] 检查 16 个导出名, 缺失=[]
[PASS] AC5.1 world_sync 包级导出齐全 -> [] [PASS] AC5.1 world_sync 包级导出齐全 -> []
[PASS] AC5.2 poll_since 门面可直调 -> {"scene_id": "sc_facade", "count": 0} [PASS] AC5.2 poll_since 门面可直调,返回键为 rows/count -> {"scene_id": "sc_facade", "count": 0}
[PASS] AC5.3 explain_poll_query 门面可直调 -> 30 rows [PASS] AC5.3 explain_poll_query 门面可直调 -> 30 rows
[PASS] AC5.4 fallback_stats 返回计数快照 -> {"polls": 1, "queries": 1, "recovered": 1, "duplicated": 0, "missed_by_broadcast": 1, "timeouts": 0, "stops_on_notify": [PASS] AC5.4 fallback_stats 返回计数快照 -> {"polls": 1, "queries": 1, "recovered": 1, "duplicated": 0, "missed_by_broadcast": 1, "timeouts": 0, "stops_on_notify":
@ -125,12 +125,12 @@ AC5 对外门面可调用性(防漏注册导致 NameError)
OK AC4.2 polling 不 import 写入端事务模块(只依赖 sql/errors/tx_env) OK AC4.2 polling 不 import 写入端事务模块(只依赖 sql/errors/tx_env)
OK AC4.3 兜底只读 pbl_runtime_event(SQL 无 INSERT/UPDATE/DELETE) OK AC4.3 兜底只读 pbl_runtime_event(SQL 无 INSERT/UPDATE/DELETE)
OK AC5.1 world_sync 包级导出齐全 OK AC5.1 world_sync 包级导出齐全
OK AC5.2 poll_since 门面可直调 OK AC5.2 poll_since 门面可直调,返回键为 rows/count
OK AC5.3 explain_poll_query 门面可直调 OK AC5.3 explain_poll_query 门面可直调
OK AC5.4 fallback_stats 返回计数快照 OK AC5.4 fallback_stats 返回计数快照
31/31 通过 31/31 通过
时延报告: P50=0.650ms P90=0.699ms P99=0.843ms max=0.969ms verdict=PASS 时延报告: P50=1.042ms P90=1.232ms P99=1.997ms max=3.124ms verdict=PASS
报告 JSON 落盘: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_latency_report.json 报告 JSON 落盘: /d/pipeline/workspaces/0/sdlc_general/modules/world_sync/scripts/m11b4_latency_report.json
RESULT: PASS FAILURES=0 RESULT: PASS FAILURES=0

View File

@ -120,6 +120,8 @@ polling 与写入端事务模块 import 零交集、补漏 SQL 只读 `pbl_runti
- **M11b-4 三处同步注册**:新增/删除 polling、latency 门面函数必须同步改 ①实现 ②`__init__.py` 导入 + `__all__` ③`init.py` `env.xxx`。漏 ② → 宿主 `from world_sync import explain_poll_query` AttributeError(本任务 QC #2 实际踩过)。selftest AC5.1 逐名核验 16 个导出名。 - **M11b-4 三处同步注册**:新增/删除 polling、latency 门面函数必须同步改 ①实现 ②`__init__.py` 导入 + `__all__` ③`init.py` `env.xxx`。漏 ② → 宿主 `from world_sync import explain_poll_query` AttributeError(本任务 QC #2 实际踩过)。selftest AC5.1 逐名核验 16 个导出名。
- **文档数值必须与 `scripts/m11b4_latency_report.json` 一致**:该 JSON 是唯一数值源且自带 `run_at` 时间戳;引用分段 P99/广播分位数/墙钟数字时逐字段照抄并标注时间戳,禁止凭记忆或引用另一次未落盘的运行。 - **文档数值必须与 `scripts/m11b4_latency_report.json` 一致**:该 JSON 是唯一数值源且自带 `run_at` 时间戳;引用分段 P99/广播分位数/墙钟数字时逐字段照抄并标注时间戳,禁止凭记忆或引用另一次未落盘的运行。
- **AC1.4 判定口径含容差**:`wall_ms <= FALLBACK_SLO_MS + SLO_TOLERANCE_MS`(3000+500ms,来源为真实 3s sleep 的唤醒抖动与周期边界),文档不得写成「3000.5ms ≤ 3000ms」。 - **AC1.4 判定口径含容差**:`wall_ms <= FALLBACK_SLO_MS + SLO_TOLERANCE_MS`(3000+500ms,来源为真实 3s sleep 的唤醒抖动与周期边界),文档不得写成「3000.5ms ≤ 3000ms」。
- **`SLO_TOLERANCE_MS` 是模块源码常量**(QC #17):定义在 `pbl_runtime_polling.py`(与 `FALLBACK_SLO_MS` 同段),并已按三处同步导出(`__init__.py` import + `__all__`)与登记(`init.py` `env.SLO_TOLERANCE_MS`)。selftest 只 `from world_sync.pbl_runtime_polling import SLO_TOLERANCE_MS` 引用,**禁止在测试脚本里重复本地定义**——否则 dev-notes 常量表「与源码逐一对齐」的声称不成立。同理 `env.SEEN_IDS_IN_SQL` 也已登记。
- **`poll_since` 门面返回键是 `count`**(不是 `recovered_count`):`{scene_id, watermark, rows, count}`。`recovered_count` 只存在于 `PollingFallback.poll_once()` / `run()` 的返回值。写文档/断言前先看源码返回字典,别把两套口径混用(QC #15)。
- `RedisPubSubChannel` 惰性 import `redis`:环境缺包只 `ChannelUnavailable` 跳过,不影响 InProcess/webhook 通道,不要让 Redis 成为硬依赖 - `RedisPubSubChannel` 惰性 import `redis`:环境缺包只 `ChannelUnavailable` 跳过,不影响 InProcess/webhook 通道,不要让 Redis 成为硬依赖
- `scripts/sync_code_to_app.py` 的 `collect()` 必须以 `WS_ROOT` 拼绝对路径 —— 用相对路径 `isdir` 会恒返回空清单,导致 `--check` 打印假 PASS(M11b-3 已修,改脚本时勿回退) - `scripts/sync_code_to_app.py` 的 `collect()` 必须以 `WS_ROOT` 拼绝对路径 —— 用相对路径 `isdir` 会恒返回空清单,导致 `--check` 打印假 PASS(M11b-3 已修,改脚本时勿回退)

View File

@ -157,6 +157,7 @@ from .pbl_runtime_polling import (
POLL_LIMIT, POLL_LIMIT,
POLL_TIMEOUT_S, POLL_TIMEOUT_S,
SEEN_IDS_IN_SQL, SEEN_IDS_IN_SQL,
SLO_TOLERANCE_MS,
PollingFallback, PollingFallback,
build_poll_sql, build_poll_sql,
configure_fallback, configure_fallback,
@ -282,6 +283,7 @@ __all__ = [
"POLL_LIMIT", "POLL_LIMIT",
"FALLBACK_SLO_MS", "FALLBACK_SLO_MS",
"SEEN_IDS_IN_SQL", "SEEN_IDS_IN_SQL",
"SLO_TOLERANCE_MS",
"PollingFallback", "PollingFallback",
"build_poll_sql", "build_poll_sql",
"configure_fallback", "configure_fallback",

View File

@ -184,6 +184,8 @@ from .pbl_runtime_polling import (
POLL_INTERVAL_S, POLL_INTERVAL_S,
POLL_LIMIT, POLL_LIMIT,
POLL_TIMEOUT_S, POLL_TIMEOUT_S,
SEEN_IDS_IN_SQL,
SLO_TOLERANCE_MS,
PollingFallback, PollingFallback,
build_poll_sql, build_poll_sql,
configure_fallback, configure_fallback,
@ -558,6 +560,8 @@ def load_world_sync(server_env=None, conn_factory=None):
env.POLL_TIMEOUT_S = POLL_TIMEOUT_S env.POLL_TIMEOUT_S = POLL_TIMEOUT_S
env.POLL_LIMIT = POLL_LIMIT env.POLL_LIMIT = POLL_LIMIT
env.FALLBACK_SLO_MS = FALLBACK_SLO_MS env.FALLBACK_SLO_MS = FALLBACK_SLO_MS
env.SEEN_IDS_IN_SQL = SEEN_IDS_IN_SQL
env.SLO_TOLERANCE_MS = SLO_TOLERANCE_MS
env.E2E_LATENCY_SLO_MS = E2E_LATENCY_SLO_MS env.E2E_LATENCY_SLO_MS = E2E_LATENCY_SLO_MS
env.PollingFallback = PollingFallback env.PollingFallback = PollingFallback
env.build_poll_sql = build_poll_sql env.build_poll_sql = build_poll_sql

View File

@ -57,6 +57,11 @@ POLL_LIMIT = 500
FALLBACK_SLO_MS = 3000.0 FALLBACK_SLO_MS = 3000.0
#: 去重集合参与 SQL 的最大长度(防止 NOT IN 参数爆炸) #: 去重集合参与 SQL 的最大长度(防止 NOT IN 参数爆炸)
SEEN_IDS_IN_SQL = 200 SEEN_IDS_IN_SQL = 200
#: AC1.4 判定调度容差(毫秒):真实 ``time.sleep(3.0)`` 的唤醒抖动 + 单次周期边界。
#: 补漏必然发生在第 1 次 sleep 之后,墙钟恒 >=3000ms,故验收判定式为
#: ``wall_ms <= FALLBACK_SLO_MS + SLO_TOLERANCE_MS``(即 <= 3500.0ms)。
#: selftest 与文档统一引用本常量,禁止在测试侧重复定义(QC #17)。
SLO_TOLERANCE_MS = 500.0
_WATERMARK_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?$") _WATERMARK_RE = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,6})?$")
@ -66,6 +71,7 @@ __all__ = [
"POLL_LIMIT", "POLL_LIMIT",
"FALLBACK_SLO_MS", "FALLBACK_SLO_MS",
"SEEN_IDS_IN_SQL", "SEEN_IDS_IN_SQL",
"SLO_TOLERANCE_MS",
"PollingFallback", "PollingFallback",
"build_poll_sql", "build_poll_sql",
"configure_fallback", "configure_fallback",