feat(server): 放开未开始采集任务的取消,批量建单后可中途叫停 #297

Open
opened 2026-09-16 15:23:27 +08:00 by ila · 2 comments
Owner

来源:2026-09-16 讨论批量图搜上限时发现。用户原话:「如果去掉禁止,批量100个,中间要怎样停止图搜采集呢」。

问题

批量创建的采集任务一旦建出来就无法取消。删除只放行 failed:

// app/goauto/task/lifecycle_service.go:282
if record.Status == models.TaskStatusRunning {
    return serviceError(CodeTaskStateConflict, "执行中的任务不能删除")
}
if record.Status != models.TaskStatusFailed {
    return serviceError(CodeTaskStateConflict, "只允许删除失败任务")
}

于是批量建出 N 个 pending 任务后,中途发现搜错商品、AI 服务不可用或图源不对,唯一的办法是等它们逐个跑完。把设备下线也只是暂停——设备恢复后继续从队列领取。

实测图搜成功率并不高(2026-09-16 本地六次四成功),批量越大,中途需要叫停的概率越高。

方案

放开 pending 状态的取消,提供单条与批量两个入口,批量入口按条件筛选(来源、状态)。

[必须] running 仍然禁止取消。它正在设备上操作拼多多,中途打断后页面停在哪一步不可控,下一个任务的归位会受影响——今天已经因为「手机停在深层页面」踩过一次(#292)。所以语义是「停止后续,当前这个跑完」,不是「立刻停止」。

取消后的任务进入终态,不再被设备领取;是复用现有状态还是新增取消态,按既有状态机的一致性决定,不得让已取消的任务再被 claim。

非目标

  • 不取消 running 任务。
  • 不改删除 failed 任务的现有行为。
  • 不改采集规则、设备租约与任务领取逻辑。
  • 不调整图搜批量上限(另见依赖本单的工单)。

验收

  • pending 任务可以取消,取消后不再被任何设备领取。
  • running 任务仍然拒绝取消,错误可读。
  • 批量取消可按来源与状态限定范围,只影响范围内的任务。
  • 取消不影响同商品其它任务,也不影响已完成和已失败的任务。
  • 并发下不会把刚被领取的任务误取消(领取与取消互斥)。

验证

go test ./app/goauto/task/...;本地构造批量 pending 后取消并确认设备不再领取。

文档影响

新增取消接口与任务状态语义变化,需更新 docs/08-agent-api-contract.md 与状态机相关 Wiki 页面。

> 来源:2026-09-16 讨论批量图搜上限时发现。用户原话:「如果去掉禁止,批量100个,中间要怎样停止图搜采集呢」。 ## 问题 批量创建的采集任务一旦建出来就**无法取消**。删除只放行 `failed`: ```go // app/goauto/task/lifecycle_service.go:282 if record.Status == models.TaskStatusRunning { return serviceError(CodeTaskStateConflict, "执行中的任务不能删除") } if record.Status != models.TaskStatusFailed { return serviceError(CodeTaskStateConflict, "只允许删除失败任务") } ``` 于是批量建出 N 个 `pending` 任务后,中途发现搜错商品、AI 服务不可用或图源不对,唯一的办法是等它们逐个跑完。把设备下线也只是暂停——设备恢复后继续从队列领取。 实测图搜成功率并不高(2026-09-16 本地六次四成功),批量越大,中途需要叫停的概率越高。 ## 方案 放开 `pending` 状态的取消,提供单条与批量两个入口,批量入口按条件筛选(来源、状态)。 `[必须]` `running` 仍然禁止取消。它正在设备上操作拼多多,中途打断后页面停在哪一步不可控,下一个任务的归位会受影响——今天已经因为「手机停在深层页面」踩过一次(#292)。所以语义是「停止后续,当前这个跑完」,不是「立刻停止」。 取消后的任务进入终态,不再被设备领取;是复用现有状态还是新增取消态,按既有状态机的一致性决定,不得让已取消的任务再被 claim。 ## 非目标 - 不取消 `running` 任务。 - 不改删除 `failed` 任务的现有行为。 - 不改采集规则、设备租约与任务领取逻辑。 - 不调整图搜批量上限(另见依赖本单的工单)。 ## 验收 - [ ] `pending` 任务可以取消,取消后不再被任何设备领取。 - [ ] `running` 任务仍然拒绝取消,错误可读。 - [ ] 批量取消可按来源与状态限定范围,只影响范围内的任务。 - [ ] 取消不影响同商品其它任务,也不影响已完成和已失败的任务。 - [ ] 并发下不会把刚被领取的任务误取消(领取与取消互斥)。 ## 验证 `go test ./app/goauto/task/...`;本地构造批量 pending 后取消并确认设备不再领取。 ## 文档影响 新增取消接口与任务状态语义变化,需更新 `docs/08-agent-api-contract.md` 与状态机相关 Wiki 页面。
Author
Owner

方案确认:新增 cancelled 终态,不需要迁移

建单时留的问题(复用 failed 还是新增取消态)已定:新增 cancelled。

两条新查到的事实改变了这个决定的成本:

  1. 采购任务早就有这个终态——models/purchase.go:25 的 PurchaseTaskStatusCancelled = "cancelled"。采集侧新增不是发明新概念,是与既有约定对齐。
  2. 不需要数据库迁移——collection_task.status 是 varchar(24),cancelled 为 9 字符,放得下。建单时预估的"要加迁移"不成立。

因此选 cancelled 的代价远低于建单时的估计,而复用 failed 会把"采购员主动取消"和"设备执行失败"混成同一个值,日后拆分需要迁移历史数据。#294 刚证明过把两类不同的东西记成同一个值,排查时要付出代价。

并发方案

取消与领取用同一种条件更新模式,不得先读后写:

WHERE id = ? AND status = 'pending'   →  校验 RowsAffected

领取逻辑(task/service.go 约 213 行)本就是这个写法并校验 RowsAffected != 1。因此任务一旦被取消,领取的条件更新自然命中 0 行而安全失败;反之任务已被领取时取消也命中 0 行,返回状态冲突而非误取消。

范围补充

[必须] 需逐一排查 cancelled 对既有"终态/非终态"判定的影响:任务领取查询、重置的终态判定、删除的状态判定、设备忙碌判定。

不创建迁移文件。

状态

实施中。

## 方案确认:新增 `cancelled` 终态,**不需要迁移** 建单时留的问题(复用 `failed` 还是新增取消态)已定:**新增 `cancelled`**。 两条新查到的事实改变了这个决定的成本: 1. **采购任务早就有这个终态**——`models/purchase.go:25` 的 `PurchaseTaskStatusCancelled = "cancelled"`。采集侧新增不是发明新概念,是与既有约定对齐。 2. **不需要数据库迁移**——`collection_task.status` 是 `varchar(24)`,`cancelled` 为 9 字符,放得下。建单时预估的"要加迁移"不成立。 因此选 `cancelled` 的代价远低于建单时的估计,而复用 `failed` 会把"采购员主动取消"和"设备执行失败"混成同一个值,日后拆分需要迁移历史数据。#294 刚证明过把两类不同的东西记成同一个值,排查时要付出代价。 ## 并发方案 取消与领取用**同一种条件更新模式**,不得先读后写: ``` WHERE id = ? AND status = 'pending' → 校验 RowsAffected ``` 领取逻辑(`task/service.go` 约 213 行)本就是这个写法并校验 `RowsAffected != 1`。因此任务一旦被取消,领取的条件更新自然命中 0 行而安全失败;反之任务已被领取时取消也命中 0 行,返回状态冲突而非误取消。 ## 范围补充 `[必须]` 需逐一排查 `cancelled` 对既有"终态/非终态"判定的影响:任务领取查询、重置的终态判定、删除的状态判定、设备忙碌判定。 不创建迁移文件。 ## 状态 实施中。
Author
Owner

实施

提交 7c43dc6。由 sonnet 子代理实施,我独立复核并重跑验证。

建单与派单时两处事实判断被实施过程纠正

  1. 需要放开 CHECK 约束。我派单前只核了 varchar(24) 的长度就断言「不需要迁移」,漏查了 ck_collection_task_status——它只认旧五个状态,数据库会直接拒绝 cancelled。已按同文件 ensureMySQLDirectSelectConstraint 的手法在 migrations/migrate.go 补幂等 DROP/ADD(先查约束是否已含 cancelled,含则跳过)。不是新建迁移文件。
  2. 互斥条件必须带租约。我交接时给的是 WHERE id = ? AND status = 'pending',这是错的:Claim 领取任务时并不改 status,只写 device_id / lease_expires_at / claim_request_id。照此实现会把刚被领走、即将 start 的任务误取消。实施方由自写的并发用例 TestCancelAfterClaimFailsInsteadOfMiscancelling 抓到并修正为 WHERE id = ? AND status = 'pending' AND (lease_expires_at IS NULL OR lease_expires_at <= now),与 Claim 完全对齐。

另有连锁影响:cancelled 须并入 syncGuardSlots 终态分支清空 active_slot / device_run_slot,否则 ck_collection_task_active_slot 与 ck_collection_task_device_run_slot 会拒绝写入。

复核阶段补入

  • BatchCancelResponse.HasMore:超出单批上限时如实上报,不静默截断。采购员界面显示实时数量,截断而不告知会让「取消了 N 个」与眼前数字对不上。
  • 注释写明批量逐条更新、不包事务是刻意的:一条因并发领取而跳过,不应回滚已成功取消的其它任务。

已验证

gofmt / go vet / go build 干净;task / purchase / access / shopeeproduct 全绿;sybimport 仍为 12 条失败,与 #285 基线一致。新增 9 个用例,含并发误取消与上限上报。

未验证

未在真实环境构造「批量建单后中途取消」的端到端场景。

部署影响

[必须] 本次含 collection_task.status 的 CHECK 约束变更,随服务启动自动执行。上线需按高风险操作单独确认。

状态

待验收。

## 实施 提交 `7c43dc6`。由 sonnet 子代理实施,我独立复核并重跑验证。 ### 建单与派单时两处事实判断被实施过程纠正 1. **需要放开 CHECK 约束**。我派单前只核了 `varchar(24)` 的长度就断言「不需要迁移」,漏查了 `ck_collection_task_status`——它只认旧五个状态,数据库会直接拒绝 `cancelled`。已按同文件 `ensureMySQLDirectSelectConstraint` 的手法在 `migrations/migrate.go` 补幂等 DROP/ADD(先查约束是否已含 `cancelled`,含则跳过)。不是新建迁移文件。 2. **互斥条件必须带租约**。我交接时给的是 `WHERE id = ? AND status = 'pending'`,这是错的:`Claim` 领取任务时**并不改 status**,只写 `device_id` / `lease_expires_at` / `claim_request_id`。照此实现会把刚被领走、即将 start 的任务误取消。实施方由自写的并发用例 `TestCancelAfterClaimFailsInsteadOfMiscancelling` 抓到并修正为 `WHERE id = ? AND status = 'pending' AND (lease_expires_at IS NULL OR lease_expires_at <= now)`,与 Claim 完全对齐。 另有连锁影响:`cancelled` 须并入 `syncGuardSlots` 终态分支清空 `active_slot` / `device_run_slot`,否则 `ck_collection_task_active_slot` 与 `ck_collection_task_device_run_slot` 会拒绝写入。 ### 复核阶段补入 - `BatchCancelResponse.HasMore`:超出单批上限时如实上报,不静默截断。采购员界面显示实时数量,截断而不告知会让「取消了 N 个」与眼前数字对不上。 - 注释写明批量逐条更新、不包事务是刻意的:一条因并发领取而跳过,不应回滚已成功取消的其它任务。 ## 已验证 `gofmt` / `go vet` / `go build` 干净;`task` / `purchase` / `access` / `shopeeproduct` 全绿;`sybimport` 仍为 12 条失败,与 #285 基线一致。新增 9 个用例,含并发误取消与上限上报。 ## 未验证 未在真实环境构造「批量建单后中途取消」的端到端场景。 ## 部署影响 `[必须]` 本次含 `collection_task.status` 的 CHECK 约束变更,随服务启动自动执行。上线需按高风险操作单独确认。 ## 状态 待验收。
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OPC/goauto#297