快照新鲜度守卫:旧快照不许当今天的计划下发

实测两条路比对:代码序列一模一样,但候选卡名次、判决、依据有实质差异。根子在节奏——
出计划是前一晚 23:10 跑的,数据基座凌晨才更新证据线,快照天生比实时装配旧半天。

两条判据:代码版本要与当前一致、生成时刻要晚于今天早六点。都不满足就退回实时装配,
也就是今天的行为。守卫要松开靠的是把出计划挪到数据基座之后,不是把界调低。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-10 09:30:44 +08:00
parent 8770fbb683
commit 79b3616162
2 changed files with 84 additions and 0 deletions

47
plan.py
View File

@ -855,6 +855,50 @@ def render_md(d: dict) -> str:
return "\n".join(L) return "\n".join(L)
class StaleSnapshot(RuntimeError):
"""快照还在,但太旧、不能拿来当今天的计划用。调用方接住它回落到实时装配。"""
def snapshot_fresh_enough(snap: dict) -> tuple:
"""这份快照够不够新,能不能当今天的计划直接下发。返回 (能不能, 一句原因)。
为什么需要这道守卫2026-09-10台账 057: 出计划这一步现在是**前一晚**跑的
实测 09-09 23:10 出的 09-09 那份而数据基座凌晨才更新公司深度评析催化事件
这些证据线所以快照天生比实时装配旧半天 实测两条路比对代码序列一模一样
但候选卡名次判决依据这些字段有实质差异直接拿旧快照下发等于让下游用昨晚的判决
两条判据都不满足就退回实时装配也就是今天的行为不会变坏:
· 代码版本要和现在跑的一致 版本变了说明判决逻辑可能变了昨天算的不算数
· 生成时刻要晚于今天的界默认早上六点 保证它是在数据基座凌晨那批任务之后出的
**这道守卫要能松开靠的是把出计划挪到数据基座之后**比如早上七点十分代码注释里
本来就是这么设计的不是把界调低界调低只会让旧快照蒙混过关
"""
import version
min_hour = int(os.environ.get("PLAN_SNAPSHOT_MIN_HOUR", "6"))
gen = str(snap.get("generated_at") or "")
ver = str(snap.get("plan_version") or "")
now_ver = ""
try:
now_ver = str(version.git_short_rev() or "")
except Exception: # noqa: BLE001 —— 取不到版本就不拿这条卡人
now_ver = ""
if now_ver and ver and ver != now_ver:
return False, (f"快照是用代码版本 {ver} 出的,现在跑的是 {now_ver}"
f"判决逻辑可能已经变了,本次按实时装配")
if not gen:
return False, "快照没有生成时刻,判不了新旧"
try:
g = dt.datetime.fromisoformat(gen)
except ValueError:
return False, f"快照的生成时刻认不出: {gen}"
today_line = dt.datetime.now().replace(hour=min_hour, minute=0, second=0, microsecond=0)
if g < today_line:
return False, (f"快照出于 {gen},早于今天 {min_hour:02d}:00"
f"数据基座凌晨那批证据线更新它没赶上,本次按实时装配")
return True, ""
def pick_rows(rows, n: int, theme_cap: int) -> list: def pick_rows(rows, n: int, theme_cap: int) -> list:
"""从**已按分数排好序**的行里取 n 条,每个传导主题最多 theme_cap 条0=不设限)。 """从**已按分数排好序**的行里取 n 条,每个传导主题最多 theme_cap 条0=不设限)。
@ -902,6 +946,9 @@ def from_snapshot(date: str | None = None, top: int = 20, obs_top: int = 10,
full_main, full_obs = snap.get("main"), snap.get("observe") full_main, full_obs = snap.get("main"), snap.get("observe")
if not isinstance(full_main, list) or not isinstance(full_obs, list): if not isinstance(full_main, list) or not isinstance(full_obs, list):
raise RuntimeError(f"快照缺全量主榜或观察档: {path}") raise RuntimeError(f"快照缺全量主榜或观察档: {path}")
ok, why = snapshot_fresh_enough(snap)
if not ok:
raise StaleSnapshot(why)
data = {k: v for k, v in snap.items() data = {k: v for k, v in snap.items()
# 前三个是落盘那次的裁剪结果与参数,与本次请求无关; # 前三个是落盘那次的裁剪结果与参数,与本次请求无关;

View File

@ -113,6 +113,42 @@ def test_from_snapshot(tmpdir=None):
t("快照不存在要抛异常", isinstance(e, (FileNotFoundError, RuntimeError)), type(e).__name__) t("快照不存在要抛异常", isinstance(e, (FileNotFoundError, RuntimeError)), type(e).__name__)
def test_freshness_guard():
print("[新鲜度守卫 —— 旧快照不许蒙混过关]")
import datetime as _dt
now = _dt.datetime.now()
import version
cur = ""
try:
cur = str(version.git_short_rev() or "")
except Exception: # noqa: BLE001
cur = ""
def snap(gen, ver=None):
return {"generated_at": gen, "plan_version": cur if ver is None else ver}
fresh = now.replace(hour=7, minute=30, second=0, microsecond=0)
if now < fresh: # 早上七点半之前跑测试时,用「一分钟前」当新鲜样本
fresh = now - _dt.timedelta(minutes=1)
ok, why = plan.snapshot_fresh_enough(snap(fresh.isoformat(timespec="seconds")))
t("今天出的、版本对得上的快照可用", ok, why)
y = (now - _dt.timedelta(days=1)).replace(hour=23, minute=10)
ok2, why2 = plan.snapshot_fresh_enough(snap(y.isoformat(timespec="seconds")))
t("昨晚出的快照不可用(数据基座凌晨那批它没赶上)", not ok2 and "早于今天" in why2, why2)
if cur:
ok3, why3 = plan.snapshot_fresh_enough(snap(fresh.isoformat(timespec="seconds"), "deadbee"))
t("代码版本对不上就不可用(判决逻辑可能变了)", not ok3 and "代码版本" in why3, why3)
ok4, _ = plan.snapshot_fresh_enough(snap(""))
t("没有生成时刻的快照不可用", not ok4)
ok5, _ = plan.snapshot_fresh_enough(snap("不是时间"))
t("生成时刻认不出的快照不可用", not ok5)
t("守卫用的是专门的异常类型,调用方接得住", issubclass(plan.StaleSnapshot, RuntimeError))
def test_api_falls_back(): def test_api_falls_back():
print("[接口回落:读不到快照不能让接口挂掉]") print("[接口回落:读不到快照不能让接口挂掉]")
src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "api.py"), src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "api.py"),
@ -131,6 +167,7 @@ def main():
test_pick_rules() test_pick_rules()
test_replay_against_real_snapshot() test_replay_against_real_snapshot()
test_from_snapshot() test_from_snapshot()
test_freshness_guard()
test_api_falls_back() test_api_falls_back()
print("-" * 70) print("-" * 70)
if FAILED: if FAILED: