fix: 商品卡在「采集中」无法恢复 (#24)
MarkCollecting 原本只在 pending/failed 时成功,而全库只有客户端提交结果 才会把 collecting 改走。客户端离线、崩溃、任务被删都是常态——一旦发生, 这个商品就永久报废,界面上没有任何入口能救,只能改数据库。 改成 collecting 超过 15 分钟视为已超时,允许重新创建采集任务。 判定在读取那一刻现算,仍是同一条原子 UPDATE,不加后台清理协程: 后台扫要处理"扫到一半客户端正好提交了"的竞态,本项目已经在并发上 栽过两次,能不引入并发就不引入。 15 分钟写成有名字的常量并注明理由:采集本身几十秒到两分钟,加上排队 等客户端来领。宁可短也不要长——采集是只读的,多采一次没有副作用, 而卡死的代价是商品永久报废。 已知取舍:超时后老任务还在队列里,客户端上线可能两个都领走、采两次。 可接受(只读,后一次覆盖前一次),已写进代码注释和 03-data-model.md, 免得后来人当成 bug 去"修"成加锁或加租约——租约和心跳是被明确移除的设计。 时间比较用字符串,依赖 NowISO 产出定宽 UTC。已在 NowISO 上加注释: 改成带时区偏移的本地时间会让这个比较静默失效,不报错但判断全错。 界面两处:超时的显示「采集中(超时)」,否则操作员盯着「采集中」 不知道它已经死了;跳过原因按正在采集/已采集/已删除分类计数, 一个都没建成时告诉操作员还要等多久。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -299,6 +299,56 @@ CREATE INDEX idx_pdd_products_status ON pdd_products(collect_status);
|
||||
所以存 `artifact_ref`(形如 `client-001:artifacts/PDD-0001/attempt-xxx/`),
|
||||
告诉操作员去哪台机器的哪个目录捞。
|
||||
|
||||
**采集超时:`collecting` 怎么才不会永久卡死**
|
||||
|
||||
客户端离线、崩溃、任务被删——这些都是常态,不是异常。原来的规则是
|
||||
"只有 `pending` / `failed` 才允许建采集任务",而**只有客户端提交结果**才会
|
||||
把状态从 `collecting` 改走。于是上面任何一种情况发生后,这个商品的
|
||||
`collect_status` 就永远卡在 `collecting`,界面上没有任何入口能救回来,
|
||||
只能改数据库(见 #24)。
|
||||
|
||||
修复:`collecting` 状态超过 `model.CollectStaleAfter`(15 分钟)**也允许**
|
||||
重新建采集任务:
|
||||
|
||||
```sql
|
||||
-- repository.MarkCollecting,简化版
|
||||
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 < ?) )
|
||||
```
|
||||
|
||||
`[必须]` 15 分钟为什么是这个数:采集本身几十秒到两分钟,加上排队等客户端来领,
|
||||
15 分钟足够宽裕。宁可短也不要长——采集是**只读**操作,多采一次没有任何副作用,
|
||||
而卡死的代价是这个商品永久报废、只能改数据库救。定义成常量 `model.CollectStaleAfter`,
|
||||
不要把 15 分钟当魔数散落在各处判断里。
|
||||
|
||||
`[必须]` 超时判定在**读取的这一刻现算**(拼进上面这条 UPDATE 的 WHERE 里),
|
||||
**不是**后台协程定期扫描把超时的状态改回 `pending`。本项目已经在并发上栽过两次
|
||||
(PRAGMA 没作用到连接池、`BEGIN DEFERRED` 死锁),能不引入并发就不引入;
|
||||
现算没有调度、没有窗口期,天然不会有"扫到一半客户端正好提交了结果"这种竞态。
|
||||
同理,**没有引入租约(lease)或心跳**——那是被明确移除的设计,
|
||||
见 [Client 术语表](../client/00-glossary.md)「为什么没有租约和心跳」一节。
|
||||
|
||||
`[必须]` 时间比较用**字符串比较** `updated_at < ?`,不解析成 `time.Time` 再比。
|
||||
这依赖 `model.NowISO()` 永远产出定宽 UTC 格式(如 `2026-08-07T06:10:19Z`):
|
||||
定宽 + 同一时区 + 补零,字符串的字典序才等于时间先后顺序。
|
||||
谁把它改成带时区偏移的本地时间(比如 `+08:00`),这个比较会**静默失效**
|
||||
——不会报错,但超时判断全错,见 `model.NowISO` 的注释。
|
||||
|
||||
**已知取舍:可能采两次**
|
||||
|
||||
超时后允许重新建任务,但**老任务还留在队列里**,客户端上线后可能把新旧两个
|
||||
任务都领走,同一个商品被采两次。这是可以接受的:
|
||||
|
||||
- 采集是只读操作,没有任何副作用;
|
||||
- 后一次的结果覆盖前一次(`SetCollectResult` 按 `goods_id` 整体覆盖),数据仍然正确。
|
||||
|
||||
`[必须]` **不要看到"可能采两次"就去加锁或加租约"修"它**——那正是被明确移除的设计,
|
||||
加回来会重新引入本项目已经吃过两次亏的并发复杂度,而这里换来的收益(避免极少数情况下
|
||||
多采一次)远小于代价。
|
||||
|
||||
### 4.2 `skus_json` 的结构
|
||||
|
||||
由 Client 采集后原样提交,Admin **不做转换**:
|
||||
|
||||
Reference in New Issue
Block a user