feat: claim 支持领取无主任务 (#17)
PDD 商品页要能「创建采集任务」而不指定客户端——采集只是浏览商品页,
没有副作用,哪台设备采都一样。但原来的查询是
WHERE assigned_client = ? AND status = 'assigned'
无主任务(assigned_client 为空 + pending)永远没人能领,建出来就是死的。
改动
- ClaimNextTask 同时查两种:指定给本机的 + 无主的
- 排序 ORDER BY (assigned_client IS NULL), priority DESC, created_at
——指定给本机的优先。显式分配是人为决定,应当先兑现
- 原子更新两种情况合成一条语句:对"指定给我的"写 assigned_client
是写同一个值无副作用;对无主的,这一步就是"谁领到就标记谁"
- 表结构不用动(assigned_client 本来可空,status 已有 pending)
推翻了一条已定案的规则
Client 契约 §5.1 原写「Admin 只把任务分配给指定的 Client」,
现改为两种并存并说明各自适用场景:
- 采集任务不指定客户端
- 采购任务可指定可留空。涉及钱和账号——不同设备可能登着不同的
拼多多账号,需要指定账号时必须显式分配,留空即接受"谁先抢到谁下单"
Client 侧对两种没有区别,不需要知道任务原来有没有主。
已验证(Go 1.23.0)
- 新增 7 个测试,全量 62 个全过
- 并发抢占用例重复 20 次稳定:8 个客户端抢同一条无主任务,
正好 1 个拿到,且 assigned_client 记的就是那个赢家
- 既有测试未受影响,"只领分配给自己的"仍然成立
一处仍未解决的风险(已记入 #17 风险表)
tasks 表没有字段标记"该任务需要真实下单",所以契约里
"不向 dry_run 客户端分配真实下单任务"实际无法执行。
真实下单开关关闭时不出问题,开启前必须补该字段。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,21 @@ const claimCandidateLimit = 10
|
||||
//
|
||||
// 没有可领的任务时返回 (nil, nil) —— 调用方据此返回 204。
|
||||
//
|
||||
// 防并发的做法是**条件更新 + 检查影响行数**:先查出候选,
|
||||
// 再用 `WHERE task_id = ? AND status = 'assigned'` 去更新,
|
||||
// # 两种任务都能领
|
||||
//
|
||||
// 指定给本机的 assigned_client = 我 且 status = 'assigned'
|
||||
// 无主的 assigned_client 为空 且 status = 'pending'
|
||||
//
|
||||
// 采集任务创建时不指定客户端(浏览商品页没有副作用,哪台设备采都一样),
|
||||
// 所以必须支持第二种,否则那些任务永远没人能领。
|
||||
// 采购任务可以指定也可以留空——涉及钱和账号时应当显式分配。
|
||||
//
|
||||
// **指定给本机的优先。** 显式分配是人为决定,应当先兑现;
|
||||
// 无主任务谁抢都一样,可以等。
|
||||
//
|
||||
// # 防并发
|
||||
//
|
||||
// 做法是**条件更新 + 检查影响行数**:先查出候选,再带着状态条件去更新,
|
||||
// 影响行数为 0 就说明被别人抢先了,换下一条。
|
||||
// 不用 SELECT ... FOR UPDATE,SQLite 没有那个。
|
||||
func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model.Task, error) {
|
||||
@@ -26,7 +39,8 @@ func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model
|
||||
}
|
||||
|
||||
query := `SELECT task_id FROM tasks
|
||||
WHERE assigned_client = ? AND status = 'assigned'`
|
||||
WHERE ( (assigned_client = ? AND status = 'assigned')
|
||||
OR (assigned_client IS NULL AND status = 'pending') )`
|
||||
args := []any{clientID}
|
||||
|
||||
// 客户端只声明支持某些类型时,不要给它别的类型
|
||||
@@ -37,7 +51,8 @@ func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model
|
||||
args = append(args, t)
|
||||
}
|
||||
}
|
||||
query += ` ORDER BY priority DESC, created_at LIMIT ?`
|
||||
// (assigned_client IS NULL) 为 0/1,0 排前面 —— 指定给本机的优先于无主的
|
||||
query += ` ORDER BY (assigned_client IS NULL), priority DESC, created_at LIMIT ?`
|
||||
args = append(args, claimCandidateLimit)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
@@ -60,10 +75,16 @@ func ClaimNextTask(db *sql.DB, clientID string, supportedTypes []string) (*model
|
||||
|
||||
now := model.NowISO()
|
||||
for _, taskID := range candidates {
|
||||
// 两种情况合成一条语句:对"指定给我的"那种,写 assigned_client
|
||||
// 是写同一个值,无副作用;对无主的,这一步就是"谁领到就标记谁"。
|
||||
res, err := db.Exec(`
|
||||
UPDATE tasks SET status = 'claimed', claimed_at = ?, updated_at = ?
|
||||
WHERE task_id = ? AND status = 'assigned'`,
|
||||
now, now, taskID)
|
||||
UPDATE tasks
|
||||
SET status = 'claimed', assigned_client = ?,
|
||||
claimed_at = ?, updated_at = ?
|
||||
WHERE task_id = ?
|
||||
AND ( (status = 'assigned' AND assigned_client = ?)
|
||||
OR (status = 'pending' AND assigned_client IS NULL) )`,
|
||||
clientID, now, now, taskID, clientID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("领取任务 %s 失败: %w", taskID, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user