朕喜技术(ZhenxiJS)
2026.10.16
1 分钟阅读

租约回收服务:回收门槛拿成宽限期 + 时钟不可用被判成零时刻 + 台账先写后判数了两遍

关键词: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_S3600回收门槛:年龄到多少才动手回收(≠ 租约时长)
LEASE_GRACE_S600宽限期:age_s - ttl_s < 600 时 in_grace=true,只是可见性标记
LEASE_CLOCK_MONO_FIELDfresh_ts单调时钟字段,只有 > 0 才可用;<= 0 = 时钟不可用
LEASE_MAX_PER_TENANT3单租户有效租约数上限
LEASE_OVER_QUOTA_POLICYblock超限时该租户本轮所有动作都不执行

跑一轮,输出看起来一切正常:

{"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

  1. 门槛与宽限期分离:判定用 local["reclaim_after_s"],local["grace_s"] 只用来算 in_grace。
  2. 时钟不可用 → 年龄 None:判 keep/clock_unavailable,绝不落到过期分支。
  3. 按契约顺序判定:时钟不可用 → 时钟回跳(age < 0 → keep/clock_skew) → 到期且年龄达标(age >= ttl and age >= reclaim_after_s → reclaim/expired) → 未到期(keep/not_due);in_grace 只算、不进 if。
  4. 年龄来自宿主时钟快照:新增 _host_now_snapshot() 读 state/hostclock.log 的 host_now=, age = host_now[host] - lease_start_epoch(host-a10 用 1700,不是 fresh_ts=1703)。
  5. 先判定、后记账:reclaimed = [r for r in rows if r["verdict"] == "reclaim"], 写 <lease_id> <tenant> <age_s>,reclaimed_count = len(reclaimed)(只数一次)。
  6. 有效租约数口径:held = 持有 + add − release;block 下整租户动作 executed=False。
  7. ttl_s 原样透传,不被判定结果改写;hosts[] 在时钟可用时补 now 快照。
  8. 汇总/账目由逐条事实统计: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),health degraded: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. 经验与避坑

方法论:

  1. 同一量纲的两个名字最容易拿错。 reclaim_after_s(回收门槛)与 grace_s(宽限期) 都是「秒」,都是 int,靠名字区分根本不够。口径永远从契约读,不从邻近变量借。
  2. 「判不了」不要用「事实值」表示。 用 None / 独立状态字段(clock_state), 别用 0 / "" / -1 这类哨兵值 —— 哨兵值一旦流进比较和上报,就变成「事实」。
  3. 标记 ≠ 规则。 in_grace 这种可见性/解释性字段,不该出现在判定 if 里。 一旦写进条件,它就从「解释」变成了「规则」。
  4. 顺序即语义。 「先记账再判定」和「先判定再记账」是两套系统。 台账记的应该是结果,不是输入。
  5. 一次写入只数一遍。 len(x) + len(x) 这种「同一份数据数两遍」, 自带 2 倍放大,会让自洽等式(leases = reclaimed + remaining)直接崩成负数。
  6. 口径字段是输入,不是输出。 报告里的 reclaim_after_s 必须逐字等于契约, 绝不能被判定结果改写。
  7. 自述健康检查要判「外部能独立重算的事实」。 空判据表 + 无条件 return 0 是「假绿」的标准形态。判据必须不依赖被检对象自己的字段。
  8. fail-closed 思维:拿不到事实 = 有问题。 退出码要分层(干净 / 有问题 / 判不了), 而不是「只要没崩就 0」。

实操坑:

  1. docker compose exec 非交互必须加 -T,否则 the input device is not a TTY。
  2. app/ 是 COPY 进镜像、无 bind mount:改宿主机文件容器看不到, 必须 docker compose up -d --build 重建(或 docker compose cp 拷进容器)。
  3. ops/repro.sh 以探针退出码为退出码,问题态返回 1,是预期信号;别用 && 串。
  4. ops/negative.sh 收尾会 apply_reference(),跑完容器里 app/ 已是参考解。
  5. ops/verify.sh --restore 会把 app/ 切回问题态(用 ops/original/ 覆盖),跑完记得重建。
  6. 改完 app/*.py 要清 __pycache__(ops/lib.sh::clean_pyc 内置),否则可能加载旧字节码。
  7. 别信 counts.reclaimed / accounts.remaining / degraded。 排查这类题直接跑 python3 client/lease_probe.py,看退出码 + 不一致清单最快。
  8. 改契约 / 改探针让判定变绿 = 造假。 只读清单(conf/、docs/、client/、input/、 state/hostclock.log、ops/verify.sh、ops/negative.sh)一字不动,只改 app/。

7. 一眼看懂的三条判据

现象别信它直接查
「回收错了」counts.reclaimedaccounts.leases == accounts.reclaimed + accounts.remaining?
「时钟不可用」age_s = 0age_s 是 null 还是 0?reason 是 clock_unavailable 吗?
「一切正常」degraded: falseBAD_FIELDS 是空表吗?拿不到事实时退出码是 0 还是 2/3?

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-16-0000

分享这篇文章