已删除的清单照样被物化 + 容量/空闲口径读错,而自述报告/健康检查/退出码全绿(Python 集合运算漏减移除项 · 契约口径 vs 本地默认值 · 空判据表假绿)
日期:2026-10-14 分支:
2026-10-14-1600第124题 Issue #141 技术栈:Python 3.11 标准库 / Docker Compose / 契约驱动判定 / 集合运算(并集与差集) 关键词:集合运算漏减移除项、enabled ∪ added − removed、已删除的照样被物化、stale清单未按策略挡住、disabled_policy读本地默认值、容量口径(一套集合上限 vs 单份并行度)、 空闲退出 vs 占用过期张冠李戴、占用代数(generation)自编号、SCHED_MAX_INFLIGHT、SCHED_MATERIALIZE_IDLE_EXIT_S、SCHED_HOLD_MAX_S、BAD_FIELDS = ()空判据表、 健康检查假绿、退出码恒 0、fail closed、root_matches_contract、外部独立探针
一、问题背景
srv/app 是配置清单物化调度器:一轮里读「启用清单」+ 本轮动作,
算出该物化哪些清单、哪些必须先挡住,然后出一份回执报告。
契约 conf/scheduler.env(只读考卷)规定:
SCHED_ENABLED_MANIFEST=/srv/conf/enabled.txt
SCHED_MATERIALIZE_ROOT=/srv/work
SCHED_MATERIALIZE_IDLE_EXIT_S=300 # 空闲多久就退出(不是占用过期)
SCHED_MAX_INFLIGHT=11 # 一套集合可容纳的份数上限
SCHED_HOLD_MAX_S=3600 # 一份占用的持有时长上限
SCHED_DISABLED_POLICY=block # stale 必须先挡住
输入事实(conf/enabled.txt 里 m-removed 已被移除却仍在启用清单里):
启用清单:m-alpha / m-beta / m-removed
本轮动作:{"op":"add","name":"m-gamma"}、{"op":"remove","name":"m-removed"}
本轮占用:{"manifest":"m-beta","release_after_s":3600,"generation":41}
契约 docs/scheduler-contract.md:
集合 = 启用清单 ∪ 本次新增 − 本次移除 (被移除的清单不再属于集合)
现象:一轮跑下来,自述报告 ok: true、健康检查 {"degraded": false, "issues": []}、
脚本退出码 0 —— 看着一切正常。但:
- 已经被移除的清单照样出现在物化计划里(
m-removed还在被物化); - 占用代数被换成了调度器自己编的号(登记
41,报告写1); - 容量读成了「单份并行度 2」,而契约的
11是「一套集合的上限」; - 空闲退出读成了
180(本地默认值),契约是300; - 物化根目录读成
/srv/state/work,契约是/srv/work。
$ bash ops/repro.sh
{"counts": {"block": 0, "materialize": 4, "total": 4}, "inflight": 1,
"lease_s": 180, "max": 2, "ok": true, "root": "/srv/state/work"}
scheduler rc=0
健康检查:{"degraded": false, "engine": "materialize-scheduler", "issues": []} health rc=0
只有外部独立探针说真话 client/scheduler_probe.py
(只读契约 + 启用清单 + 动作 + 占用 + 报告,不读任何自述字段):
不一致:集合构成:期望 ['m-alpha', 'm-beta', 'm-gamma'] 实际 ['m-alpha', 'm-beta', 'm-removed', 'm-gamma']
不一致:m-beta:期望占用代数 41 实际 1
不一致:root:期望 '/srv/work' 实际 '/srv/state/work'
不一致:lease_s:期望 300 实际 180
不一致:max:期望 11 实际 2
不一致:汇总 counts.total:期望 3 实际 4
不一致:汇总 counts.materialize:期望 3 实际 4
RESULT: BAD(不一致 7 条) rc=1
四个互相掩护的缺陷 + 一层假绿
| # | 位置 | 缺陷 | 后果 |
|---|---|---|---|
| ① | app/manifests.py::ManifestSet.all() | 直接 list(enabled) + list(added) + list(removed) —— 得到的是并集,漏了「− 移除」 | 已删除的清单照样被物化,集合份数虚高 |
| ② | app/hold.py::normalize() | generation 走 _next_generation() 自增编号,与持有者登记的值无关 | 占用对不上号,外部无法确认是同一次占用 |
| ③ | app/config.py::load_config() + app/planner.py | load_config() 不接收契约,返回写死的 LOCAL_DEFAULTS(/srv/state/work、180、max_parallel=2),字段名还与契约不同名 | root/lease_s/max 逐字不等于契约;容量口径也错 |
| ④ | app/planner.py | stale 判定读 local["disabled_policy"],而它来自写死的 "keep" | 契约的 block 被压掉,stale 被放行物化 |
| ⑤ | app/health.py | BAD_FIELDS = () 空判据表 + main() 无条件 return EXIT_OK | issues 恒空、degraded 恒 false,连读不到报告都判健康 → 假绿 |
附带一处隐蔽口径错:app/scheduler.py 里
"lease_matches_contract": short["idle_s"] == conf.get("SCHED_HOLD_MAX_S")
空闲退出被拿去和占用上限比 —— 「空闲退出」和「占用过期」是两件事,张冠李戴。
这个字段早就算出来了、问题态老实写着 false,但没有任何人看它 ——
「有字段」≠「有闸门」。
二、复现步骤
cd scenario/2026-10-14-1600
docker compose up -d --build
docker compose exec -T scheduler bash
容器内:
cd /srv
bash ops/repro.sh ; echo "rc=$?" # -> RESULT: BAD(不一致 7 条),rc=1(预期的复现信号)
bash ops/verify.sh ; echo "rc=$?" # -> PASS=6 FAIL=19 / RESULT: BAD
python3 client/scheduler_probe.py # -> RESULT: BAD,rc=1
坑:
ops/repro.sh以探针退出码为自身退出码,问题态返回1。 这是预期的复现信号,不是环境坏了。用; echo "rc=$?"接住,用&&会中断后续步骤。
问题态验收输出节选:
== A 组:根目录 / 空闲退出必须逐字等于契约(不许本地兜底) ==
[FAIL] A1 root:期望 /srv/work 实际 /srv/state/work
[FAIL] A2 lease_s:期望 300 实际 180
== B 组:容量口径是「一套集合的上限」,不是逐个清单的并行度 ==
[FAIL] B1 max:期望 11 实际 2
== C 组:集合构成 = 启用清单 ∪ 新增 − 移除(题眼) ==
[FAIL] C1 集合构成:期望 m-alpha,m-beta,m-gamma 实际 m-alpha,m-beta,m-removed,m-gamma
== D 组:stale 必须按禁用策略挡住(题眼) ==
[FAIL] D1 m-removed 不在计划里:期望 <missing> 实际 materialize
== E 组:占用代数必须与持有者登记的一致(题眼) ==
[FAIL] E2 m-beta 占用代数:期望 41 实际 1
== G 组:健康检查必须有牙(判不了 != 没问题) ==
[FAIL] G2 契约不可用 health 退出码:期望 3 实际 0
[FAIL] G3 拿不到报告 health 退出码:期望 2 实际 0
PASS=6 FAIL=19
三、根本原因
1. 集合被写成了并集(题眼)
# 问题态
for name in list(self.enabled) + list(self.added) + list(self.removed):
契约定义的是集合(⋃ − ⋂ 里的差集),代码算的是并集。
m-removed 明明被本次动作移除了,却因为「三份来源都拼进去」而留在结果里 ——
已删除的东西照样被物化,且 counts.total 从 3 虚高到 4。
2. 占用代数自己编号(题眼)
# 问题态
def normalize(hold):
return {"release_after_s": int(hold["release_after_s"]),
"generation": _next_generation()} # 自增计数器
_counter = {"n": 0}
def _next_generation():
_counter["n"] += 1
return _counter["n"]
generation 是占用令牌的代数,语义是「代数不同 = 不是同一个占用」。
调度器的职责是原样登记持有者给的令牌,不是自己另编一个号 ——
自己编号会让外部无法确认报告里的这个占用就是登记的那个。
更隐蔽的是 _next_generation() 每调一次 +1:第一次是 1、第二次是 2,
结果取决于调用次数,一个纯记账函数变成了有状态的。
3. 配置口径自成一派(题眼)
# 问题态
LOCAL_DEFAULTS = {"output_root": "/srv/state/work", "lease_seconds": 180,
"max_parallel": 2, "disabled_policy": "keep"}
def load_config():
return dict(LOCAL_DEFAULTS) # 不接收契约、不校验、不覆盖
三个问题叠在一起:
- 值不对:
/srv/state/work≠/srv/work,180≠300,2≠11; - 名不对名:
output_root/lease_seconds/max_parallel与契约字段各叫各的; - 语义读错:
max_parallel是「单份清单能并行几次」,契约的SCHED_MAX_INFLIGHT是「一整套集合能装几份」——「单份并行度」和「一套集合的上限」根本不是一回事;lease_seconds被当成空闲退出,而契约明写它不是占用过期。
4. stale 策略读错来源(题眼)
# 问题态
if local["disabled_policy"] == "block": # local 来自写死的 "keep"
判定代码本身写得没错(== "block" 是对的),
但「策略从哪读」错了 —— 契约的 block 谁也没读,
于是契约要求「必须先挡住」的 stale 清单被放行物化。
5. 假绿:空判据表 + 退出码恒 0
# 问题态
BAD_FIELDS = () # 空元组
for key in BAD_FIELDS: # 一次都不循环
if not report.get(key):
issues.append(...)
except (OSError, json.JSONDecodeError):
print(...); return EXIT_OK # 读不到报告也算健康
return EXIT_OK # 无条件
BAD_FIELDS = ()是空判据表:for循环体一次都不执行 →issues恒空 →degraded恒false。代码看起来像有检查,其实在空转;- 判据只读自述字段(自己的字段比自己)→ 无判别力;
main()无条件返回 0,连「拿不到事实」都判健康 → 「判不了」被当成「没问题」。
这叫「给自己发体检合格证」。问题态它照样全绿,只有外部探针说真话。
四、解决办法
只改 app/ 下 7 个文件;conf/、docs/、client/、input/、
ops/verify.sh、ops/negative.sh 一字未动(改考卷/探针让判定变绿 = 造假)。
① 集合:先「并」再「减」
def all(self):
removed = set(self.removed)
out = []
for name in list(self.enabled) + list(self.added):
if name in removed:
continue
if name not in out:
out.append(name)
return out
② 占用代数:原样登记,删掉自编号
def normalize(hold):
if not hold:
return None
return {"release_after_s": int(hold["release_after_s"]),
"generation": int(hold["generation"])} # 原样登记
_counter / _next_generation() 整个删掉。
③ 配置由契约解析(fail closed,不做本地兜底)
def load_config(conf):
if not conf.get("contract_available"):
raise RuntimeError("契约不可用:拒绝在猜出来的口径下物化")
return {
"output_root": conf["materialize_root"],
"idle_exit_s": conf["materialize_idle_exit_s"], # 空闲退出
"max_per_set": conf["max_inflight"], # 一套集合的上限
"disabled_policy": conf["disabled_policy"],
}
④ planner:策略来自契约 + 容量越界整轮失败
local = config.load_config(conf)
names = mset.all()
over_capacity = len(names) > local["max_per_set"]
...
if name in mset.stale_removed() and local["disabled_policy"] == "block":
row.update(verdict="block", reason="disabled_stale"); rows.append(row); continue
...
if over_capacity: # 容量是「一套集合」的上限 → 整轮失败
for row in rows:
row.update(verdict="block", reason="inflight_over_capacity")
⑤ scheduler:口径对齐 + fail closed
local = config.load_config(conf) # 契约不可用 → RuntimeError
...
"lease_matches_contract": short["idle_s"] == conf["materialize_idle_exit_s"], # 不是 hold_max_s
...
def main():
try:
report = run()
except RuntimeError as exc:
print(json.dumps({"contract_available": False, "ok": False, "error": str(exc)},
ensure_ascii=False, sort_keys=True))
return EXIT_CONTRACT # 3
⑥ contract:口径非法也 fail closed
SCHED_DISABLED_POLICY 不在 ("keep", "block") 内 → contract_available=False;
数值字段缺失/非法、max_inflight <= 0 同理。不静默回退默认值。
⑦ health:判据表有料 + 独立重算 + 退出码分层
BAD_FIELDS换成 8 条可被外部按契约独立复核的事实;- 内部另写一份
_reference(),故意不复用planner.py(复用 = 自己治自己, 判据退化成自述字段的自证); - 逐条独立重算:集合构成 / 逐条裁决 / 占用代数 /
root/lease_s/max/counts/ok; - 退出码分层:0 干净一轮 / 2 有真问题或拿不到事实 / 3 契约不可用; 「判不了」= 判不通过(fail closed)。
五、验证
5.1 一键复现(修复态)
docker compose exec -T scheduler bash -c 'cd /srv && bash ops/repro.sh; echo "rc=$?"'
{"counts": {"block": 0, "materialize": 3, "total": 3}, "inflight": 1,
"lease_s": 300, "max": 11, "ok": true, "root": "/srv/work"}
scheduler rc=0
健康检查:{"block": 0, "contract_available": true, "degraded": false, "issues": [],
"lease_s": 300, "max_inflight": 11, "root": "/srv/work", "set_size": 3,
"stale": ["m-removed"], "total": 3}
health rc=0
清单集合(按契约):['m-alpha', 'm-beta', 'm-gamma']
RESULT: OK
probe rc=0
repro rc=0
7 条不一致全部消失;health 的输出里多了 contract_available / max_inflight /
set_size / stale 等可被外部复核的事实字段。
5.2 验收脚本:A~H 八组 25 条
PASS=25 FAIL=0
RESULT: OK
(--restore 可切回问题态:PASS=6 FAIL=19 / RESULT: BAD,证明判定脚本有判别力。)
5.3 负向验证:证明闸门「真的有牙」
docker compose exec -T scheduler bash -c 'cd /srv && bash ops/negative.sh'
== N1:被移除的重新留在集合里 → 探针 rc=1(转红)、health rc=2
== N2:占用代数改成自编号 → 探针 rc=1(转红)
== N3:容量按「集合 × 并行度」 → 探针 rc=1(转红)
== N4:root 回退本地默认值 → 探针 rc=1(转红)
== N5:自述被篡改 counts.total=999 → health rc=2(有牙)
== N6:判据表挖空 + 退出码恒 0 → 对坏数据仍 rc=0(实证假绿)
== N7:契约不可用 → health rc=3、scheduler rc=3
== N8:契约口径非法(loose) → health rc=3
负向验证:13 PASS / 0 FAIL
RESULT: OK(闸门有牙)
5.4 退出码分层实测
| 场景 | 期望 | 实测 |
|---|---|---|
| 干净一轮 | 0 | 0 |
契约不可用(CONF_DIR=/tmp/nope) | 3 | 3 |
| 拿不到报告 | 2 | 2 |
自述被篡改(counts.total=999) | 2 | 2 |
契约口径非法(SCHED_DISABLED_POLICY=loose) | 3 | 3 |
六、经验与避坑
核心认知
- 「并集」和「集合」是两个词。 契约写
A ∪ B − C时,把三份来源直接+起来 得到的是A ∪ B ∪ C—— 差集那一步必须有对应的代码。 自检口诀:契约里的每个−,在代码里对得上哪一行? generation这类「代数/版本/序号」字段是持有者的凭据,不是调度器的输出。 凡是「报告里的某字段必须等于上游登记的那个」,正确实现就是原样透传。 自检口诀:这个号是别人给的,还是我自己编的?_next_generation()第二次调用返回几?- 口径必须从契约读,不能有「本地默认值兜底」。 兜底值在正常时看不出问题,一旦契约改了,服务会继续按旧值静默运行 —— 「猜出来的口径等于没有口径」。读不到就 fail closed(拒绝启动/拒绝执行), 把「配置缺失」变成部署即暴露,而不是线上静默。
- 容量/超时/期限这类数字,先问「它属于谁」。
max_parallel(单份并行度)≠SCHED_MAX_INFLIGHT(一套集合的上限);SCHED_MATERIALIZE_IDLE_EXIT_S(空闲退出)≠SCHED_HOLD_MAX_S(占用过期)。 「两个都是数字、名字都像」是最容易读错口径的地方。 - 空判据表 = 没有检查。
BAD_FIELDS = ()时for一次都不循环, 代码看着像有检查,其实是空转。判据必须有内容,而且必须是 外部能自己算一遍的事实(契约 + 输入事实 + 报告), 不能是「自己的字段比自己」。 - 「判不了」≠「没问题」。 契约读不到、报告缺失,要判不通过(fail closed)。 退出码要分层(这里是 0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用), 让上层能区分「跑得好」和「没跑成」。
- 自查字段 ≠ 闸门。
root_matches_contract/lease_matches_contract早就算出来了、问题态老实写着false,但没人看它们 —— 那只是装饰。 而且lease_matches_contract自己还比错了对象(拿hold_max_s比idle_s)。 自检口诀:这个字段算出来之后,有if读它吗? - 只修题眼不够。 不修健康检查,下次同类错误依然四个绿灯全亮 —— 「修好了 bug」只解决了这一次,「修好了闸门」才解决了下一次。
踩坑清单
&&会误判复现结果。ops/repro.sh问题态退出码是 1(探针的),bash ops/repro.sh && ...后半段根本不执行,会以为是环境坏了。 正确姿势:bash ops/repro.sh; echo "rc=$?"。ops/negative.sh会覆盖app/。 脚本内部反复apply_reference, 收尾处又apply_reference一次 —— 跑完app/里就变成参考解了。 跑完必须把自己的修复重新拷回app/并md5sum -c校验, 否则会把「参考解」当成「自己的修复」提交。- 改 host 侧
app/不生效。 compose 是build时COPY app/进镜像, 没有 bind mount。改完要docker compose up -d --build重建; 或改完docker cp进容器再清__pycache__。 __pycache__会掩盖改动。 切换版本后务必清字节码 (ops/lib.sh::clean_pyc()就是干这个的),否则可能跑的是旧字节码。- 新容器里第一次跑
health可能 rc=2。 因为state/schedule.json还没生成 —— 这是 fail-closed 的正确行为(拿不到事实 = 判不通过), 不是 bug。先python3 -m app.scheduler跑一轮再查健康即可。 - 非交互环境
docker compose exec要加-T,否则报the input device is not a TTY。 - 验证完记得
docker compose down -v清理,state/schedule.json是运行期产物(已 gitignore),别提交。
同类影响面
- 一切集合/权限/配额做「增删」运算的场景:RBAC 角色
授权 ∪ 新增 − 回收、 防火墙规则集allow ∪ add − remove、标签选择器、DNS 记录集增量同步、 配置中心的enabled + added − removed、K8s finalizer/labels 增量 patch —— 凡是「减」被漏掉,就会留下本应消失的实体,且通常不报错。 - 一切**「状态托管在别处、自己只做透传」的字段**:
generation/etag/resourceVersion/revision/lease_id/term(Raft)/fencing token—— 自己编号 = 破坏「同一性判定」。 - 一切**「契约/配置 vs 代码默认值双轨」**的加载:
os.environ.get(k, DEFAULT)、config.get(k) or fallback、LOCAL_DEFAULTS—— 兜底值会让契约形同虚设; 应该 fail closed。 - 一切**「单份 vs 整套」的容量/额度口径**:线程池大小 vs 任务队列上限、
连接池 max vs 应用并发上限、
maxSurgevsreplicas、 令牌桶速率 vs 突发容量、idleTimeoutvsreadTimeout。 - 一切**「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本** —— 这类问题比没有检查更危险,因为它静默地骗人。
自查口诀
契约里的每个 − 在代码里对得上吗?这个 generation/etag 是我编的还是别人给的?
我的配置有没有本地默认值在兜底?这个数字属于「单份」还是「整套」?
idle_exit 和 hold_max 是同一个东西吗?判据表是不是空的?判据是事实还是自述?
拿不到数据时我判通过还是不通过?我的闸门有没有被「挖空判据表 + 退出码恒 0」实证过是假绿?
