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

卷租约续约器「续约窗口里四个绿灯,卷却没人续」:拿增量冒充剩余时长、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 续约只推到 17711430601771144260(少 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_S600不是业务快照
引擎会话 TTLusage/startup_snapshot.txt 的 engine_ttl_s600只用于记录
契约业务值conf/lease.env 的 LEASE_DEFAULT_TTL_S1800唯一权威
# 问题态:读了会话快照
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

退出码分层实测:

场景servicehealthprobe
有 reject/dropped220
干净一轮000
删 conf/lease.env333
LEASE_MAX_TTL_S=-13——
报告缺失—3—

六、经验与避坑

6.1 口径类 bug 的通用自查

  1. 窗口/阈值的量纲对吗? expires_at - startup_at 才是"还剩多久"; 拿绝对时刻、点数、条数、增量去比"秒数窗口"永远错。
  2. 这个数有几个来源? 构建期默认值 / 运行期快照 / 契约业务值 —— 先问"该取哪个"再写 fallback。 最危险的是三者取值不同但类型相同,报告字段全部合法、没有任何报错。
  3. 这个字段是业务快照、会话参数,还是展示字段? attach_mode、size_giB、snapshot_at 挤在同一个 TSV 里,很容易一起读进来或一起扔掉 —— 逐个判断。
  4. 判定优先级是契约的一部分。 invalid_ttl > unknown_volume > stale_request > held > accept, 顺序错了,先命中的规则会吃掉后面的。
  5. 中性/特殊结果也要记账。 dropped 不是"什么都不用做",它是台账的一行、计数的分量, 而且 accepted + rejected + dropped 必须加回 requests。
  6. verdict 与 reason 是两个维度。 被压顶是 accept/clamped,被丢弃是 accept/dropped (它确实通过了判定规则)。合成一个字段就会逼你在账目上撒谎。

6.2 假绿的三种病

病症状修法
空判据表BAD_FIELDS = (),for 执行 0 次 → 永远健康判据表填满,且每条都要真的落成断言
退出码恒 0main() 无条件 return 0分层 0 / 2 / 3
判不了当没问题配置读不到就回退默认值;报告缺失就 report = {}fail-closed,「判不了 ≠ 没问题」

关键认知:有 ok 字段 ≠ 有闸门。ok = accepted > 0 这种"业务上看起来还行"的判定, 和契约里 ok := 无 reject 且无 dropped(任何拒绝/丢弃都要报到台面上)完全是两件事。 唯一可信的是外部独立探针 —— 只读契约 + 事实 + 产物,按契约独立重算,不读报告里任何自述字段。

6.3 Docker Compose 复现环境的坑

  1. docker compose up -d --build 是必须的 —— Dockerfile 用 COPY app/、compose 没有 bind mount 时,改完宿主机文件直接 exec 跑,输出与问题态一字不差(我第一次就这样白排查一轮)。 迭代用 docker compose cp app/. lease:/srv/app/,验收必须重建。
  2. docker compose exec 非交互必须加 -T(compose 里 tty: true, stdin_open: true,否则挂死)。
  3. __pycache__ 会把"已改"掩盖成"没改" —— .pyc 按秒级 mtime 判过期,注入与恢复在同一秒完成时 会复用旧字节码。改完先 find . -name __pycache__ -exec rm -rf {} +,或用 python3 -B。
  4. bash ops/repro.sh && ... 会中断 —— 它的退出码 = 探针退出码,问题态是 1(这是复现信号, 不是环境坏了)。用 ; echo rc=$? 接住。
  5. 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。

版权声明

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

分享这篇文章