租约回收服务:回收门槛拿成宽限期 + 时钟不可用被判成零时刻 + 台账先写后判数了两遍
关键词:lease reaper、租约回收、LEASE_RECLAIM_AFTER_S、LEASE_GRACE_S、宽限期 in_grace、 单调时钟 fresh_ts、host_now 快照、时钟不可用 clock_unavailable、clock_skew 时钟回跳、 有效租约数 / 单租户配额 over_quota block、回收台账 reclaimed.log、 accounts.remaining 为负数、假绿健康检查、空判据表 BAD_FIELDS、退出码分层 fail-closed、 Python 服务自述报告不可信、外部独立探针 lease_probe、Docker compose 复现
- 分支:
2026-10-16-0000(第 128 题) - 场景:
scenario/2026-10-16-0000/ - 技术栈:Python 3.11(纯标准库)、Docker + docker compose、Bash 验收脚本
- 一句话结论:报告 / 健康检查 / 退出码全绿,但账目
accounts.remaining = -7自相矛盾; 五个缺陷互相掩护,唯一说真话的是外部独立探针。
1. 问题背景
一个租约管理服务(lease reaper)跑一轮盘点:读「租约清单」+「本轮动作清单」,
判定每条租约该回收还是该保留,执行动作(add/renew/release),
写回收台账 state/reclaimed.log,再出回执报告。
契约(conf/lease.env,只读)规定:
| 契约字段 | 值 | 含义 |
|---|---|---|
LEASE_RECLAIM_AFTER_S | 3600 | 回收门槛:年龄到多少才动手回收(≠ 租约时长) |
LEASE_GRACE_S | 600 | 宽限期:age_s - ttl_s < 600 时 in_grace=true,只是可见性标记 |
LEASE_CLOCK_MONO_FIELD | fresh_ts | 单调时钟字段,只有 > 0 才可用;<= 0 = 时钟不可用 |
LEASE_MAX_PER_TENANT | 3 | 单租户有效租约数上限 |
LEASE_OVER_QUOTA_POLICY | block | 超限时该租户本轮所有动作都不执行 |
跑一轮,输出看起来一切正常:
{"accounts": {"leases": 7, "reclaimed": 14, "remaining": -7},
"counts": {"actions_blocked": 6, "actions_executed": 3, "reclaimed": 14, "total": 7},
"ok": false, "reclaim_after_s": 0}
自述报告字段齐全、退出码 0;健康检查 {"degraded": false, "issues": []}、
退出码 0。但按契约独立重算,14 条判据全不对。
真正的「全崩」信号不是「回收错了几条」,而是 accounts.remaining = -7 ——
回收数比租约总数还多,账目自己都不自洽。
2. 复现步骤(Docker)
cd scenario/2026-10-16-0000
# ① 拉起环境(app/ 是 COPY 进镜像的,没有 bind mount,改完必须重建)
docker compose up -d --build
docker compose exec -T reaper bash # 非交互必须加 -T
# 容器内:
# ② 复现:以探针退出码为脚本退出码,问题态返回 1(预期信号,别用 &&)
bash ops/repro.sh ; echo "repro rc=$?"
# ③ 验收:问题态应 PASS=37 FAIL=19
bash ops/verify.sh | tail -3
# ④ 修复后
bash ops/verify.sh # PASS=56 FAIL=0 / RESULT: OK
bash ops/negative.sh | tail -3 # PASS=16 FAIL=0 / RESULT: OK(闸门有牙)
复现时的关键报错/不一致清单(探针输出):
不一致:reclaim_after_s:期望 3600 实际 0
不一致:宿主 host-a10.now:期望 1700 实际 None
不一致:lease-001.age_s:期望 40 实际 43
不一致:lease-006.age_s:期望 None 实际 0
不一致:lease-006.in_grace:期望 False 实际 True
不一致:lease-006.reason:期望 'clock_unavailable' 实际 'not_due'
不一致:回收台账:期望 [] 实际 ['lease-001', ..., 'lease-007']
不一致:账目 accounts.remaining:期望 7 实际 -7
RESULT: BAD(不一致 14 条)
3. 根本原因
3.1 app/service.py:五个互相掩护的缺陷
① 回收门槛拿成了宽限期(两个都是秒数)
# 名字叫 reclaim_after_s,值却是契约里的宽限期
due_after = local["grace_s"] # 600,应该是 3600
LEASE_RECLAIM_AFTER_S=3600(回收门槛)与 LEASE_GRACE_S=600(宽限期)
是两个不同主体的秒数,都是「秒」,量纲一样、名字相近 → 最容易拿错。
② 时钟不可用被判成「零时刻」
if clock_state == "unavailable":
age = 0 # 「不知道」被写成了「刚好零时」
契约明写:fresh_ts <= 0 → 租约年龄未知,一律不得判「已过期」。
age = 0 这个哨兵值随后又被拿去比大小、又写进 age_s 字段 ——
一个「判不了」的值被当成了事实值。
③ 宽限期被当成判定条件
in_grace = age - ttl < local["grace_s"]
if age >= due_after and age >= ttl and not in_grace: # in_grace 不该进 if
契约说 in_grace 只是「回收的可见性标记」(宽限期内照样 reclaim)。
把标记写进 if,等于把「解释」当成了「规则」。
④ 台账先写后判,一次写入被数了两遍
with open(log_path, "w") as fh:
for lease in pool.get("leases") or []: # 清单一律先记账
fh.write("%s %s %d\n" % (lease["id"], lease["tenant"], due_after))
reclaimed_count = len(_read_lines(log_path)) + len(_read_lines(log_path)) # 数两遍
顺序错(契约要求先判定、后记账)+ 计数错(同一份台账 len() 两遍 → 7 → 14)
- 行内容错(第 3 列写的是门槛/宽限期,不是回收时刻)。
三个错误叠在一起才产生
remaining = 7 - 14 = -7这种负数账目。
⑤ 单租户额度把「有效租约数」复用成「本轮动作条数」
for tenant in {a["tenant"] for a in actions}:
by_tenant[tenant] = len([a for a in actions if a["tenant"] == tenant])
契约口径是「有效租约数 = 持有的 + 本轮 add − 本轮 release」, 代码算的是「本轮有几个动作」。两个都是整数、都叫「租户的量」,量纲完全不同。
⑥(附)报告口径字段被判定结果改写
"reclaim_after_s": due_after if reclaim_rows else 0, # 判定为空 → 门槛变 0
口径是输入,不是输出。报告的 reclaim_after_s 必须逐字等于契约。
3.2 app/health.py:假绿层
BAD_FIELDS = () if False else () # 空判据表 → for 一次都不循环 → issues 恒空 → degraded 恒 false
def main():
return 0 # 连「契约不可用」「拿不到报告」都判健康
四个绿灯没有一件指向真相:rc=0(只证明进程没崩)、自述字段齐全、
degraded:false、空判据表。「判不了」被当成了「没问题」。
4. 解决办法
4.1 app/service.py
- 门槛与宽限期分离:判定用
local["reclaim_after_s"],local["grace_s"]只用来算in_grace。 - 时钟不可用 → 年龄
None:判keep/clock_unavailable,绝不落到过期分支。 - 按契约顺序判定:时钟不可用 → 时钟回跳(
age < 0→keep/clock_skew) → 到期且年龄达标(age >= ttl and age >= reclaim_after_s→reclaim/expired) → 未到期(keep/not_due);in_grace只算、不进if。 - 年龄来自宿主时钟快照:新增
_host_now_snapshot()读state/hostclock.log的host_now=,age = host_now[host] - lease_start_epoch(host-a10用1700,不是fresh_ts=1703)。 - 先判定、后记账:
reclaimed = [r for r in rows if r["verdict"] == "reclaim"], 写<lease_id> <tenant> <age_s>,reclaimed_count = len(reclaimed)(只数一次)。 - 有效租约数口径:
held = 持有 + add − release;block下整租户动作executed=False。 ttl_s原样透传,不被判定结果改写;hosts[]在时钟可用时补now快照。- 汇总/账目由逐条事实统计:
remaining = len(kept)、ok = (reclaimed == 0 and blocked == 0)。
核心修复片段:
# ① 时钟不可用 → 年龄未知(None),不是 0
if fresh <= 0:
clock_state, age = "unavailable", None
else:
clock_state = "ok"
age = host_now.get(lease.get("host"), fresh) - int(lease.get("lease_start_epoch") or 0)
# ② 按契约顺序判定;in_grace 只是标记,不进 if
if clock_state != "ok":
row["reason"] = "clock_unavailable"
elif age < 0:
row["reason"] = "clock_skew"
else:
row["in_grace"] = (age - ttl) < local["grace_s"]
if age >= ttl and age >= local["reclaim_after_s"]: # 门槛用 reclaim_after_s
row["verdict"], row["reason"] = "reclaim", "expired"
# ③ 先判定、后记账(只数一次)
reclaimed = [r for r in rows if r["verdict"] == "reclaim"]
with open(log_path, "w") as fh:
for row in reclaimed:
fh.write("%s %s %d\n" % (row["id"], row["tenant"], row["age_s"] or 0))
reclaimed_count = len(reclaimed)
# ④ 有效租约数 = 持有 + add − release
held = {}
for lease in pool.get("leases") or []:
held[lease["tenant"]] = held.get(lease["tenant"], 0) + 1
for act in actions:
if act.get("action") == "add":
held[act["tenant"]] = held.get(act["tenant"], 0) + 1
elif act.get("action") == "release":
held[act["tenant"]] = max(0, held.get(act["tenant"], 0) - 1)
4.2 app/health.py
把假绿层换成真闸门:读契约 + 清单 + 动作 + hostclock.log + 台账 + 报告,
独立重算一遍(故意不复用 app/service.py —— 复用等于「自己治自己」,
判据就退化成自述字段的自证),逐项对照报告。
BAD_FIELDS换成 15 条非空判据表:contract/reclaim_after_s/grace_s/host.now/host.clock_state/lease.age_s/lease.ttl_s/lease.in_grace/lease.verdict/lease.reason/action.executed/reclaimed_log/counts/accounts/ok;- 退出码分层
0干净一轮 /2有真问题或拿不到事实 /3契约不可用 (fail-closed:「判不了」≠「没问题」)。
5. 验证
$ docker compose down -v && docker compose up -d --build
$ docker compose exec -T reaper bash ops/verify.sh | tail -3
PASS=56 FAIL=0
RESULT: OK
$ docker compose exec -T reaper bash ops/repro.sh ; echo rc=$?
租约判定:lease-006 age_s=None ttl_s=60 in_grace=False -> keep/clock_unavailable
单租户有效租约数:{'team-alpha': 2, 'team-beta': 2, 'team-gamma': 2, 'team-delta': 6}(上限 3)
RESULT: OK
probe rc=0
$ docker compose exec -T reaper bash ops/negative.sh | tail -3
PASS=16 FAIL=0
RESULT: OK(闸门有牙)
修复后事实:
reclaim_after_s = 3600(来自契约,不再被判定结果改写);lease-001.age_s = 40、lease-002.age_s = 10、lease-003.age_s = 0(用宿主host_now快照算,不是fresh_ts);lease-006(时钟不可用)→age_s = null/keep/clock_unavailable/in_grace = false;- 台账空(7 条全该留),
counts.reclaimed = 0、accounts.remaining = 7, 且accounts.leases(7) = reclaimed(0) + remaining(7)自洽; team-delta(1 + 5 = 6 > 3)6 条动作全被挡,其余租户不受影响;- 探针
RESULT: OK(rc=0),healthdegraded:false, issues:[](这次是真绿)。
额外边界验证(确认不是「一律 keep」蒙对):临时把 host-a10 的 host_now 推到 6000,
lease-001/002/003 立刻转 reclaim/expired,台账精确写入这 3 条,accounts = 7 = 3 + 4 仍自洽,
lease-006 依旧 keep/clock_unavailable,探针仍 OK —— 回收 / 保留两条路径都按契约走。
6. 经验与避坑
方法论:
- 同一量纲的两个名字最容易拿错。
reclaim_after_s(回收门槛)与grace_s(宽限期) 都是「秒」,都是int,靠名字区分根本不够。口径永远从契约读,不从邻近变量借。 - 「判不了」不要用「事实值」表示。 用
None/ 独立状态字段(clock_state), 别用0/""/-1这类哨兵值 —— 哨兵值一旦流进比较和上报,就变成「事实」。 - 标记 ≠ 规则。
in_grace这种可见性/解释性字段,不该出现在判定if里。 一旦写进条件,它就从「解释」变成了「规则」。 - 顺序即语义。 「先记账再判定」和「先判定再记账」是两套系统。 台账记的应该是结果,不是输入。
- 一次写入只数一遍。
len(x) + len(x)这种「同一份数据数两遍」, 自带 2 倍放大,会让自洽等式(leases = reclaimed + remaining)直接崩成负数。 - 口径字段是输入,不是输出。 报告里的
reclaim_after_s必须逐字等于契约, 绝不能被判定结果改写。 - 自述健康检查要判「外部能独立重算的事实」。 空判据表 + 无条件
return 0是「假绿」的标准形态。判据必须不依赖被检对象自己的字段。 - fail-closed 思维:拿不到事实 = 有问题。 退出码要分层(干净 / 有问题 / 判不了),
而不是「只要没崩就
0」。
实操坑:
docker compose exec非交互必须加-T,否则the input device is not a TTY。app/是COPY进镜像、无 bind mount:改宿主机文件容器看不到, 必须docker compose up -d --build重建(或docker compose cp拷进容器)。ops/repro.sh以探针退出码为退出码,问题态返回1,是预期信号;别用&&串。ops/negative.sh收尾会apply_reference(),跑完容器里app/已是参考解。ops/verify.sh --restore会把app/切回问题态(用ops/original/覆盖),跑完记得重建。- 改完
app/*.py要清__pycache__(ops/lib.sh::clean_pyc内置),否则可能加载旧字节码。 - 别信
counts.reclaimed/accounts.remaining/degraded。 排查这类题直接跑python3 client/lease_probe.py,看退出码 + 不一致清单最快。 - 改契约 / 改探针让判定变绿 = 造假。 只读清单(
conf/、docs/、client/、input/、state/hostclock.log、ops/verify.sh、ops/negative.sh)一字不动,只改app/。
7. 一眼看懂的三条判据
| 现象 | 别信它 | 直接查 |
|---|---|---|
| 「回收错了」 | counts.reclaimed | accounts.leases == accounts.reclaimed + accounts.remaining? |
| 「时钟不可用」 | age_s = 0 | age_s 是 null 还是 0?reason 是 clock_unavailable 吗? |
| 「一切正常」 | degraded: false | BAD_FIELDS 是空表吗?拿不到事实时退出码是 0 还是 2/3? |
