Files
ai-agent/workflow/service/flow/exec_recover.go
19904408334 73f296731c refactor(workflow): 隔离逻辑运行键为 node_group_id
将 checkpoint、异步任务与段结果缓存的唯一键从 execution_id 改为 node_group_id,区分换参重跑与续跑语义;恢复/续跑复用执行记录的组标识,换参重跑则换新组并软删旧组残留,消除软删墓碑导致同键重存失效的问题。
2026-09-04 17:36:46 +08:00

237 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package flow
import (
"context"
"errors"
"fmt"
"time"
sessionDao "ai-agent/workflow/dao/session"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/utils"
wsCommon "gitea.redpowerfuture.com/red-future/common/websocket"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/gtrace"
"github.com/google/uuid"
)
// 恢复相关常量(可参数化调整)
const (
heartbeatInterval = 30 * time.Second // 心跳 touch 间隔
heartbeatStaleAfter = 2 * heartbeatInterval // 心跳陈旧阈值(>2×间隔),小于此视为僵尸
recoverScanInterval = 30 * time.Second // 周期扫描间隔
recoverLockTTL = 15 * time.Minute // 恢复锁 TTL(覆盖长时间续跑)
recoverLockPhase = 2 * time.Minute // 抢锁/前置判定阶段总时限(Redis SET + 一次 DB 读,毫秒级)
recoverExecTimeout = 12 * time.Hour // 单次恢复执行最长时长;超时按可重试失败落库交下一轮扫描
heartbeatMaxFail = 2 // 心跳连续失败达到陈旧阈值(2×30s=60s)即租约丢失,中止执行
execMaxRetryCount = 2 // 整次执行最多自动重试 2 次(共 3 次尝试)
)
// StartRecoveryLoop 启动恢复扫描:先立即扫一次,再周期扫描。
// 多节点各自扫描,靠 Redis 锁对同一 exec 抢占去重(Redis 锁只管瞬时互斥、运行期靠 DB 心跳,见《工作流执行并发仲裁设计.md》§1/§2)
func StartRecoveryLoop(ctx context.Context) {
go func() {
scanAndRecover(ctx)
ticker := time.NewTicker(recoverScanInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
scanAndRecover(ctx)
case <-ctx.Done():
return
}
}
}()
}
// scanAndRecover 扫描可恢复执行并逐个异步恢复(不阻塞扫描循环)。
// 恢复运行在无 HTTP 用户的后台:ListRecoverable 走跨租户 NoTenantId,需要 ctx 携带 OTel span
// NoTenantId 以 traceID 为 gcache 标记键,无 span 时 getTraceID 返回空 → NoTenantId 返回 nil 会 panic
func scanAndRecover(ctx context.Context) {
// 优雅关停期间不再捞起:连接 ctx 刚被取消、exec 正在落终态,避免本进程内部用合成用户重跑
if IsShuttingDown() {
return
}
scanCtx, span := gtrace.NewSpan(ctx, "workflow.recover.scan")
defer span.End()
now := time.Now().UnixMilli()
rows, err := sessionDao.ExecWorkflowDao.ListRecoverable(scanCtx, now, now-int64(heartbeatStaleAfter/time.Millisecond), execMaxRetryCount)
if err != nil {
g.Log().Errorf(scanCtx, "扫描可恢复执行失败: %v", err)
return
}
for _, r := range rows {
// 扫描触发无附着连接(attach=nil):hub 在确认可恢复后按 exec 的 session+flow 建立
go recoverExecution(context.WithoutCancel(scanCtx), r.Id, nil)
}
}
// recoverExecution 统一恢复例程:抢锁 → 判定(isRecoverable)→ 条件重置抢权 → 置运行中续跑
// (触发源/抢权闸/输家纪律见《工作流执行并发仲裁设计.md》§1/§2/§4)。
// 两个触发源共用:启动/周期扫描(attach=nil)、executeOrResume 对僵尸行的用户触发(attach 携带连接)。
func recoverExecution(parentCtx context.Context, execId int64, attach *execAttach) {
// 优雅关停期间不再启动新的恢复执行(周期扫描已有守卫;用户 executeOrResume 触发路径这里兜底)
if IsShuttingDown() {
return
}
// 在函数最前面创建并登记本执行的 cancel:trackExecRun 绑定最终控制执行的 cancel,
// 提前登记保证优雅关停(SetShuttingDown→cancelAllExecRuns)快照必然覆盖到本执行(已登记),
// 或本执行在开始前置判定前自行发现关停标记放弃——不留"已置 status=1 却无执行"的无主记录。
topCtx, topCancel := context.WithCancel(context.WithoutCancel(parentCtx))
defer topCancel()
finish := trackExecRun(topCancel)
defer finish()
// 登记后复查关停标记:已置位说明 cancelAllExecRuns 快照早于本登记(未覆盖本执行),
// 此时本执行尚未走 ResetRunningIfRecoverable(不会留下 status=1 无主执行),直接放弃;
// 若置位发生在复查之后,本执行已被快照覆盖,随关停取消并在错误分类路径落终态(status=3/retryable=1
if IsShuttingDown() {
return
}
// 事件中枢(attach 路径):用户点击执行触发恢复时,复用连接已订阅的候选 hub(executeOrResume 传入),
// 让该连接订阅到后续节点进度与终态、并能通过 workflow_cancel 永久取消(hub.CancelByUser→topCancel)。
// 此处只订阅不 TryOwn:所有权由"抢到锁+重置权"的执行者确定;提前退出路径(锁输/不可恢复/重置失败)
// 不关 hub,保证最终执行者与连接指向同一 hub 实例,取消/进度不因竞态丢失。
var hub *execHub
if attach != nil {
if attach.hub != nil {
hub = attach.hub
} else {
hub, _ = registerHubIfAbsent(attach.sessionId, attach.flowId, newExecHub(attach.sessionId, attach.flowId))
}
subscribeConnToHub(attach.conn, hub)
}
lockKey := fmt.Sprintf("workflow:exec:recover:%d", execId)
// 抢锁/前置判定用独立短超时 ctx:该阶段只做 Redis SET + 一次 DB 读 + 一次条件重置,应毫秒级完成
lockCtx, lockCancel := context.WithTimeout(context.WithoutCancel(parentCtx), recoverLockPhase)
defer lockCancel()
// 恢复体整体包进 utils.WithLock(自动续期 + 单次尝试,替代本地对象形态锁 redis_lock.go):
// - 自动续期:15min TTL 覆盖整段执行。长执行原本就靠心跳陈旧 + 条件重置防双跑,
// 锁持满只是让其它节点提前「锁忙跳过」;节点崩溃续期停 → TTL 过期 → 其它节点照常捞起;
// - 单次尝试(retryTimes=1):原「被其它节点持有就跳过」语义,抢不到不等待。
ok, err := utils.WithLock(lockCtx, lockKey, int64(recoverLockTTL/time.Second), func(_ context.Context) error {
// 抢锁后重读:此刻租户未知(该 exec 可能属于任意租户),必须跨租户读
exec, err := sessionDao.ExecWorkflowDao.GetByIdNoTenant(lockCtx, execId)
if err != nil || exec == nil {
return nil
}
if !isRecoverable(exec, time.Now().UnixMilli()) {
return nil // 锁等待期间状态已变化(其它节点已恢复/用户取消/已耗尽)
}
// 事件中枢(scan 路径,无附着连接):此刻才知道 exec 的 session+flow,建 hub 供后续用户连接附着
//(所有权在重置成功后统一接管,见下)
if attach == nil {
hub, _ = registerHubIfAbsent(exec.SessionId, exec.FlowId, newExecHub(exec.SessionId, exec.FlowId))
}
// 置运行中并续跑(BuildExecution false → 断点续跑:checkpoint 有则从中继续,无则从头 + 段/memo 复用)。
// 心跳在条件重置成功后才启动:避免对未抢到重置权的 exec 空跑心跳
saveCtx := context.WithoutCancel(parentCtx)
// 恢复无 HTTP 用户,但图节点 lambda 的 INSERTnode_execution/flow_async_task/segment_result)走
// insertHook 硬性要求 user,且节点模型调用外发 model-gateway 带 X-User-Info 要过单次调用最低
// 余额门禁(须 user.Id>0)——用 exec 所属租户合成系统用户 ctx(Id 取创建时落库的 user_id
// creator 仅 userName 推不回数字 id;旧记录 user_id=0 时其恢复续跑会被该门禁拦截),保留 span。
userCtx := context.WithValue(saveCtx, "user", &beans.User{Id: uint64(exec.UserId), UserName: exec.Creator, TenantId: exec.TenantId})
// 恢复 = 同一逻辑运行继续:复用 exec 记录的组读其 checkpoint 续跑(不换组,否则读不到断点从头跑)。
// 仅旧记录无组时新造并随重置持久化;置运行中与图执行的组标识须一致。
nodeGroupId := exec.NodeGroupId
if nodeGroupId == "" {
nodeGroupId = uuid.NewString()
}
// 条件重置(原子防与用户断点续跑双跑):仅当仍可恢复(status=3 或 status=1 心跳陈旧)时才抢到重置权
staleBeforeMs := time.Now().UnixMilli() - int64(heartbeatStaleAfter/time.Millisecond)
reset, err := sessionDao.ExecWorkflowDao.ResetRunningIfRecoverable(userCtx, execId, nodeGroupId, staleBeforeMs)
if err != nil {
g.Log().Errorf(lockCtx, "恢复置运行中失败 execId=%d: %v", execId, err)
return nil
}
if !reset {
// 状态已被其它路径(用户 reExecute/execute 或其它节点恢复)抢先重置:放弃续跑,状态由持有方收敛,不落终态
g.Log().Infof(lockCtx, "execId=%d 已被其它路径抢先重置为运行中,跳过恢复", execId)
return nil
}
// 事件中枢所有权:此时已抢到锁+重置权,本 goroutine 是实际执行者,接管 hubTryOwn 原子置 owned+cancel)。
// 已有持有者(同 session+flow 另有执行在跑)则本恢复不持有 hub:锁与重置权已到手,放弃会留 status=1
// 无主,继续执行但进度不广播(恢复仍正常完成落终态)。
if hub != nil {
if !hub.TryOwn(topCancel) {
hub = nil
} else {
defer hub.Close()
// 恢复建立前用户已取消(占位期 workflow_cancel 只置标志、cancel 尚未设置):立即中止,
// 走下方 UserCancelled 分类写永久取消(retryable=0
if hub.UserCancelled() {
topCancel()
}
}
}
// 执行与心跳绑定同一 execCtx(带 recoverExecTimeout 上限):
// 心跳停止(超时/进程死/租约丢失)与 BuildExecution 中止必须同步,否则出现
// "心跳已过期但执行还活着" 的窗口,被其它节点扫描恢复导致双跑。
// 心跳连续失败达陈旧阈值时回调 execCancel 中止执行(心跳与执行同生共死)。
// 从函数顶部登记的 topCtx 派生执行 ctx:注入用户信息(供 insertHook 落库)+ 12h 执行超时上限。
// 关停时 cancelAllExecRuns 取消 topCancel → 本 ctx 随之取消,BuildExecution 中止后走下方错误分类落终态。
execCtx, execCancel := context.WithTimeout(context.WithValue(topCtx, "user", &beans.User{Id: uint64(exec.UserId), UserName: exec.Creator, TenantId: exec.TenantId}), recoverExecTimeout)
defer execCancel()
stop := startHeartbeat(execCtx, execId, execCancel)
defer stop()
// 图节点进度经 hub 广播(无 hub 时 getProgressHub 返回 nilreporter nil 安全)
progressCtx := execCtx
if hub != nil {
progressCtx = context.WithValue(execCtx, wsProgressCtxKey{}, hub)
}
err = BuildExecution(progressCtx, false, exec.FlowId, execId, nodeGroupId, exec.SessionId, exec.RequestParams)
if err != nil {
// 用户显式取消(附着连接 workflow_cancel):永久取消,retryable=0,恢复扫描不再捞起,
// 杜绝"取消→恢复→再取消"循环;产物(checkpoint/段/异步缓存)保留,落"用户已终止执行"。
// 与 WS 路径一致:失败/取消不清,统一由"成功尾部 / 下一次 forceNewRun 起跑前"清理
if hub != nil && hub.UserCancelled() {
retryable, retryCnt := 0, 0
recordWorkflow(userCtx, execId, 0, context.Canceled, &retryable, &retryCnt)
hub.Publish(&wsCommon.WsPushMsg{Type: "error", Message: errWorkflowTerminated})
return nil
}
// 续跑失败:恢复例程无用户,任意错误(含执行超时/租约丢失取消/模型/DB/网络/panic
// 一律 retryable=1 交下一轮扫描决定是否再恢复;重试耗尽才终局失败。
// 产物(checkpoint/段/异步缓存)保留不在此清:重试耗尽后用户仍可手动同参数续跑
// 复用已成功段/异步结果,换参数则由 forceNewRun 起跑前统一清理(见 BuildExecution
retryable, retryCnt := 1, exec.RetryCount+1
// 优雅关停导致的取消:换错误标记让 recordWorkflow 写"程序关停中断"(与 WS 路径一致),
// 仍 retryable=1 下次启动扫描捞起续跑;非关停的取消(租约丢失/执行超时)保留原错误
if errors.Is(err, context.Canceled) && IsShuttingDown() {
err = errInterruptedByShutdown
}
recordWorkflow(userCtx, execId, 0, err, &retryable, &retryCnt)
// 终态广播(附着连接;scan 路径无连接则无人接收)
if hub != nil {
hub.Publish(&wsCommon.WsPushMsg{Type: "error", Message: "工作流执行失败", Error: err.Error()})
}
return nil
}
// 续跑成功:recordWorkflow 落 status=2BuildExecution 已清理 checkpoint/segment_result/flow_async_task
recordWorkflow(userCtx, execId, 0, nil, nil, nil)
// 终态广播:把本次执行保存的结果文件路径一并推给前端
if hub != nil {
hub.Publish(&wsCommon.WsPushMsg{Type: "flow_complete", Message: "工作流执行完成", Data: map[string]interface{}{
"resultFileUrls": workflowResultFileUrls(userCtx, execId),
}})
}
return nil
})
if err != nil {
g.Log().Errorf(lockCtx, "恢复抢锁失败 execId=%d: %v", execId, err)
return
}
if !ok {
g.Log().Infof(lockCtx, "恢复锁被其它节点持有,跳过 execId=%d", execId)
return
}
}