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

已删除的清单照样被物化 + 容量/空闲口径读错,而自述报告/健康检查/退出码全绿(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.pyload_config() 不接收契约,返回写死的 LOCAL_DEFAULTS(/srv/state/work、180、max_parallel=2),字段名还与契约不同名root/lease_s/max 逐字不等于契约;容量口径也错
④app/planner.pystale 判定读 local["disabled_policy"],而它来自写死的 "keep"契约的 block 被压掉,stale 被放行物化
⑤app/health.pyBAD_FIELDS = () 空判据表 + main() 无条件 return EXIT_OKissues 恒空、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 退出码分层实测

场景期望实测
干净一轮00
契约不可用(CONF_DIR=/tmp/nope)33
拿不到报告22
自述被篡改(counts.total=999)22
契约口径非法(SCHED_DISABLED_POLICY=loose)33

六、经验与避坑

核心认知

  1. 「并集」和「集合」是两个词。 契约写 A ∪ B − C 时,把三份来源直接 + 起来 得到的是 A ∪ B ∪ C —— 差集那一步必须有对应的代码。 自检口诀:契约里的每个 −,在代码里对得上哪一行?
  2. generation 这类「代数/版本/序号」字段是持有者的凭据,不是调度器的输出。 凡是「报告里的某字段必须等于上游登记的那个」,正确实现就是原样透传。 自检口诀:这个号是别人给的,还是我自己编的?_next_generation() 第二次调用返回几?
  3. 口径必须从契约读,不能有「本地默认值兜底」。 兜底值在正常时看不出问题,一旦契约改了,服务会继续按旧值静默运行 —— 「猜出来的口径等于没有口径」。读不到就 fail closed(拒绝启动/拒绝执行), 把「配置缺失」变成部署即暴露,而不是线上静默。
  4. 容量/超时/期限这类数字,先问「它属于谁」。max_parallel(单份并行度)≠ SCHED_MAX_INFLIGHT(一套集合的上限); SCHED_MATERIALIZE_IDLE_EXIT_S(空闲退出)≠ SCHED_HOLD_MAX_S(占用过期)。 「两个都是数字、名字都像」是最容易读错口径的地方。
  5. 空判据表 = 没有检查。 BAD_FIELDS = () 时 for 一次都不循环, 代码看着像有检查,其实是空转。判据必须有内容,而且必须是 外部能自己算一遍的事实(契约 + 输入事实 + 报告), 不能是「自己的字段比自己」。
  6. 「判不了」≠「没问题」。 契约读不到、报告缺失,要判不通过(fail closed)。 退出码要分层(这里是 0 干净 / 2 有真问题或拿不到事实 / 3 契约不可用), 让上层能区分「跑得好」和「没跑成」。
  7. 自查字段 ≠ 闸门。 root_matches_contract / lease_matches_contract 早就算出来了、问题态老实写着 false,但没人看它们 —— 那只是装饰。 而且 lease_matches_contract 自己还比错了对象(拿 hold_max_s 比 idle_s)。 自检口诀:这个字段算出来之后,有 if 读它吗?
  8. 只修题眼不够。 不修健康检查,下次同类错误依然四个绿灯全亮 —— 「修好了 bug」只解决了这一次,「修好了闸门」才解决了下一次。

踩坑清单

  1. && 会误判复现结果。 ops/repro.sh 问题态退出码是 1(探针的), bash ops/repro.sh && ... 后半段根本不执行,会以为是环境坏了。 正确姿势:bash ops/repro.sh; echo "rc=$?"。
  2. ops/negative.sh 会覆盖 app/。 脚本内部反复 apply_reference, 收尾处又 apply_reference 一次 —— 跑完 app/ 里就变成参考解了。 跑完必须把自己的修复重新拷回 app/ 并 md5sum -c 校验, 否则会把「参考解」当成「自己的修复」提交。
  3. 改 host 侧 app/ 不生效。 compose 是 build 时 COPY app/ 进镜像, 没有 bind mount。改完要 docker compose up -d --build 重建; 或改完 docker cp 进容器再清 __pycache__。
  4. __pycache__ 会掩盖改动。 切换版本后务必清字节码 (ops/lib.sh::clean_pyc() 就是干这个的),否则可能跑的是旧字节码。
  5. 新容器里第一次跑 health 可能 rc=2。 因为 state/schedule.json 还没生成 —— 这是 fail-closed 的正确行为(拿不到事实 = 判不通过), 不是 bug。先 python3 -m app.scheduler 跑一轮再查健康即可。
  6. 非交互环境 docker compose exec 要加 -T,否则报 the input device is not a TTY。
  7. 验证完记得 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 应用并发上限、maxSurge vs replicas、 令牌桶速率 vs 突发容量、idleTimeout vs readTimeout。
  • 一切**「自述字段自证 + 空判据表 + 退出码恒 0」的假绿健康检查与巡检脚本** —— 这类问题比没有检查更危险,因为它静默地骗人。

自查口诀

契约里的每个 − 在代码里对得上吗?这个 generation/etag 是我编的还是别人给的? 我的配置有没有本地默认值在兜底?这个数字属于「单份」还是「整套」? idle_exit 和 hold_max 是同一个东西吗?判据表是不是空的?判据是事实还是自述? 拿不到数据时我判通过还是不通过?我的闸门有没有被「挖空判据表 + 退出码恒 0」实证过是假绿?

版权声明

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

分享这篇文章