将 checkpoint、异步任务与段结果缓存的唯一键从 execution_id 改为 node_group_id,区分换参重跑与续跑语义;恢复/续跑复用执行记录的组标识,换参重跑则换新组并软删旧组残留,消除软删墓碑导致同键重存失效的问题。
237 lines
13 KiB
Go
237 lines
13 KiB
Go
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 的 INSERT(node_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 是实际执行者,接管 hub(TryOwn 原子置 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 返回 nil,reporter 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=2;BuildExecution 已清理 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
|
||
}
|
||
}
|