task.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. // Package task 统一 DB 任务表 + 工作池(替代旧两套队列 t_queue + t_agent_queue,架构规格 §3.7)。
  2. //
  3. // 设计要点(与 ADR-015 单机双实例对齐):
  4. // - 单状态机:pending → running → done / dead(双实例共享 t_task,靠乐观锁认领防重复消费);
  5. // - 认领:UPDATE ... SET status='running', owner=?, claimed_at=? WHERE id=? AND status='pending'
  6. // (RowsAffected==1 才算认领成功——双实例并发认领同一任务时只有一个成功,另一个 RowsAffected==0 跳过);
  7. // - 重试:失败按指数退避 next_run_at = now + 2^retry × 基数(1s→2s→4s→…→上限 1h),
  8. // retry 超过 TaskCfg.MaxRetry 置 dead(进告警 + 人工介入);
  9. // - 幂等键:type + 业务唯一键(如 "ai_grading:{campus_id}:{homework_id}")——
  10. // 派发侧写入前查重,重投/重复请求不产生重复任务;
  11. // - **派发成功 ≠ 任务完成**(AI 批改/分析/导出/PDF):前端轮询任务终态或业务表,
  12. // Agent 不可达只记日志不阻断主链路(fire-and-forget 语义,重活靠本表重试)。
  13. //
  14. // 典型使用:
  15. //
  16. // // 派发(业务域,事务内或事务后)
  17. // t := task.New{Type: "ai_grading", CampusID: 3, Payload: payload, IdempotentKey: fmt.Sprintf("ai_grading:%d:%d", campusID, hwID)}
  18. // if err := repo.Enqueue(ctx, t); err != nil { ... } // 幂等键冲突时静默跳过(已存在等价任务)
  19. //
  20. // // 工作池消费(cmd/api 启动的长驻 goroutine)
  21. // pool := task.NewPool(db, cfg.Task, redisClient, dispatcher)
  22. // go pool.Run(ctx) // ctx 取消时排空在途任务后退出(优雅停机,配合 GracePeriodSec)
  23. package task
  24. import (
  25. "context"
  26. "encoding/json"
  27. "fmt"
  28. "strings"
  29. "time"
  30. "gorm.io/gorm"
  31. )
  32. // Status 任务状态机(t_task.status 列取值,单状态机——禁止新增中间态,
  33. // 需要细分进度时在 payload 内表达,不改列枚举)。
  34. const (
  35. StatusPending = "pending" // 已入队,待认领
  36. StatusRunning = "running" // 已被某实例认领,执行中
  37. StatusDone = "done" // 执行成功(终态)
  38. StatusDead = "dead" // 重试超限放弃(终态,进告警)
  39. )
  40. // Task 统一任务表 t_task 的领域模型(列与 DDL 一一对应,见 unified/02-data-model)。
  41. //
  42. // 字段:
  43. // - ID int64:主键自增(仅内部排序用;对外暴露 idempotentKey,不暴露自增 ID)。
  44. // - Type string:任务类型("ai_grading"/"export_report"/"pdf_render"…;
  45. // 新类型需在注册表注册 handler,未注册类型消费时直接置 dead + 告警)。
  46. // - CampusID int64:任务归属校区(0=平台级任务;多租户作用域过滤用)。
  47. // - Payload string:业务载荷 JSON(字符串直存,避免 GORM 序列化自定义类型;
  48. // 消费侧反序列化失败 → 置 dead + 告警,不重试——载荷损坏重试也不会好)。
  49. // - Status string:Status* 之一。
  50. // - IdempotentKey string:幂等键(type + 业务唯一键;UNIQUE 约束,防重)。
  51. // - Retry int:已重试次数(每次失败 +1;超过 MaxRetry 置 dead)。
  52. // - NextRunAt time.Time:下次可执行时刻(指数退避由 Fail 计算;pending 且 NextRunAt 未到不可被认领)。
  53. // - Owner string:认领实例标识("instance-a"/"instance-b",ADR-015 双实例审计用;
  54. // 实例崩溃后靠 claimed_at 超时回收,见 reclaimStale)。
  55. // - TraceID string:贯穿 traceId(派发侧从请求 context 取;消费侧日志串联)。
  56. // - ClaimedAt time.Time:认领时刻(stale 回收依据:running 且 now - claimed_at > 超时阈值 → 可被重新认领)。
  57. // - CreatedAt time.Time:入队时刻。
  58. type Task struct {
  59. ID int64 `gorm:"column:id"`
  60. Type string `gorm:"column:type"`
  61. CampusID int64 `gorm:"column:campus_id"`
  62. Payload string `gorm:"column:payload"`
  63. Status string `gorm:"column:status"`
  64. IdempotentKey string `gorm:"column:idempotent_key"`
  65. Retry int `gorm:"column:retry"`
  66. NextRunAt time.Time `gorm:"column:next_run_at"`
  67. Owner string `gorm:"column:owner"`
  68. TraceID string `gorm:"column:trace_id"`
  69. ClaimedAt time.Time `gorm:"column:claimed_at"`
  70. CreatedAt time.Time `gorm:"column:created_at"`
  71. }
  72. // New 待入队任务构造器(业务域派发侧使用;只填业务字段,状态/时间戳由 Enqueue 填充)。
  73. type New struct {
  74. Type string // 任务类型(必填,注册表内)
  75. CampusID int64 // 校区(0=平台级)
  76. Payload any // 业务载荷(任意可 JSON 序列化结构)
  77. IdempotentKey string // 幂等键(必填;形如 "ai_grading:{campus_id}:{homework_id}")
  78. TraceID string // 贯穿 traceId(可空,空则消费侧重新生成)
  79. }
  80. // Repo 任务表仓储(GORM 实现;独立于业务域 Repository——task 域是横切域,
  81. // 不继承 scope.BaseRepo:任务表本身平台级,作用域过滤在"派发侧"由业务域完成,
  82. // 消费侧按 Type 路由到对应 handler,handler 内部自带作用域)。
  83. type Repo struct {
  84. db *gorm.DB // 由 cmd/api 统一建立连接池后注入
  85. }
  86. // NewRepo 构造任务仓储。
  87. //
  88. // 入参:
  89. // - db *gorm.DB:数据库句柄(连接池由 infra/db 统一建立)。
  90. //
  91. // 出参:
  92. // - *Repo:就绪的仓储实例(db 为 nil 时各方法直接 panic——装配错误 fail-fast,单测覆盖)。
  93. func NewRepo(db *gorm.DB) *Repo { return &Repo{db: db} }
  94. // Enqueue 幂等入队(写 t_task,status=pending)。
  95. //
  96. // 入参:
  97. // - ctx context.Context:请求上下文(GORM WithContext 传递 traceId/取消信号)。
  98. // - n New:待入队任务(Type/IdempotentKey 必填)。
  99. //
  100. // 出参:
  101. // - int64:新任务 ID(幂等命中已有任务时返回已有任务 ID,不报错——
  102. // "重复派发"是正常业务(用户连点两次提交),不是错误)。
  103. // - error:
  104. // - Type 或 IdempotentKey 为空 → 参数错误(调用方 bug);
  105. // - Payload 序列化失败 → 包装 json 错误(载荷不可序列化属编码 bug,快速失败);
  106. // - 唯一键冲突(并发双请求同幂等键):GORM 返回 duplicate entry,本函数捕获后
  107. // 回查已有任务 ID 返回(等价于成功,幂等语义);
  108. // - 其它 DB 异常 → 包装 ErrDBError 语义(调用方决策:AI 类可降级重试,强一致类上报)。
  109. //
  110. // 样例:
  111. //
  112. // id, err := repo.Enqueue(ctx, task.New{
  113. // Type: "ai_grading", CampusID: 3,
  114. // Payload: GradingPayload{HomeworkID: 42},
  115. // IdempotentKey: fmt.Sprintf("ai_grading:3:42"),
  116. // })
  117. func (r *Repo) Enqueue(ctx context.Context, n New) (int64, error) {
  118. if n.Type == "" || n.IdempotentKey == "" {
  119. return 0, fmt.Errorf("task: Enqueue 要求 Type 与 IdempotentKey 非空")
  120. }
  121. payload, err := json.Marshal(n.Payload)
  122. if err != nil {
  123. return 0, fmt.Errorf("task: 载荷序列化失败(type=%s): %w", n.Type, err)
  124. }
  125. now := time.Now()
  126. t := &Task{
  127. Type: n.Type,
  128. CampusID: n.CampusID,
  129. Payload: string(payload),
  130. Status: StatusPending,
  131. IdempotentKey: n.IdempotentKey,
  132. NextRunAt: now,
  133. TraceID: n.TraceID,
  134. CreatedAt: now,
  135. }
  136. res := r.db.WithContext(ctx).Create(t)
  137. if res.Error == nil {
  138. return t.ID, nil
  139. }
  140. // 幂等键唯一冲突 → 回查已有任务 ID(并发重复派发场景)
  141. if isDuplicateEntry(res.Error) {
  142. var existing Task
  143. if err := r.db.WithContext(ctx).
  144. Where("idempotent_key = ?", n.IdempotentKey).
  145. Take(&existing).Error; err == nil { // Take:存在性判断标准姿势(架构规格 §3.6)
  146. return existing.ID, nil
  147. }
  148. }
  149. return 0, fmt.Errorf("task: 入队失败(type=%s): %w", n.Type, res.Error)
  150. }
  151. // Claimable 拉取一批可认领任务(status=pending 且 next_run_at<=now;按创建序 FIFO)。
  152. //
  153. // 入参:
  154. // - ctx context.Context:请求上下文。
  155. // - limit int:本批上限(TaskCfg.ClaimBatch,建议 10——防单实例饿死另一实例)。
  156. //
  157. // 出参:
  158. // - []Task:候选任务(可能为空 slice,非 nil);**注意**:此处仅 SELECT,未加锁,
  159. // 候选≠已认领——必须逐个经 Claim 乐观锁确认后才算本实例的任务。
  160. // - error:DB 异常时非 nil(调用方:本轮跳过 + 记 WARN,不退出工作池)。
  161. func (r *Repo) Claimable(ctx context.Context, limit int) ([]Task, error) {
  162. if limit <= 0 {
  163. limit = 10
  164. }
  165. var list []Task
  166. err := r.db.WithContext(ctx).
  167. Where("status = ? AND next_run_at <= ?", StatusPending, time.Now()).
  168. Order("id ASC"). // FIFO:先入队先消费(公平性)
  169. Limit(limit).
  170. Find(&list).Error
  171. if err != nil {
  172. return nil, fmt.Errorf("task: 拉取可认领任务失败: %w", err)
  173. }
  174. return list, nil
  175. }
  176. // Claim 乐观锁认领单个任务(双实例防重复消费的核心)。
  177. //
  178. // 入参:
  179. // - ctx context.Context:请求上下文。
  180. // - id int64:候选任务 ID(来自 Claimable)。
  181. // - owner string:认领实例标识("instance-a"/"instance-b")。
  182. //
  183. // 出参:
  184. // - bool:true 认领成功(本实例独占该任务,开始执行);
  185. // false 认领失败(已被另一实例抢先认领,或状态已非 pending——直接跳过,不是错误)。
  186. // - error:DB 异常时非 nil(调用方:本轮放弃该任务,下轮再试;不置 dead——
  187. // "认领失败"与"执行失败"语义不同,前者不该消耗重试次数)。
  188. //
  189. // SQL 语义(单条 UPDATE 原子化,无 SELECT 竞态):
  190. //
  191. // UPDATE t_task SET status='running', owner=?, claimed_at=NOW()
  192. // WHERE id=? AND status='pending'
  193. // RowsAffected==1 → 成功;==0 → 已被抢/状态已变
  194. //
  195. // 说明:不用 SELECT ... FOR UPDATE(PolarDB 行锁 + 长事务风险)——
  196. // 条件 UPDATE 的 RowsAffected 判定是 InnoDB 下的标准乐观并发做法。
  197. func (r *Repo) Claim(ctx context.Context, id int64, owner string) (bool, error) {
  198. res := r.db.WithContext(ctx).
  199. Model(&Task{}).
  200. Where("id = ? AND status = ?", id, StatusPending).
  201. Updates(map[string]interface{}{
  202. "status": StatusRunning,
  203. "owner": owner,
  204. "claimed_at": time.Now(),
  205. })
  206. if res.Error != nil {
  207. return false, fmt.Errorf("task: 认领任务 %d 失败: %w", id, res.Error)
  208. }
  209. return res.RowsAffected == 1, nil // 0 = 被抢或状态已变
  210. }
  211. // Done 标记任务成功(终态)。
  212. //
  213. // 入参:
  214. // - ctx context.Context:请求上下文。
  215. // - id int64:任务 ID(调用方必须是该任务的 owner——本函数不做 owner 校验,
  216. // 依赖"认领成功才执行"的调用纪律;审计靠 owner 列回溯)。
  217. //
  218. // 出参:
  219. // - error:仅 DB 异常时非 nil(调用方:重试标记操作;任务实际已成功,
  220. // 标记失败最坏后果是任务"僵尸 running"——靠 reclaimStale 回收后重复执行,
  221. // handler 必须幂等(按 idempotentKey 查业务表终态直接返回成功))。
  222. func (r *Repo) Done(ctx context.Context, id int64) error {
  223. res := r.db.WithContext(ctx).
  224. Model(&Task{}).
  225. Where("id = ? AND status = ?", id, StatusRunning).
  226. Update("status", StatusDone)
  227. if res.Error != nil {
  228. return fmt.Errorf("task: 标记成功(id=%d)失败: %w", id, res.Error)
  229. }
  230. return nil
  231. }
  232. // Fail 标记任务失败并计算下次重试(指数退避;超限置 dead)。
  233. //
  234. // 入参:
  235. // - ctx context.Context:请求上下文。
  236. // - id int64:任务 ID。
  237. // - maxRetry int:重试上限(TaskCfg.MaxRetry,建议 6:1s,2s,4s,8s,16s,32s 后 dead)。
  238. // - reason string:失败原因摘要(≤256 字符,进日志/告警;**禁止**塞堆栈全文)。
  239. //
  240. // 出参:
  241. // - bool:true 任务仍存活(已安排 next_run_at 重试);false 已置 dead(重试超限,告警+人工)。
  242. // - error:DB 异常时非 nil。
  243. //
  244. // 退避公式:next_run_at = now + min(2^retry 秒, 3600 秒)
  245. // (retry 为本函数执行**前**的已重试次数:首次失败 retry=0→1s 后重试,第 6 次失败→dead)。
  246. //
  247. // 幂等性要求(调用方):handler 失败路径若"业务已部分落库",重试时必须走幂等键查终态,
  248. // 否则退避重试会造成重复副作用(如重复批改写两份结果——业务表按 homework_id 唯一约束兜底)。
  249. func (r *Repo) Fail(ctx context.Context, id int64, maxRetry int, reason string) (bool, error) {
  250. var t Task
  251. if err := r.db.WithContext(ctx).Where("id = ?", id).Take(&t).Error; err != nil {
  252. return false, fmt.Errorf("task: 读取任务 %d 失败: %w", id, err)
  253. }
  254. t.Retry++
  255. if t.Retry > maxRetry {
  256. res := r.db.WithContext(ctx).
  257. Model(&Task{}).
  258. Where("id = ? AND status = ?", id, StatusRunning).
  259. Update("status", StatusDead)
  260. if res.Error != nil {
  261. return false, fmt.Errorf("task: 置 dead(id=%d)失败: %w", id, res.Error)
  262. }
  263. return false, nil // dead:调用方记 ERROR 日志 + Prometheus 计数器 task_dead_total
  264. }
  265. backoff := time.Duration(1<<uint(t.Retry)) * time.Second // 2^retry 秒
  266. if backoff > time.Hour {
  267. backoff = time.Hour // 上限 1h(防长尾任务堆积时退避过久)
  268. }
  269. res := r.db.WithContext(ctx).
  270. Model(&Task{}).
  271. Where("id = ? AND status = ?", id, StatusRunning).
  272. Updates(map[string]interface{}{
  273. "status": StatusPending,
  274. "retry": t.Retry,
  275. "next_run_at": time.Now().Add(backoff),
  276. })
  277. if res.Error != nil {
  278. return false, fmt.Errorf("task: 安排重试(id=%d)失败: %w", id, res.Error)
  279. }
  280. return true, nil
  281. }
  282. // ReclaimStale 回收僵死任务(实例崩溃/卡死:running 且 claimed_at 超过超时阈值 → 重置 pending)。
  283. //
  284. // 入参:
  285. // - ctx context.Context:请求上下文。
  286. // - staleAfter time.Duration:僵死判定阈值(建议 10min:Agent 长任务同步调用超时 60s ×
  287. // 重试 2 + 余量;**必须大于** handler 最长执行时间,否则健康任务会被误回收重复执行)。
  288. //
  289. // 出参:
  290. // - int64:本次回收的任务数(0=无僵死,正常)。
  291. // - error:DB 异常时非 nil(调用方:下一周期再试;本函数由工作池侧周期调用,非请求链路)。
  292. //
  293. // 语义:回收后任务回到 pending,可能被另一实例重新认领——handler 幂等是最后一道防线。
  294. //
  295. // 样例(工作池每 60s 调一次):
  296. //
  297. // n, _ := repo.ReclaimStale(ctx, 10*time.Minute)
  298. // if n > 0 { log.Warn("task: 回收僵死任务", zap.Int64("count", n)) }
  299. func (r *Repo) ReclaimStale(ctx context.Context, staleAfter time.Duration) (int64, error) {
  300. res := r.db.WithContext(ctx).
  301. Model(&Task{}).
  302. Where("status = ? AND claimed_at < ?", StatusRunning, time.Now().Add(-staleAfter)).
  303. Updates(map[string]interface{}{
  304. "status": StatusPending,
  305. "owner": "",
  306. })
  307. if res.Error != nil {
  308. return 0, fmt.Errorf("task: 回收僵死任务失败: %w", res.Error)
  309. }
  310. return res.RowsAffected, nil
  311. }
  312. // isDuplicateEntry 判定 MySQL 唯一键冲突错误(匹配 "Duplicate entry" 文案,
  313. // 不依赖具体驱动错误类型——PolarDB 兼容 MySQL 协议,驱动错误包装层级可能不同,
  314. // 文案匹配最稳)。
  315. //
  316. // 入参:err error:GORM 返回的原始错误。
  317. // 出参:bool:true 表示唯一键冲突(幂等命中)。
  318. func isDuplicateEntry(err error) bool {
  319. if err == nil {
  320. return false
  321. }
  322. msg := err.Error()
  323. return strings.Contains(msg, "Duplicate entry") || strings.Contains(msg, "Error 1062")
  324. }