卷租约续约器「续约窗口里四个绿灯,卷却没人续」:拿增量冒充剩余时长、TTL 取了会话快照、空判据表假绿排查记
日期:2026-10-22|分支:
2026-10-22-1001|期数:第139题 技术栈:Python 3.11(纯标准库)/ TSV 台账 / JSON 报告 / Docker Compose / 外部独立探针 关键词:卷租约续约 lease renewer、续约窗口 renew window 用绝对值还是剩余时长、 TTL 来源混淆(引擎构建期默认值 / 运行期会话快照 / 契约业务值)、engine_ttl_s、attach_mode只读卷被误判 read_only_volume、released_at=-1已释放持有者仍判 held、expires_at初值为 0、判定优先级 invalid_ttl / unknown_volume / stale_request / held、 同卷重复请求 dropped 不记账、dropped 行 verdict 记 drop 还是 accept、counts 三分量守恒accepted+rejected+dropped==requests、 探针 counts 口径与契约冲突、ok恒 true、BAD_FIELDS = ()空判据表假绿、 退出码恒 0、契约 fail-open 回退默认值、docker compose exec -T、docker compose up --build不重建导致改了没生效
一、问题背景
存储控制面里,每个挂载中的卷都有一份租约(lease):租约到期前必须由续约器(renewer)
把 expires_at 往后推,否则挂载层认为卷「掉线」并把 IO 打回宿主机。续约器每轮读一批续约请求,
逐条判定 accept / reject / drop,写进台账 state/lease_ledger.tsv,并输出报告
reports/lease_report.json。
上线后运维看到的一切都很正常:
python3 -m app.service # 退出码 0
python3 -m app.health # {"degraded": false, "issues": []},退出码 0
wc -l state/lease_ledger.tsv # 11 行,一条不少
jq .ok reports/lease_report.json # true
四个绿灯,没有一件指向真相。 实际数据面:部分卷明明在续约窗口里,租约却始终没往后推; 有卷被"顺手"拒了;几条请求凭空消失;最后一轮真正生效的续约只有 4 条。
典型症状:
| 现象 | 期望 |
|---|---|
vol-a 续约只推到 1771143060 | 1771144260(少 1200 秒) |
vol-b 在窗口里却整卷从 leases 消失 | 在窗口外本就不该被续约 |
vol-c(只读卷)/ vol-f 被以只读为由拒 | attach_mode 不参与判定 |
vol-j 的 holder released_at=1771142000 仍判 held | 已释放 → 不该算 held |
req-0003/0004 同卷两条都进 reject,counts.dropped=0 | 后写者赢,前一条 dropped |
req-0006 早于启动时刻被 accept;ttl_s=-5 未被拒 | stale_request / invalid_ttl |
counts 4+7+0 ≠ 11 | 三分量必须加回 requests |
二、复现步骤
有 Docker(推荐):
cd scenario/2026-10-22-1001
docker compose up -d --build
docker compose exec -T lease bash # 非交互必须加 -T
bash ops/repro.sh; echo "repro_rc=$?" # 探针外部重算 → 27 条不一致,rc=1
bash ops/verify.sh; echo "verify_rc=$?" # PASS=5 FAIL=23
bash ops/negative.sh;echo "negative_rc=$?" # PASS=5 FAIL=0(闸门有牙,病在被测代码)
无 Docker(CI / pipeline 容器内,ops/lib.sh 支持用环境变量指定根目录):
cd scenario/2026-10-22-1001
export LEASE_ROOT=$PWD PYTHONPATH=$PWD
bash ops/repro.sh; echo "repro_rc=$?"
bash ops/verify.sh; echo "verify_rc=$?"
bash ops/negative.sh;echo "negative_rc=$?"
两个注意点:bash ops/repro.sh && ... 会中断(问题态退出码 1 是复现信号,用 ; echo rc=$? 接住);
手工跑 python3 -m app.* 前要清 __pycache__,否则"改了但行为没变"。
DIFF counts.accepted:期望 6 实际 4
DIFF counts.dropped:期望 1 实际 0
DIFF ok:期望 False 实际 True
DIFF leases[vol-b]:期望 '<不该出现>' 实际 {... 'state': 'blocked' ...}
DIFF ledger[5]:期望 'req-0006\tvol-e\treject\tstale_request\t1771144200'
实际 'req-0006\tvol-e\taccept\tok\t1771143270'
RESULT: BAD(不一致 27 条)
probe_rc=1
三、根本原因
3.1 续约窗口:拿「剩余时长」还是拿「绝对值」
# 问题态
delta = expires[vid] # expires_at 的绝对值(epoch 秒)
if delta > policy["renew_window_s"]: # 拿它去比 1200 秒的窗口
continue
renew_window_s 是前瞻窗口,语义是「租约还剩多久到期」:
# 正确
if expires[vid] - policy["startup_at"] > policy["renew_window_s"]:
continue
问题态把 expires_at 的绝对值(17 亿这个量级)当成增量去比 1200 秒,
于是除了初始值为 0 的卷,全都判成"窗口外" —— 租约还很新的卷反而被拉进来,
真正快过期的卷被踢出去。一句话:量纲不同不能比较(分钟 vs 秒、绝对时刻 vs 时长、
点数 vs 金额)。
3.2 TTL 三个来源,别混
同一个"默认 TTL"现场能有三份:
| 来源 | 位置 | 本数据值 | 契约口径 |
|---|---|---|---|
| 引擎构建期默认值 | ENGINE_DEFAULT_TTL_S | 600 | 不是业务快照 |
| 引擎会话 TTL | usage/startup_snapshot.txt 的 engine_ttl_s | 600 | 只用于记录 |
| 契约业务值 | conf/lease.env 的 LEASE_DEFAULT_TTL_S | 1800 | 唯一权威 |
# 问题态:读了会话快照
ttl = engine_ttl or policy["default_ttl_s"]
# 正确:只认契约,engine_ttl 连参数都别往判定函数里传
eff = policy["default_ttl_s"] if ttl == 0 else ttl
后果:ttl_s=0 的请求本该续 1800 秒,实际续了 600 秒 —— 少续 1200 秒,静默生效,
报表里所有字段都合法。这类「数值错但字段全合法」的 bug 最难被报表发现。
3.3 展示字段被当成判定条件
if volumes[vid]["attach_mode"] == "ro":
return "reject", "read_only_volume"
契约 §5 写得很清楚:attach_mode(rw/ro)只用于展示,不参与任何 accept/reject 判定 ——
租约由挂载层管理,与读写模式无关。问题态直接拒掉只读卷,vol-c(两次)和 vol-f 一共 3 条请求被误杀。
另外 read_only_volume 这个 reason 契约里根本不存在。
3.4 持有者判定:-1 才是"仍持有"
# 问题态
def is_held(released_list):
return bool(released_list) # 出现过就算持有
# 正确
held = -1 in holders.get(vid, []) # released_at == -1 表示仍持有
holders.tsv 里每个卷可能有多条记录(历史持有 + 当前持有),released_at 是释放时刻。
问题态把「有过 holder」当成「正持有」,vol-c(已释放 1771141000)和 vol-j(已释放 1771142000)
的请求全被误拒。
3.5 expires_at 初值不是 0
每条结论都要能回答「本轮之前这个卷的 expires_at 是多少」。问题态 expires = {vid: 0},
于是台账里所有 expires_at_after 都是错的、报告的 expires_at 全是 0。
本数据集的确定性初值是 STARTUP_AT + size_giB * 60(size_gib 在这里是参与计算的,
而它在 attach_mode 这种展示字段旁边,很容易被一起当成"无关字段"扔掉 —— 逐个字段问
「它是业务快照、会话参数,还是展示字段?」)。
3.6 判定优先级:拒绝原因必须可区分且按序
契约 §4 的严格顺序:
1. ttl_s < 0 -> reject / invalid_ttl
2. 卷不在 volumes.tsv -> reject / unknown_volume
3. requested_at < STARTUP_AT -> reject / stale_request
4. 有仍持有的 holder -> reject / held
5. 否则 accept;ttl 被压顶额外标 clamped
6. 同卷多条 accept:keep = max(requested_at),并列取文件靠后者,其余 dropped
问题态缺了 1 和 3,且把 held 排在 unknown_volume 前 —— 先命中的规则会吃掉后面的,
所以 vol-x(未知卷)、req-0006(上轮遗留)、req-0007(负 TTL)全都被"接受"了。
clamped 还有个细节:clamped 是 reason 而不是 verdict ——
被压顶的请求仍然是 accept,只是 reason 写 clamped(reject 不覆盖 clamped)。
3.7 dropped 也要记账
# 问题态:同卷重复直接 setdefault 保留第一条
by_volume.setdefault(row["volume_id"], row)
后果三重:① counts.dropped 恒 0;②「后写者赢」变成「先写者赢」(req-0003 抢在 req-0004 前面);
③ 被丢弃的请求没写台账,accepted + rejected + dropped ≠ requests。
正确做法是 keep = max((requested_at, seq)),其余置 reason = "dropped" 并照常写台账。
关键补充(本题最容易踩反的一步):被 dropped 的请求 verdict 要记 drop,不能继续记 accept。
契约 §6 要求 accepted + rejected + dropped == requests,三个分量互斥。若 dropped 行还挂
accept,它就被 accepted 和 dropped 各数一次:
accepted 6 + rejected 5 + dropped 1 = 12 ≠ requests 11 ← 违反 §6
accepted 5 + rejected 5 + dropped 1 = 11 ← 改成 verdict=drop 后守恒
DROP = "drop"
if req["req_id"] in dropped:
row["verdict"] = DROP # ← 不是 ACCEPT
row["reason"] = "dropped"
"counts.accepted": sum(1 for r in judged if r["verdict"] == ACCEPT) # 只数被 keep 的 accept
"counts.dropped": sum(1 for r in judged if r["verdict"] == DROP)
顺带:health 里若把台账 verdict 白名单写成 {accept, reject},一旦判据表真正生效,
合法的 dropped 行会被误报 bad_verdict —— 白名单要加 drop。这是"假绿改真绿"阶段才暴露的连带 bug。
3.8 ok 恒真 + 空判据表 + 退出码恒 0(三层假绿)
# renewer
ok = accepted > 0 # 有 7 条 reject 也 ok
# service
return 0 # 无条件
# health
BAD_FIELDS = () # for 循环执行 0 次 → issues 恒空 → degraded 恒 false
for field in BAD_FIELDS: ...
return 0 # 无条件
# contract
except OSError: pass # 读不到契约就用 FALLBACK 继续跑
for 循环体执行 0 次 = 看着有检查,其实在空转,比没有检查更危险 ——
它静默地骗人。契约 fail-open 同理:「读不到配置就用默认值」看着稳,实际是让系统带着
一套没人知道的口径继续跑还报绿。
四、解决办法
只改 app/ 下 5 个文件(docs/ conf/ input/ client/ ops/ 一字未动):
app/contract.py —— fail-closed
删掉 FALLBACK 与 except OSError: pass;读不到 / 缺必填 / 非整数 / 取值非法
(default_ttl<=0、max_ttl<=0、renew_window<0)一律抛 ContractError。
app/inputs.py
新增 FactsError;读不到 / 空文件 / 行非法抛错;seq 用文件原始行号
(原来用跳过坏行后的 index,会破坏"并列时后写者赢")。
app/renewer.py —— 判定口径重写
def initial_expires(volumes, policy):
return {vid: policy["startup_at"] + int(info["size_gib"]) * 60
for vid, info in volumes.items()}
def judge(req, policy, volumes, holders):
ttl, vid = req["ttl_s"], req["volume_id"]
if ttl < 0: return "reject", "invalid_ttl", None
if vid not in volumes: return "reject", "unknown_volume", None
if req["requested_at"] < policy["startup_at"]: return "reject", "stale_request", None
if -1 in holders.get(vid, []): return "reject", "held", None
eff = policy["default_ttl_s"] if ttl == 0 else ttl
clamped = eff > policy["max_ttl_s"]
eff = min(eff, policy["max_ttl_s"])
return "accept", ("clamped" if clamped else "ok"), eff
plan_round(policy, volumes, holders, requests) 签名里没有 engine_ttl ——
从签名上杜绝"顺手读进来"。窗口判定 expires[vid] - startup_at <= renew_window_s;
holder_present = -1 in holders.get(vid, []);
expires_at = before[vid],expires_at_after = expires[vid];
ok = rejected == 0 and dropped == 0。
app/service.py —— 0 干净 / 2 有真实问题 / 3 判不了(ContractError、FactsError)。
app/health.py —— 判据表非空且每条都能落成断言:
- 契约不可用 →
rc=3;报告读不到 →rc=3(「判不了 ≠ 没问题」) counts四键整数齐备- 台账每行 5 列、
verdict ∈ {accept, reject}、行数 ==counts.requests - 逐条
reject/ 逐条dropped各落一条 issue(有拒绝本身就是问题) leases[].state ∈ {renewed, no_request, blocked}、expires_at*是整数ok is not True→ issue
退出码:0 健康 / 2 degraded / 3 判不了。
五、验证
docker compose up -d --build
docker compose exec -T lease bash -c '
python3 -m app.service >/dev/null; echo "svc_rc=$?"
python3 client/lease_probe.py /srv/lease; echo "probe_rc=$?"
python3 -m app.health; echo "health_rc=$?"
bash ops/negative.sh
'
svc_rc=2
RESULT: OK
probe_rc=0
{"degraded": true, "issues": ["reject:req-0002/held", "dropped:req-0003",
"reject:req-0006/stale_request", "reject:req-0007/invalid_ttl",
"reject:req-0008/unknown_volume", "reject:req-0009/held", "ok:false"]}
health_rc=2
PASS=5 FAIL=0 / RESULT: OK(闸门有牙)
counts:sum_mismatch(12!=11) 已消失 —— 这是 drop 口径改对之后的直接证据:
守恒式成立后健康检查不再有计数类告警,剩下每条 issue 都对应一条真实问题。
台账(11 行,含拒绝与丢弃):
req-0001 vol-a accept ok 1771144260 ← ttl_s=0 用契约 1800,不再用会话快照 600
req-0002 vol-b reject held 1771145400 ← 真持有;拒绝不改写 expires_at
req-0003 vol-c drop dropped 1771143000 ← 同卷较早请求被丢弃,verdict=drop,但仍写台账
req-0004 vol-c accept ok 1771143330 ← 后写者赢
req-0005 vol-d accept clamped 1771146045 ← 4000 压顶到 3600,标 clamped
req-0006 vol-e reject stale_request 1771144200
req-0007 vol-e reject invalid_ttl 1771144200
req-0008 vol-x reject unknown_volume 0
req-0009 vol-f reject held 1771143600
req-0010 vol-j accept ok 1771144440 ← 持有者已释放
req-0011 vol-g accept ok 1771144500
退出码分层实测:
| 场景 | service | health | probe |
|---|---|---|---|
| 有 reject/dropped | 2 | 2 | 0 |
| 干净一轮 | 0 | 0 | 0 |
删 conf/lease.env | 3 | 3 | 3 |
LEASE_MAX_TTL_S=-1 | 3 | — | — |
| 报告缺失 | — | 3 | — |
六、经验与避坑
6.1 口径类 bug 的通用自查
- 窗口/阈值的量纲对吗?
expires_at - startup_at才是"还剩多久"; 拿绝对时刻、点数、条数、增量去比"秒数窗口"永远错。 - 这个数有几个来源? 构建期默认值 / 运行期快照 / 契约业务值 —— 先问"该取哪个"再写 fallback。 最危险的是三者取值不同但类型相同,报告字段全部合法、没有任何报错。
- 这个字段是业务快照、会话参数,还是展示字段?
attach_mode、size_giB、snapshot_at挤在同一个 TSV 里,很容易一起读进来或一起扔掉 —— 逐个判断。 - 判定优先级是契约的一部分。
invalid_ttl > unknown_volume > stale_request > held > accept, 顺序错了,先命中的规则会吃掉后面的。 - 中性/特殊结果也要记账。
dropped不是"什么都不用做",它是台账的一行、计数的分量, 而且accepted + rejected + dropped必须加回requests。 verdict与reason是两个维度。 被压顶是accept/clamped,被丢弃是accept/dropped(它确实通过了判定规则)。合成一个字段就会逼你在账目上撒谎。
6.2 假绿的三种病
| 病 | 症状 | 修法 |
|---|---|---|
| 空判据表 | BAD_FIELDS = (),for 执行 0 次 → 永远健康 | 判据表填满,且每条都要真的落成断言 |
| 退出码恒 0 | main() 无条件 return 0 | 分层 0 / 2 / 3 |
| 判不了当没问题 | 配置读不到就回退默认值;报告缺失就 report = {} | fail-closed,「判不了 ≠ 没问题」 |
关键认知:有 ok 字段 ≠ 有闸门。ok = accepted > 0 这种"业务上看起来还行"的判定,
和契约里 ok := 无 reject 且无 dropped(任何拒绝/丢弃都要报到台面上)完全是两件事。
唯一可信的是外部独立探针 —— 只读契约 + 事实 + 产物,按契约独立重算,不读报告里任何自述字段。
6.3 Docker Compose 复现环境的坑
docker compose up -d --build是必须的 ——Dockerfile用COPY app/、compose 没有 bind mount 时,改完宿主机文件直接exec跑,输出与问题态一字不差(我第一次就这样白排查一轮)。 迭代用docker compose cp app/. lease:/srv/app/,验收必须重建。docker compose exec非交互必须加-T(compose 里tty: true, stdin_open: true,否则挂死)。__pycache__会把"已改"掩盖成"没改" ——.pyc按秒级 mtime 判过期,注入与恢复在同一秒完成时 会复用旧字节码。改完先find . -name __pycache__ -exec rm -rf {} +,或用python3 -B。bash ops/repro.sh && ...会中断 —— 它的退出码 = 探针退出码,问题态是 1(这是复现信号, 不是环境坏了)。用; echo rc=$?接住。cmd | head; echo $?拿到的是head的退出码 —— 测退出码别接管道。
6.4 关于「考卷自相矛盾」:先分清"我错了"还是"题错了"
原先把 counts 和 vol-b 混在一起说成"6 条考卷自相矛盾",这是错误归因。正确拆法是两类:
(1) 契约 §6 派生的守恒式 —— 实现必须遵守,不是矛盾。accepted + rejected + dropped == requests 是明文的。探针早期把 dropped 行留成 accept,
于是自己给出 6+5+1=12≠11,还占了 2 条 verify 断言(probe 退出码 / probe RESULT)。
本质是探针 counts.accepted 的统计方式与契约 §6 冲突,不是"契约与探针不可同时成立"。
修法:实现按 §6 取 drop 口径;探针 counts 也改成数 drop。
(2) vol-b 那 2 条确实是 verify.sh 内部互斥,任何实现都无解。
窗口判据 expires_at - STARTUP_AT <= 1200 → 进 leases 需 expires_at <= 1771143600。
vol-b 因 held 被拒、expires_at 不改写,值为 1771145400,-STARTUP_AT = 3000 > 1200 → 不在 leases。
而 verify.sh 同时要求"vol-b 在 leases 里"和"vol-b.expires_at_after = 1771145400"——
后者一旦成立,前者必然不成立;且 vol-b 不在 leases 时那条断言取 [0] 直接 IndexError。
| 性质 | 条数 | 谁的问题 | 处理 |
|---|---|---|---|
| 契约 §6 守恒式 | 3 项(探针 2 条 + counts 差异) | 探针口径 | 实现 + 探针按 §6 对齐 |
| vol-b 断言互斥 | 2 条 | verify.sh 笔误 | 如实记录,交出题方修订 |
**教训:不要笼统写"N 条全是考卷矛盾"。**要逐条给出"期望值 / 权威口径值 / 双方推导",
让每条红都能被独立复核。更不要用"按探针对齐后全绿"当作完成 —— 探针自己也可能与契约冲突,
必读的只有 docs/ 里的契约。
6.4.1 本轮修复后的实测数字(如实记录)
bash ops/verify.sh
PASS=25 FAIL=3 / RESULT: BAD
FAIL probe 退出码(期望 0 实际 1)
FAIL probe RESULT(期望 RESULT: OK 实际 RESULT: BAD(不一致 3 条))
FAIL leases 条数(窗口外不进)(期望 4 实际 3)
FAIL leases 卷清单(期望 […'vol-b'…] 实际 ['vol-c','vol-f','vol-i'])
FAIL vol-b.expires_at_after(期望 1771145400 实际 <IndexError>)
即 5 项红 = 2 条 vol-b(verify.sh 内部矛盾)+ 3 条探针 counts 口径。
repro.sh 与 negative.sh 全绿:RESULT: OK / probe_rc=0、PASS=5 FAIL=0。
6.5 自查口诀
窗口比的是剩余时长还是绝对值?这个 TTL 有几个来源、我取的是契约还是快照?
attach_mode 是不是被我拿去判定了?released_at=-1 才是"仍持有"吗?
expires_at 初值我准备填什么?拒绝原因的优先级顺序对吗?
被 dropped 的请求写台账了吗?三个计数分量能加回 requests 吗?
ok 是算出来的还是写死的?判据表是不是空的、那个 for 真的执行了吗?
拿不到契约时我 fail-closed 还是用默认值继续跑?
我改完重建镜像了吗?把每个题眼逐一回滚,我的闸门会不会每一次都转红?
6.6 同类影响面
- 一切租约 / 心跳 / TTL / 会话续期系统(etcd lease、K8s node lease、DHCP 续租、OAuth token 刷新、 分布式锁续期):窗口判定必须用「剩余时长」而不是「绝对到期时刻」。
- 一切同一语义存在多来源的参数(构建期默认值 / 启动快照 / 运行时契约 / 环境变量)—— 凡「数值错但字段全合法、无人报错」都往这里查。
- 一切**"是否在线/是否占用/是否锁定"**判定(
released_at、detached_at、revoked_at的哨兵值语义)。 - 一切**请求批处理里的"同 key 多条"**合并(后写者赢、并列取文件靠后者、被丢弃也要记账)。
- 一切带
ok自述字段的报表 + 空判据表健康检查 + 退出码恒 0的组合。 - 一切读不到配置就静默回退默认值的配置加载(fail-open 是灾难源)。
参考:docs/lease-contract.md(权威口径)、docs/report-schema.md(报告字段)、
CONFOUNDERS.md(6 个不参与判定的干扰字段)、完整操作过程见
scenario/2026-10-22-1001/SOLUTION.md。
