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

作业调度器「同一作业只看到 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 给运维面板。

值班同事反馈三件事:

  1. 同一作业的动作丢了 —— 每个 job_id 只看到 submit,commit / abort / finish 都不见了,作业永远停在 STAGED;
  2. 配额账算不准 —— t-alpha 上限 5 MiB、基线 1 MiB,面板显示已用 3 MiB 还剩 1 MiB(应为 2 MiB),被拦的取消作业计数里也没有;
  3. 交付分批不对 —— 一个 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_s3 / 3 / 3
batch_max_bytes=03 / 3 / 3
batch_max_bytes=abc3 / 3 / 3
stale_after_s=-13 / 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

  1. sorted() + 取第一个匹配 —— 表达的是「我以为这条键唯一」。多窗口 / 多版本 / 多分区数据, 必须显式定义「当前是哪一个」(这里是 window_start 最大)。
  2. continue 丢弃超限元素 —— 分批 / 分页 / Top-N 里,单条超出上限的元素必须独占一批,不能丢。 if size > cap: continue 这种写法看起来「安全」,其实是在静默丢数据。
  3. 批内对 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 场景踩坑

  1. Dockerfile 是 COPY app/、compose 无 bind mount —— 改完源码必须 docker compose up -d --build, 否则容器里跑的还是老代码(「改完毫无变化」几乎都是这个)。
  2. docker compose exec 非交互必须 -T,否则 the input device is not a TTY。
  3. ops/repro.sh 退出码 = 探针退出码,问题态 1 是复现信号,用 ; echo rc=$? 接住,别用 &&。
  4. 退出码 2 会让整条 shell 命令「失败」 —— 修好后 service rc=2 是正确结果, 但链式命令会被判失败,把多步塞进一个脚本跑完再 tail 截断。
  5. 不要用 grep -c "<模式>" 反查变异是否落地 —— 无匹配时 grep 返回 1,整条命令 exit 1, 会让你误判「文件没改」。用 assert 子串 in s 或直接 sed -n 看行。
  6. 落盘目录不是「内容到达」的证据 —— 本题 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、Kafka batch.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                                      # 完整改动点与踩坑记录

版权声明

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

分享这篇文章