#38 归档记录了打回一次的经过(ParseSpec 两个失败分支无测试覆盖, 其中 size=="" 就是真实数据里 2 行走的分支),以及架构角色第一轮 变异打在死分支上、差点误报「测试没牙」的过程。 改正:/usr/local/go 从 2026-07-02 起一直是 1.26.5,而六份归档都写着 「架构角色亲自执行(Go 1.23.0)」——那是照项目固定版本抄的, 没有实际验证过工具链,违反 CLAUDE.md §9「说验证过必须真的跑过」。 措辞改为不宣称具体版本。已用 GOTOOLCHAIN=go1.23.0 补验,结论不变。 admin/AGENTS.md 加两条 [必须]: - 交付前至少跑一次带 GOTOOLCHAIN=go1.23.0 的验证。开发机装的可能更新, Go 会默默用它编译,测过的不是要交付的那个版本。新加依赖时尤其要跑。 - 改数据库时不要只测全新库,先列出现实中存在哪些 schema 状态再一个个验(来自 #20)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.6 KiB
24 Admin 商品卡在「采集中」无法恢复
- 类型:缺陷(状态机死锁)
- 父级大工单:#14
- 所属 MVP / 版本:#15 / MVP
- 关联:#18(缺陷所在页面)、#16(
collect_status的引入) - 状态:验收通过
- 日期:2026-08-07
- Gitea 工单:#24
背景与目标
在 PDD 商品页点「创建采集任务」之后,商品进入 collecting,
再也出不来:
第 1 次点 已创建 1 个采集任务,等待客户端领取
第 2 次点 已创建 0 个采集任务(跳过 1 个采集中或已删除的)
弹窗「重新采集」 同上
商品状态: collecting ← 永远卡在这里
根因:MarkCollecting 只在 pending / failed 时成功,而全库只有四个地方
会改 collect_status,其中把 collecting 改走的两个
(SetCollectResult / SetCollectFailed)只有客户端提交结果才会触发。
客户端离线、崩溃、任务被删都是常态,不是异常。每发生一次就永久废掉一个商品,
只能改数据库救。而当时 Client 的 claim_next 还没实现,等于每点一次就废一个。
提示「跳过 1 个采集中或已删除的」对操作员毫无信息量: 到底是在采、被删了、还是卡死了,看不出来,也不知道该等还是该做点什么。
最终方案
超时在读取那一刻现算,不开后台协程
UPDATE pdd_products
SET collect_status = 'collecting', updated_at = ?
WHERE goods_id = ? AND deleted_at IS NULL
AND ( collect_status IN ('pending', 'failed')
OR (collect_status = 'collecting' AND updated_at < ?) )
[必须] 仍是同一条原子 UPDATE,靠影响行数判断是否抢到。
加超时分支最容易顺手把原子抢占破坏掉。
[必须] 不写后台协程定期把超时的改回 pending。 后台扫要处理
「扫到一半客户端正好提交了」的竞态;现算没有调度、没有并发窗口。
本项目已经在并发上栽过两次(PRAGMA 没作用到连接池、BEGIN DEFERRED 死锁),
能不引入并发就不引入。
阈值 15 分钟,写成有名字的常量
// CollectStaleAfter 是采集任务多久没动静就当它死了。
//
// 采集本身几十秒到两分钟;加上排队等客户端来领,15 分钟足够宽裕。
// 宁可短也不要长:采集是**只读**操作,多采一次没有任何副作用,
// 而卡死的代价是这个商品永久报废、只能改数据库救。
const CollectStaleAfter = 15 * time.Minute
字符串比时间依赖 NowISO 的格式
[必须] updated_at < ? 用的是字符串比较,只有在定宽 + 统一 UTC + 补零
(2026-08-07T06:10:19Z)时字典序才等于时间序。
已在 NowISO 上加注释说明有代码依赖这个性质——改成带时区偏移的本地时间
会让这个判断静默失效:不报错,但判断全错。
已知取舍:可能采两次
超时后允许重新建任务,但老任务还在队列里,客户端上线后可能把两个都领走。 可接受:采集是只读的,后一次结果覆盖前一次,数据仍然正确。
[必须] 这条取舍写进了代码注释和 docs/admin/03-data-model.md §4.1,
并明写「不要看到"可能采两次"就加锁或加租约去"修"它——租约和心跳是
被明确移除过的设计」。
为什么不加「手工取消采集」按钮
超时自动回退已经覆盖了这个场景,而且不需要操作员判断—— 他并不知道客户端是不是还活着、该不该取消。多一个按钮就多一处要维护、 多一处可能被误点。
界面两处
列表和弹窗:超时的显示「采集中(超时)」,不是「采集中」。 操作员盯着「采集中」不知道它已经死了,会一直等。
筛选下拉不新增「已超时」:它不是数据库里真实存在的 collect_status 取值,
只是「采集中」在读取那一刻的一种呈现,加进去会让下拉取值和 collect_status 对不上。
跳过提示按原因分类:
改前 已创建 0 个采集任务(跳过 1 个采集中或已删除的)
改后 没有创建任何任务:1 个正在采集中(还需等待约 15 分钟才可重试)
与建单方案的差异
- 常量必须导出(
model.CollectStaleAfter)——repository和service都要用,Go 不允许跨包访问未导出名。工单里的小写名是示意。 CreatePddCollectTasks签名改成返回结构体,用来携带分类计数。- 多加一个「已采集的」跳过桶。工单示例只列了「正在采集的」「已删除的」两类,
原先已
collected的行会被含糊地并进同一桶,单独标注更准确。 - 「采集中(超时)」也用到详情弹窗。工单只要求列表页;不这样做会出现 列表说超时、弹窗说采集中,自相矛盾。
admin/templates/pdd/list.html未改动——文案通过已有的{{.StatusText}}绑定自动生效,比工单预计的改动更小。
改了哪些
admin/model/model.go:CollectStaleAfter常量;NowISO加注释。admin/repository/pdd.go:MarkCollecting增加超时分支(仍是单条原子 UPDATE)。admin/service/pdd.go:statusTextFor/isCollectingStale(列表与详情共用);CollectTaskResult分类计数;提示文案组装。admin/handler/web/pdd.go:改调FormatCollectTaskMessage。admin/service/pdd_page_test.go:超时、边界、并发、分类计数、文案共 8 个新用例。docs/admin/03-data-model.md§4.1、05-ui-specification.md§5.2/§5.5。
验收结果
| 验收标准 | 结果 |
|---|---|
collecting 且超过 15 分钟 → 能重新创建 |
通过 |
collecting 但没到 15 分钟 → 仍然跳过 |
通过 |
pending / failed → 照常能建 |
通过 |
| 已软删除的 → 仍然跳过 | 通过 |
| 阈值是有名字的常量并写清理由 | 通过 |
| 多个请求同时对同一「已超时」商品建任务,只产生 1 条 | 通过 |
| 列表页超时显示「采集中(超时)」 | 通过 |
| 状态有文字区分,不只靠颜色 | 通过 |
| 筛选下拉未新增「已超时」 | 通过(仍是 5 项) |
| 跳过原因分类计数 | 通过 |
| 一个都没建成时告诉操作员还要等多久 | 通过 |
NowISO 有格式依赖的注释 |
通过 |
| 「可能采两次」写进代码注释和文档 | 通过 |
| 没有后台协程、没有租约或心跳 | 通过(生产代码零 go func) |
go vet / gofmt / go test 全过 |
通过 |
测试
架构角色亲自执行(admin/ 目录):
go vet ./... 无输出
gofmt -l . 无输出
go test ./... -count=1 ok repository / ok service
go test ./service/ -run '并发' -count=20 ok(稳定)
变异测试(架构角色补做,工单未要求)
| 变异 | 结果 |
|---|---|
去掉超时限制(collecting 一律允许重建) |
4 个用例变红 |
| 去掉超时分支(退回卡死行为) | 2 个变红 |
破坏原子性判断(n == 1 → n >= 0) |
5 个变红 |
比较方向反了(< → >) |
5 个变红 |
第三个是重点——加超时分支最容易把原子抢占破坏掉,测试能抓到说明有牙。
端到端
① 第一次点 已创建 1 个采集任务,等待客户端领取 列表: 采集中
② 立刻再点 没有创建任何任务:1 个正在采集中
(还需等待约 15 分钟才可重试) 列表: 采集中
③ updated_at 往前调 20 分钟 列表: 采集中(超时)
弹窗: 采集中(超时)
④ 再点 已创建 1 个采集任务,等待客户端领取
⑤ 任务表 2 条(已知取舍:老任务还在队列里)
筛选下拉仍是 5 项,页面里「已超时」出现 0 次。
未验证到的部分:
- 15 分钟的精确边界未单独测。测的是 20 分钟(超时)和 5 分钟(未超时)。
当前是严格
<,即「正好卡在 15 分钟那一刻」算未超时。 - Windows 环境未实测(本机 WSL/Linux)。
- 未与真实 Client 联调确认「超时后的第二个任务被领走、其结果正确覆盖第一个」—— 工单验证步骤明确说不需要真机和客户端。
遗留问题
- 15 分钟边界的精确行为未单独测试。
- 「可能采两次」在真实客户端上线后的表现未验证。
相关提交
e40ce5afix: 商品卡在「采集中」无法恢复 (#24)