作业调度器「同一作业只看到 submit、配额少剩 1 MiB、5 MiB 大作业整条消失」——批内去重 + 配额窗口取旧值 + 超限动作被 continue 丢弃
日期:2026-10-24 · 分支
2026-10-24-1000· 场景scenario/2026-10-24-1000/技术栈关键词:Python 3.11 纯标准库 / TSV 调度流水 / JSON 报告 / Docker Compose / 外部独立探针 / 状态机 / 配额窗口 / 交付批切分 / 健康检查假绿 / fail-closed 报错与现象关键词:RESULT: BAD(不一致 10 条)、counts.accepted 19 应为 18、pending 0 应为 1、per_tenant.t-alpha.checkpoint_bytes 期望 0 实际 1048576、remaining_bytes 期望 2097152 实际 1048576、batches.count 期望 6 实际 5、batches.max_bytes 期望 5242880 实际 3932160、job-302 FINISHED、job-303 UNKNOWN、degraded:false issues:[]但数据全错、退出码恒 0
一、问题背景
内网补丁导入作业调度器(impjob)每天读一份 TAB 分隔的动作流水 jobs/jobs.tsv(10 列),
按作业生命周期 submit → commit/abort → finish 逐条推进,每轮结束输出 outbox/report.json 给运维面板。
值班同事反馈三件事:
- 同一作业的动作丢了 —— 每个
job_id只看到submit,commit/abort/finish都不见了,作业永远停在STAGED; - 配额账算不准 ——
t-alpha上限 5 MiB、基线 1 MiB,面板显示已用 3 MiB 还剩 1 MiB(应为 2 MiB),被拦的取消作业计数里也没有; - 交付分批不对 —— 一个 5 MiB 的大作业(批上限 4 MiB)整条消失,
batches.max_bytes只有 3.75 MiB。
而 service / run / health 三个退出码全是 0,health 输出 {"degraded": false, "issues": []},counts 四个字段一个不缺。
四个绿灯没有一件指向真相;唯一说真话的是外部独立探针 client/impjob_probe.py:RESULT: BAD(不一致 10 条)。
这类「字段齐全、退出码 0、ok 自述为真」的组合,是本文最有价值的部分 —— 它比崩溃难查得多。
二、复现步骤
cd scenario/2026-10-24-1000
docker compose up -d --build
docker compose exec -T impjob bash # 非交互必须 -T
bash ops/repro.sh; echo "rc=$?" # 退出码 = 探针退出码,问题态 1 是复现信号,别用 &&
bash ops/verify.sh; echo "rc=$?" # 问题态 PASS=18 FAIL=18
问题态关键输出:
== 1) service 一轮 ==
{"degraded": false, "engine": "impjob", "issues": []}
service rc=0 # ← 假绿
== 2) run 一轮 ==
{"accepted": 19, "actions": 23, "pending": 0, "rejected": 4}
run rc=0 # ← 假绿
== 3) health ==
{"degraded": false, "engine": "impjob", "issues": []}
health rc=0 # ← 假绿
== 4) 外部独立探针 ==
RESULT: BAD(不一致 10 条)
- counts:期望 {'actions': 23, 'accepted': 18, 'rejected': 4, 'pending': 1}
实际 {'accepted': 19, 'actions': 23, 'pending': 0, 'rejected': 4}
- per_tenant.t-alpha.checkpoint_bytes:期望 0 实际 1048576
- per_tenant.t-alpha.remaining_bytes:期望 2097152 实际 1048576
- batches.count:期望 6 实际 5
- batches.max_bytes:期望 5242880 实际 3932160
- actions[16].verdict:期望 'pending' 实际 'accept'
宿主机没有 Docker 也能跑(纯标准库):
cd scenario/2026-10-24-1000
PYTHONPATH=. python3 -m app.run && python3 client/impjob_probe.py; echo "rc=$?"
bash ops/verify.sh
三、根本原因
根因 1(题眼 A)批次里对 job_id 去重 → 同一作业的后续动作全被压掉
拿到「同一作业只看到 submit」的第一反应都是去查输入解析。但 counts.actions 当时就是 23(正确) ——
ledger.load_actions() 逐行读、results.append() 逐行加,引擎从来没去重,行数一条不少。
真正丢的地方是交付批:
# app/engine.py::_plan_batches —— 问题态
if res["job_id"] not in cur["job_ids"]:
cur["job_ids"].append(res["job_id"])
同一个作业的 submit / commit 只进批一次,job_ids 也只有一份。而契约 §5.2 明写
「batches.items 里 job_ids 是动作的 job_id(同一作业多次动作会出现多次)」。
排查教训:先看
counts对不对,再决定往哪个方向查。「行数对不对」和「行内容对不对」是两个完全不同的问题。
根因 2(题眼 B)配额窗口按 sorted() 取第一个 → 把上一窗口的基线当成当前窗口
jobs/tenant_quota.tsv 里 t-alpha 有两个窗口:
t-alpha 1765512000 1765515600 5242880 1048576 ← 上一窗口,checkpoint=1 MiB
t-alpha 1765515600 1765519200 5242880 0 ← 当前窗口,checkpoint=0
# app/engine.py::_remaining —— 问题态
for (t, start), (end, limit, checkpoint) in sorted(idx.items()):
if t == tenant:
return limit - checkpoint # 返回了 window_start 最小的那个窗口
sorted() 是元组排序,(tenant, start) 里 start 小的排前面 → 拿到旧窗口的 checkpoint=1048576。
于是 remaining = 5242880 - 1048576 = 4194304:
- 5 MiB 的
job-401被quota_exceeded误拦 → 它不进批,batches.max_bytes只有 3.75 MiB; - 256 KiB 的
job-202也在余量不足时被误拦; - 而且
per_tenant.checkpoint_bytes报出 1048576(应 0),remaining报 1048576(应 2097152)。
契约 §3 与探针都按「当前窗口 = 该租户 window_start 最大的窗口」取,且
remaining = max(0, limit - checkpoint)(基线必须从上限里扣掉,但只能扣当前窗口的基线)。
排查教训:
sorted()+ 「取第一个匹配」几乎总在表达「我以为是唯一的那条键其实有多条」。 多窗口 / 多版本 / 多分区数据,必须显式定义「当前」是哪一个。
根因 3(题眼 C)超限动作被 continue 直接丢弃
# app/engine.py::_plan_batches —— 问题态
if size > cap:
continue # 5 MiB 的 job-401 就这样消失了
契约 §4:单条动作即使自身 size_bytes > BATCH_MAX_BYTES,也必须独占一个批独立交付,不能被丢弃。
删掉这个 continue 就行 —— 因为后面已有 cur["size_bytes"] + size > cap → 开新批,
超限动作自然落到一个空批里独占(job-401 = 5242880 → batch 5)。
根因 4(题眼 D)状态机缺失:finish 不验前置状态,非法 op 还写作业状态
elif op == "finish":
job_state[jid] = "FINISHED" # 无条件
...
if op not in OPS:
verdict, reason = "reject", "bad_op"
job_state[jid] = "UNKNOWN" # 非法 op 不该产生作业状态
job-302只有一行finish、没有submit、actual_checksum为空 → 应pending / invalid_transition,且jobs里不输出;job-303的op=patch是非法值 → 该行reject / bad_op,但不应产生作业状态(探针期望jobs里没有job-303)。
根因 5(假绿,最危险)健康检查只看字段「在不在」,退出码写死 0
# app/health.py —— 问题态
BAD_FIELDS = ("counts", "per_tenant", "jobs", "batches", "actions")
for field in BAD_FIELDS:
if report.get(field) in (None, "", [], {}):
issues.append(field)
return EXIT_OK, {"degraded": False, "issues": issues} # 永远 EXIT_OK
counts 里每个数字都错,字段也「在」→ issues 恒空 → degraded:false。
service.main() 同样无条件 return EXIT_OK。
degraded:false 只代表「没检查」,不代表「没问题」。
根因 6(假绿)契约不校验范围
contract.load_policy() 只做 int(raw[key]),batch_max_bytes=0、stale_after_s=-1、abc 都能过;
int() 抛的是 ValueError 而不是 ContractError,调用方的 except ContractError 直接漏接。
四、解决办法
只改 app/(docs/ client/ ops/ conf/ jobs/ input/ 是考卷与验收口径,改它们让判定变绿 = 造假)。
4.1 app/engine.py
def _current(idx, tenant):
"""当前窗口 = 该租户 window_start 最大的窗口。"""
keys = [k for k in idx if k[0] == tenant]
return max(keys, key=lambda k: k[1]) if keys else None
# 配额账本
entry = _window_entry(idx, t) # {limit_bytes, checkpoint_bytes}
remaining[t] = max(0, entry["limit_bytes"] - entry["checkpoint_bytes"])
状态机:
elif op == "commit":
if act["actual_checksum"]: # 校验和优先(契约 §2.1)
if act["actual_checksum"] != act["declared_checksum"]:
verdict, reason = "reject", "checksum_mismatch"
job_state[jid] = "REJECTED"
else:
job_state[jid] = "COMMITTED"
elif cur is None:
verdict, reason = "pending", "invalid_transition"
else:
job_state[jid] = "COMMITTED" # 非法转移但内容已到达 → done
reason = "invalid_transition"
elif op == "finish":
if cur in ("COMMITTED", "ABORTED"):
job_state[jid] = "FINISHED"
elif _has_content(act): # 有 actual_checksum → done
job_state[jid] = "FINISHED"
reason = "invalid_transition"
else:
verdict, reason = "pending", "invalid_transition"
交付批:
for res in results:
if res["verdict"] != "accept":
continue
size = size_of.get((res["job_id"], res["op"]), 0)
if cur is None or cur["size_bytes"] + size > cap:
cur = {"seq": len(items) + 1, "job_ids": [], "size_bytes": 0}
items.append(cur)
# 同一作业的多个动作各占一次(契约 §5.2:job_ids 可重复),不得去重
cur["job_ids"].append(res["job_id"])
cur["size_bytes"] += size
ok 按契约算,不写死:
"ok": (counts["rejected"] == 0 and counts["pending"] == 0
and all(v["remaining_bytes"] >= 0 for v in per_tenant.values())),
4.2 app/health.py(重写)
- 不 import
engine(复用被测代码 = 让 bug「一致地」复现,两边对上 → 判健康), 只用ledger+util.align_up按契约独立重算counts / per_tenant / jobs / batches / actions / ok,再逐字段对照; - 判据表非空,为空的显式报
no_checks; - 退出码分层:
0干净 /2有真问题(不一致 / 有 reject / 有 pending / 拿不到事实) /3契约不可用; - 输出里带
checks: N,让人一眼看见「到底查了几条」。
checks = len(_CHECKS)
if issues:
return EXIT_ISSUE, {"degraded": True, "checks": checks, "issues": issues}
return EXIT_OK, {"degraded": False, "checks": checks, "issues": []}
4.3 app/contract.py(fail-closed)
LIMITS = {"batch_max_bytes": 1, "stale_after_s": 0} # 下限(含)
try:
out[key] = caster(raw[key])
except (TypeError, ValueError):
raise ContractError("policy 非整数 %s=%r" % (key, raw[key]))
if out[key] < LIMITS[key]:
raise ContractError("policy 越界 %s=%r" % (key, out[key]))
4.4 app/service.py
rc, payload = health.check(policy, base)
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
return rc # 不再无条件 EXIT_OK
五、验证
cd scenario/2026-10-24-1000
docker compose up -d --build
docker compose exec -T impjob bash ops/verify.sh # PASS=36 FAIL=0 / RESULT: OK
docker compose exec -T impjob bash ops/repro.sh # 探针 RESULT: OK,rc=0
修复后报告关键数字:
ok: False
counts: {'actions': 23, 'accepted': 18, 'rejected': 4, 'pending': 1}
per_tenant.t-alpha : limit 5242880 / checkpoint 0 / used 3145728 / remaining 2097152
per_tenant.t-bravo : limit 1048576 / checkpoint 786432 / used 196608 / remaining 65536
per_tenant.t-charlie: limit 2097152 / checkpoint 2097152 / used 0 / remaining 0
jobs: 无 job-302 / job-303;job-402=FINISHED;job-301=REJECTED
batches: count 6, max_bytes 5242880
b1 [job-101, job-101] b2 [job-102, job-102, job-103, job-103]
b3 [job-104] b4 [job-104, job-201, job-201, job-202, job-203, job-203, job-301]
b5 [job-401] b6 [job-402, job-402, job-402]
actions[16] = job-302|finish|pending|invalid_transition|0
actions[17] = job-303|patch|reject|bad_op|0
health: {"checks": 18, "degraded": true, "issues": ["rejected=4", "pending=1"]} rc=2
service rc=2
ok: false + rc=2 就是正确答案 —— 有 reject、有 pending,报表就该报「不健康」。
要修的是判定口径,不是把灯弄绿。
契约 fail-closed 手工实测(conf/policy.tsv 逐个改坏,run / health / service):
| 场景 | rc |
|---|---|
| 文件被删 | 3 / 3 / 3 |
缺 stale_after_s | 3 / 3 / 3 |
batch_max_bytes=0 | 3 / 3 / 3 |
batch_max_bytes=abc | 3 / 3 / 3 |
stale_after_s=-1 | 3 / 3 / 3 |
变异自测(改实现 → 重建镜像 → 跑 verify),证明判定有牙:
| 变异 | verify |
|---|---|
批内去重恢复 if job_id not in cur["job_ids"] | PASS=31 FAIL=5(E3/E4/E5/E7 全红)✅ |
判据表挖空(_add() 立刻 return) | health → {"checks": 0, "issues": ["no_checks", ...]} rc=2 ✅ |
abort 不做块对齐 | ❌ 没转红,该数据集不可观测(见下) |
六、经验与避坑
6.1 三个「值错但字段合法」的隐形 bug
sorted()+ 取第一个匹配 —— 表达的是「我以为这条键唯一」。多窗口 / 多版本 / 多分区数据, 必须显式定义「当前是哪一个」(这里是window_start最大)。continue丢弃超限元素 —— 分批 / 分页 / Top-N 里,单条超出上限的元素必须独占一批,不能丢。if size > cap: continue这种写法看起来「安全」,其实是在静默丢数据。- 批内对
job_id去重 —— 「一个作业多个动作」是状态机推进,不是重复行。 去重条件写错,前一层的行数统计(counts.actions=23)是对,后一层的产物(job_ids/batches)全错。
6.2 假绿的三种病(比崩溃危险)
| 病 | 表现 | 治法 |
|---|---|---|
| 判据表只看「字段在不在」 | 值全错,issues:[] | 按契约独立重算再逐字段比对 |
判据表为空 / for 不执行 | degraded:false,还挺快 | 判据表为空必须显式报 no_checks,并输出 checks: N |
退出码写死 return 0 | 算出问题也返回 0 | 分层 0 干净 / 2 有真问题 / 3 判不了 |
再加一条:健康检查不能 import 被测代码。复用引擎等于让 bug「一致地」复现,两边对上 → 判健康。
本题 health.py 只用 ledger + util.align_up 自己重算。
6.3 「修好了 bug」≠「修好了闸门」
ops/negative.sh 是按「当前是问题态」写的:它注入问题态行为并期望 verify 返回 1。
修好之后 verify 返回 0,所以 N2/N3/N4 必然 FAIL —— 这不是实现有问题,是闸门的期望值过期了。
不要为了让闸门变绿去改实现或改考卷,改成手工变异(改实现 → 重建 → 跑 verify)自证判定有效。
同时要接受一个事实:有些 bug 在这份数据上不可观测。
本题所有 abort 行的 size_bytes 恰好都是 65536 的整数倍,「块对齐」改不改结果一样 ——
PASS 依然 36/36。这种情况如实记录为未解决,比假装「已覆盖」诚实得多。
6.4 Docker 场景踩坑
Dockerfile是COPY app/、compose 无 bind mount —— 改完源码必须docker compose up -d --build, 否则容器里跑的还是老代码(「改完毫无变化」几乎都是这个)。docker compose exec非交互必须-T,否则the input device is not a TTY。ops/repro.sh退出码 = 探针退出码,问题态1是复现信号,用; echo rc=$?接住,别用&&。- 退出码 2 会让整条 shell 命令「失败」 —— 修好后
service rc=2是正确结果, 但链式命令会被判失败,把多步塞进一个脚本跑完再tail截断。 - 不要用
grep -c "<模式>"反查变异是否落地 —— 无匹配时grep返回 1,整条命令 exit 1, 会让你误判「文件没改」。用assert 子串 in s或直接sed -n看行。 - 落盘目录不是「内容到达」的证据 —— 本题 12 个 workdir 每个都有
content.bin(连只submit的作业也有), 拿目录非空判断「已到达」会把只finish的作业误判成完成。可用证据只有契约里写的那一项(actual_checksum)。 契约说什么是证据,就只有那一个是证据。
6.5 自查口诀
行数对吗?还是行内容错?——
counts对了就别往解析层查。 「当前」是怎么定义的?sorted()取第一个是不是在赌唯一? 分批 / 分页 / Top-N 里超限的那一条,我是丢掉了还是让它独占一批? 「一个实体多条记录」是重复数据还是状态推进?(去重前先问这句) 状态机校验了吗?非法 op 会污染作业状态吗? 判据表是不是空的、那个for真的执行了吗?checks有多少条? 「判不了」和「没问题」是同一个退出码吗? 健康检查是不是在拿被测代码的输出校验它自己? 契约读不到 / 值越界时,我是 fail-closed 还是带着默认值继续跑? 把每个题眼逐一回滚,我的闸门会不会每一次都转红?注入真的落地了吗? 我改完重建镜像了吗?
6.6 同类影响面
- 一切按作业 / 任务 / 订单生命周期推进的调度器(
submit→commit→finish、create→pay→ship、pending→running→done):多动作共享状态、job_id会重复出现,任何set()/not in去重都会吞动作。 - 一切多窗口 / 多周期配额(日配额、月配额、滚动窗口、K8s
ResourceQuota、云账单 budget): 「当前窗口」必须显式定义,checkpoint/ 已用基线必须只从当前窗口扣。 - 一切分批 / 分页 / 限流打包(
max_bytes、max_items、LIMIT/OFFSET、Kafkabatch.size): 单条超限元素的处理是必答题(独占一批 vs 丢弃),不是边界情况。 - 一切带自述
ok字段的报表 + 空判据表健康检查 + 退出码恒 0 + 配置读不到回退默认值的组合 —— 这类「四个绿灯」比崩溃危险得多,因为它静默地骗人。 - 一切用字符串
replace/sed实现「回滚缺陷」的负向测试闸门(手工版 mutation testing): 注入不生效时闸门看着是绿的,必须验证补丁真的落地了;反过来,实现修好之后闸门期望值也需要更新。
可复现命令(TL;DR)
git checkout 2026-10-24-1000
cd scenario/2026-10-24-1000
docker compose up -d --build
docker compose exec -T impjob bash ops/repro.sh # 探针 RESULT: OK
docker compose exec -T impjob bash ops/verify.sh # PASS=36 FAIL=0 / RESULT: OK
cat SOLUTION.md # 完整改动点与踩坑记录
