feat(workflow): 支持运行中执行事件中枢与优雅关停

This commit is contained in:
2026-08-26 14:52:15 +08:00
parent e0f7ce61a1
commit 5c5f97af17
17 changed files with 747 additions and 514 deletions
+8
View File
@@ -11,6 +11,7 @@ import (
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
// Upload 以 multipart 方式上传文件字节到 OSS,返回可访问 URL。
@@ -38,6 +39,13 @@ func Upload(ctx context.Context, fileName string, fileBytes []byte) (string, err
}
}
}
// 后台恢复续跑无 HTTP 请求时(ctx 携带合成 user),补充 X-User-Info 供 OSS GetUserInfo 识别用户与桶名;
// 与 gateway/model.go requestHeaders 的恢复兜底保持一致,否则 OSS GetBucketName 落到空 token 解析报错
if headers["X-User-Info"] == "" {
if u := ctx.Value("user"); u != nil {
headers["X-User-Info"] = gconv.String(u)
}
}
headers["Content-Type"] = writer.FormDataContentType()
res := &uploadFileRes{}
+6
View File
@@ -101,6 +101,7 @@ type ModelTool struct {
// requestHeaders 透传当前 HTTP 请求头(鉴权/链路信息)。
// 浏览器 WebSocket 握手无法携带 Authorization 头,前端把 token 放在握手 URL query?token=)里;
// 若请求头没有 Authorization,则从 query 补回,保证下游(model-gateway → admin-go)能拿到用户 token。
// 后台恢复续跑无 HTTP 请求时(ctx 携带合成 user),补充 X-User-Info 供下游 GetUserInfo 识别租户。
func requestHeaders(ctx context.Context) map[string]string {
headers := make(map[string]string)
if r := g.RequestFromCtx(ctx); r != nil {
@@ -115,6 +116,11 @@ func requestHeaders(ctx context.Context) map[string]string {
}
}
}
if headers["X-User-Info"] == "" {
if u := ctx.Value("user"); u != nil {
headers["X-User-Info"] = gconv.String(u)
}
}
return headers
}
-15
View File
@@ -1,15 +0,0 @@
package gateway
import (
"context"
"testing"
"ai-agent/workflow/consts/model"
)
// TestSplitSignatures 只做编译期签名验证:拆分后的函数存在且签名符合 memo 包装契约。
// 不做实际调用(依赖 live model-gateway + Consul),实际行为由端到端走查覆盖。
func TestSplitSignatures(t *testing.T) {
var _ func(context.Context, int64, model.ResponseType, string, map[string]any, map[string]any) (*ModelCallRes, string, error) = SubmitModelCall
var _ func(context.Context, string) (*ModelCallRes, error) = WaitModelCallResult
}
+13 -2
View File
@@ -21,6 +21,7 @@ import (
"os"
"os/signal"
"syscall"
"time"
"gitea.redpowerfuture.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/jaeger"
@@ -77,11 +78,21 @@ func main() {
// 监听退出信号,执行优雅关闭
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
shutdownDone := make(chan struct{})
go func() {
<-sigCh
// 先置关停标记再 Close:Close 会取消各连接 ctx,正在执行的 exec 会收到 context.Canceled
// 标记让错误分类识别为"程序关停中断"retryable=1),下次启动恢复扫描捞起续跑,
// 而非误判为"用户已终止执行"retryable=0,永不恢复)
// SetShuttingDown 同时取消所有运行中执行(含脱离连接的恢复执行),保证全部落终态
flow.SetShuttingDown()
flow.SessionWsService.Close()
// 等待运行中执行落完终态再退出,避免进程退出时 exec 仍卡 status=1
// 结束后返回 main 触发 deferred 资源清理,进程自然退出(不再 select{} 挂死)
flow.WaitExecRunsDrain(15 * time.Second)
close(shutdownDone)
}()
// 保持应用运行
select {}
// 保持应用运行;收到关停信号并落完终态后自然退出
<-shutdownDone
}
@@ -1,94 +0,0 @@
package flow
import (
"context"
"os"
"testing"
"ai-agent/workflow/consts/public"
"gitea.redpowerfuture.com/red-future/common/beans"
"gitea.redpowerfuture.com/red-future/common/db/gfdb"
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
)
// resultJSONB 是 JSONB 读回的规范化形式:PG 对 {"taskId":2026} 做 JSONB 归一化后输出
// {"taskId": 2026}(冒号后带空格),直接比较字面量会失配,以实际读回为准。
const resultJSONB = `{"taskId": 2026}`
func TestFlowAsyncTaskDaoRoundTrip(t *testing.T) {
if os.Getenv("AI_AGENT_TEST_DB") != "1" {
t.Skip("skip: AI_AGENT_TEST_DB not set")
}
// gfdb 的 Hook 依赖 ctx 中注入用户信息(租户/创建人),否则 Insert/Select 报 "token 数据为空"
ctx := context.WithValue(context.Background(), "user", &beans.User{
UserName: "test-async", TenantId: 1,
})
const execId, nodeId, seg = 900001, "node-x", 3
// 清理可能残留
if err := FlowAsyncTaskDao.DeleteByKey(ctx, execId, nodeId, seg); err != nil {
t.Fatalf("cleanup: %v", err)
}
if err := FlowAsyncTaskDao.Upsert(ctx, execId, nodeId, seg, 1001, 2026, "model-call-test"); err != nil {
t.Fatalf("upsert: %v", err)
}
rec, err := FlowAsyncTaskDao.Get(ctx, execId, nodeId, seg)
if err != nil || rec == nil {
t.Fatalf("get: %v", err)
}
if rec.TaskId != 2026 || rec.MsgTopic != "model-call-test" || rec.State != FlowAsyncStateInflight {
t.Fatalf("upsert state wrong: %+v", rec)
}
if err := FlowAsyncTaskDao.UpdateByKey(ctx, execId, nodeId, seg, FlowAsyncStateDone, `{"taskId":2026}`); err != nil {
t.Fatalf("update result: %v", err)
}
rec, err = FlowAsyncTaskDao.Get(ctx, execId, nodeId, seg)
if err != nil || rec == nil {
t.Fatalf("get: %v", err)
}
if rec.State != FlowAsyncStateDone || rec.Result != resultJSONB {
t.Fatalf("result not persisted: %+v", rec)
}
// 唯一键 upsert:同 (exec,node,seg) 再次写入应更新而非新建
if err := FlowAsyncTaskDao.Upsert(ctx, execId, nodeId, seg, 1001, 9999, "model-call-test2"); err != nil {
t.Fatalf("re-upsert: %v", err)
}
rec, err = FlowAsyncTaskDao.Get(ctx, execId, nodeId, seg)
if err != nil || rec == nil {
t.Fatalf("get: %v", err)
}
if rec.TaskId != 9999 {
t.Fatalf("re-upsert should update task_id, got %d", rec.TaskId)
}
// OnDuplicate 限定了冲突更新列,已缓存结果不应被覆盖
if rec.Result != resultJSONB {
t.Fatalf("re-upsert should preserve cached result, got %q", rec.Result)
}
// 物理删除验证:DeleteByKey 后 Get 为 nil(软删会留下 deleted_at 行且 Get 仍返回)
if err := FlowAsyncTaskDao.DeleteByKey(ctx, execId, nodeId, seg); err != nil {
t.Fatalf("delete: %v", err)
}
rec, err = FlowAsyncTaskDao.Get(ctx, execId, nodeId, seg)
if err != nil {
t.Fatalf("get after delete: %v", err)
}
if rec != nil {
t.Fatalf("物理删除后不应查到残留: %+v", rec)
}
// DeleteByExecution
if err := FlowAsyncTaskDao.Upsert(ctx, execId, nodeId, seg, 1001, 1, "t"); err != nil {
t.Fatalf("upsert: %v", err)
}
if err := FlowAsyncTaskDao.DeleteByExecution(ctx, execId); err != nil {
t.Fatalf("delete by exec: %v", err)
}
if rec, _ := FlowAsyncTaskDao.Get(ctx, execId, nodeId, seg); rec != nil {
t.Fatalf("DeleteByExecution 后不应查到残留")
}
// 清理表残留(跨测试)
gfdb.DB(ctx, public.DbNameBlackDeacon).Exec(ctx, "DELETE FROM black_deacon_flow_async_task WHERE execution_id = ?", execId)
}
+66 -5
View File
@@ -49,6 +49,20 @@ func (d *execWorkflowDao) Update(ctx context.Context, req *sessionDto.UpdateWork
return r.RowsAffected()
}
// UpdateMap 按列更新执行记录(map 更新不走 OmitEmpty,可显式写 0 值)。
// 供 recordWorkflow 终态一次原子落库(status/error_message/error/retryable/retry_count 同语句),
// 避免"先 UpdateRetry 再 Update"两步写部分生效导致 retryable 与状态不一致
func (d *execWorkflowDao) UpdateMap(ctx context.Context, id int64, data map[string]any) error {
if id <= 0 || len(data) == 0 {
return nil
}
_, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
Where(entity.ExecWorkflowCol.Id, id).
Data(data).
Update()
return err
}
// ClearError 清空执行记录的报错信息(重新执行成功后调用,OmitEmpty 的 Update 会跳过空串,需显式写空)
func (d *execWorkflowDao) ClearError(ctx context.Context, id int64) (rows int64, err error) {
r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
@@ -121,18 +135,65 @@ func (d *execWorkflowDao) ListBySession(ctx context.Context, sessionId string) (
return
}
// ResetRunning 置为运行中并刷新心跳(execute 复用失败记录 / reExecute / 恢复例程共用;
// 用 map 更新避免 OmitEmpty 省略 0 值,同时写 status、last_heartbeat、node_group_id
func (d *execWorkflowDao) ResetRunning(ctx context.Context, id int64, nodeGroupId string) error {
_, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
// ResetRunning 置为运行中并刷新心跳(execute 复用失败记录 / reExecute 共用;
// 用 map 更新避免 OmitEmpty 省略 0 值,同时写 status、last_heartbeat、node_group_id
// 条件更新防双跑:仅当记录当前状态仍属于 prevStatuses 之一时才重置(DB 行锁原子判定),
// 其余场景(已被恢复例程/并发触发抢先重置)返回 reset=false,调用方应放弃执行并返回 errExecAlreadyRunning
// 由持有方收敛状态,避免同一 exec 被两条路径并发 BuildExecution。
func (d *execWorkflowDao) ResetRunning(ctx context.Context, id int64, nodeGroupId string, prevStatuses ...int8) (reset bool, err error) {
m := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
Where(entity.ExecWorkflowCol.Id, id)
if len(prevStatuses) > 0 {
statuses := make([]interface{}, 0, len(prevStatuses))
for _, s := range prevStatuses {
statuses = append(statuses, s)
}
m = m.WhereIn(entity.ExecWorkflowCol.Status, statuses)
}
r, err := m.Data(map[string]any{
entity.ExecWorkflowCol.Status: gconv.Int8(*flow.FlowExecutionStatusRunning.Code()),
entity.ExecWorkflowCol.LastHeartbeat: time.Now().UnixMilli(),
entity.ExecWorkflowCol.NodeGroupId: nodeGroupId,
}).Update()
if err != nil {
return false, err
}
rows, err := r.RowsAffected()
if err != nil {
return false, err
}
return rows > 0, nil
}
// ResetRunningIfRecoverable 恢复例程专用:条件重置为运行中,仅当记录仍处于可恢复状态时
// status=3 可重试失败,或 status=1 心跳陈旧的僵尸运行中)才重置(DB 行锁原子判定防双跑)。
// staleBeforeMsstatus=1 时的心跳陈旧阈值(毫秒),与 ListRecoverable/isRecoverable 判定一致。
// 返回 reset=false 表示状态已被其它路径(用户 reExecute/execute 或其它节点恢复)抢先重置,
// 本恢复例程应放弃续跑、不落终态,状态由持有方收敛。
func (d *execWorkflowDao) ResetRunningIfRecoverable(ctx context.Context, id int64, nodeGroupId string, staleBeforeMs int64) (reset bool, err error) {
r, err := gfdb.DB(ctx, public.DbNameBlackDeacon).Model(ctx, public.TableNameExecWorkflow).
Where(entity.ExecWorkflowCol.Id, id).
Where(fmt.Sprintf("(%s = ? OR (%s = ? AND %s < ?))",
entity.ExecWorkflowCol.Status,
entity.ExecWorkflowCol.Status,
entity.ExecWorkflowCol.LastHeartbeat),
gconv.Int8(*flow.FlowExecutionStatusFailed.Code()),
gconv.Int8(*flow.FlowExecutionStatusRunning.Code()),
staleBeforeMs).
Data(map[string]any{
entity.ExecWorkflowCol.Status: gconv.Int8(*flow.FlowExecutionStatusRunning.Code()),
entity.ExecWorkflowCol.LastHeartbeat: time.Now().UnixMilli(),
entity.ExecWorkflowCol.NodeGroupId: nodeGroupId,
}).
Update()
return err
if err != nil {
return false, err
}
rows, err := r.RowsAffected()
if err != nil {
return false, err
}
return rows > 0, nil
}
// TouchHeartbeat 更新执行心跳(毫秒时间戳),供后台心跳 goroutine 每 30s 调用一次
@@ -1,110 +0,0 @@
package session
import (
"context"
"os"
"testing"
"time"
"ai-agent/workflow/consts/flow"
sessionDto "ai-agent/workflow/model/dto/session"
"ai-agent/workflow/model/entity"
"gitea.redpowerfuture.com/red-future/common/beans"
_ "github.com/gogf/gf/contrib/drivers/pgsql/v2"
"github.com/gogf/gf/v2/net/gtrace"
)
// TestExecWorkflowRetryColsRoundTrip 验证三新列读写 + ListRecoverable 三类判定(需 AI_AGENT_TEST_DB=1 且本地 PG 可用)
func TestExecWorkflowRetryColsRoundTrip(t *testing.T) {
if !isTestDBEnabled(t) {
t.Skip("skip: AI_AGENT_TEST_DB not set")
}
// gfdb 的 Hook 依赖 ctx 中注入用户信息(租户/创建人),否则 Insert 报 "token 数据为空"。
// 先造 spanListRecoverable 链 NoTenantId 依赖 traceID 作 gcache 标记键,无 span 时 getTraceID 返回空 → NoTenantId 返回 nil 会 panic
ctx, span := gtrace.NewSpan(context.Background(), "test.recover")
defer span.End()
ctx = context.WithValue(ctx, "user", &beans.User{
UserName: "test-retry", TenantId: 1,
})
id, err := ExecWorkflowDao.Insert(ctx, &sessionDto.CreateWorkflowReq{
SessionId: "test-retry", FlowId: 1, NodeGroupId: "ng",
Status: flow.FlowExecutionStatusRunning.Code(),
LastHeartbeat: time.Now().UnixMilli(),
})
if err != nil {
t.Fatalf("insert: %v", err)
}
defer ExecWorkflowDao.Delete(ctx, &sessionDto.DeleteExecWorkflowReq{Id: []int64{id}})
if err := ExecWorkflowDao.TouchHeartbeat(ctx, id); err != nil {
t.Fatalf("touch heartbeat: %v", err)
}
if err := ExecWorkflowDao.UpdateRetry(ctx, id, 1, 1); err != nil {
t.Fatalf("update retry: %v", err)
}
got, err := ExecWorkflowDao.GetById(ctx, id)
if err != nil || got == nil {
t.Fatalf("get: %v", err)
}
if got.Retryable != 1 || got.RetryCount != 1 || got.LastHeartbeat == 0 {
t.Fatalf("cols not persisted: retryable=%d retryCount=%d heartbeat=%d", got.Retryable, got.RetryCount, got.LastHeartbeat)
}
now := time.Now().UnixMilli()
stale := now - int64(120*time.Second/time.Millisecond)
if err := ExecWorkflowDao.UpdateRetry(ctx, id, 0, 0); err != nil {
t.Fatalf("update retry reset: %v", err)
}
// 情况1status=3 retryable=1 retry_count<N → 可恢复
_, err = ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{Id: id, Status: flow.FlowExecutionStatusFailed.Code()})
if err != nil {
t.Fatalf("update failed: %v", err)
}
if err := ExecWorkflowDao.UpdateRetry(ctx, id, 1, 0); err != nil {
t.Fatalf("update retry: %v", err)
}
rows, err := ExecWorkflowDao.ListRecoverable(ctx, now, stale, 2)
if err != nil {
t.Fatalf("list recoverable: %v", err)
}
if !containsID(rows, id) {
t.Fatalf("status=3 retryable=1 应可恢复,未命中 id=%d", id)
}
// 情况2retry_count 耗尽 → 不可恢复
if err := ExecWorkflowDao.UpdateRetry(ctx, id, 1, 2); err != nil {
t.Fatalf("update retry exhaust: %v", err)
}
rows, err = ExecWorkflowDao.ListRecoverable(ctx, now, stale, 2)
if err != nil {
t.Fatalf("list recoverable: %v", err)
}
if containsID(rows, id) {
t.Fatalf("retry 耗尽不应可恢复")
}
// 情况3status=3 retryable=0(用户取消)→ 不可恢复
if err := ExecWorkflowDao.UpdateRetry(ctx, id, 0, 0); err != nil {
t.Fatalf("update retry: %v", err)
}
rows, err = ExecWorkflowDao.ListRecoverable(ctx, now, stale, 2)
if err != nil {
t.Fatalf("list recoverable: %v", err)
}
if containsID(rows, id) {
t.Fatalf("用户取消不可恢复")
}
}
func containsID(rows []*entity.ExecWorkflow, id int64) bool {
for _, r := range rows {
if r.Id == id {
return true
}
}
return false
}
// isTestDBEnabled 测试辅助:AI_AGENT_TEST_DB=1 时连接真实 PG
func isTestDBEnabled(t *testing.T) bool {
return os.Getenv("AI_AGENT_TEST_DB") == "1"
}
-28
View File
@@ -1,28 +0,0 @@
package flow
import (
"testing"
flowDao "ai-agent/workflow/dao/flow"
"ai-agent/workflow/model/entity"
)
func TestAsyncCallAction(t *testing.T) {
cases := []struct {
name string
rec *entity.FlowAsyncTask
want asyncAction
}{
{"无记录 → 重新提交", nil, asyncActionResubmit},
{"done 有结果 → 复用", &entity.FlowAsyncTask{State: flowDao.FlowAsyncStateDone, Result: `{"taskId":1}`}, asyncActionReuse},
{"done 空结果 → 重新提交", &entity.FlowAsyncTask{State: flowDao.FlowAsyncStateDone, Result: ""}, asyncActionResubmit},
{"done 空对象 → 重新提交", &entity.FlowAsyncTask{State: flowDao.FlowAsyncStateDone, Result: "{}"}, asyncActionResubmit},
{"failed → 重新提交", &entity.FlowAsyncTask{State: flowDao.FlowAsyncStateFailed}, asyncActionResubmit},
{"in-flight → 重订阅收尾", &entity.FlowAsyncTask{State: flowDao.FlowAsyncStateInflight, MsgTopic: "model-call-x"}, asyncActionFinalize},
}
for _, c := range cases {
if got := asyncCallAction(c.rec); got != c.want {
t.Fatalf("%s: got %v want %v", c.name, got, c.want)
}
}
}
+248
View File
@@ -0,0 +1,248 @@
package flow
import (
"context"
"fmt"
"sync"
"sync/atomic"
wsCommon "gitea.redpowerfuture.com/red-future/common/websocket"
)
// ====================== 执行事件中枢(attach to running execution ======================
//
// execHub 是单次工作流执行的事件中枢:把节点进度(node_start/node_complete)与终态
// flow_complete/error)广播到所有订阅的 WS 连接,并暴露执行取消入口(CancelByUser)。
//
// 背景:恢复例程捞起的执行没有 WS 连接、进度被丢弃;用户执行中再点"执行"时,现有代码只发
// round_start "正在执行中" 就返回,收不到进度也无法取消。execHub 让任意时刻建立的连接都能
// 订阅到同一 session+flow 运行中执行的后续进度,并通过 workflow_cancel 停止它。
//
// 生命周期:执行方(handleExecute 的新执行/断点续跑、recoverExecution 的恢复)创建并接管
// SetCancel + MarkOwnedhub,终态落库后 Publish 终态消息并 Close(退订全部连接、注销)。
// 候选 hub 由 handleExecute 提前注册(registerHubIfAbsent),同键已有运行中执行则复用其 hub。
type execHub struct {
sessionId string
flowId int64
execId int64 // 已知后设置,仅用于日志
mu sync.Mutex
subs map[*wsCommon.WsConnection]struct{}
cancel context.CancelFunc // 取消执行(用户执行=execCancel / 恢复=topCancel
owned bool // 已被执行方接管(MarkOwned);未接管视为候选/占位
closed bool
closeOnce sync.Once
done chan struct{}
userCancelled atomic.Bool
}
func newExecHub(sessionId string, flowId int64) *execHub {
return &execHub{
sessionId: sessionId,
flowId: flowId,
subs: make(map[*wsCommon.WsConnection]struct{}),
done: make(chan struct{}),
}
}
// ====================== 注册表(按 sessionId+flowId ======================
var (
hubRegMu sync.Mutex
hubReg = make(map[string]*execHub)
)
func hubKey(sessionId string, flowId int64) string {
return fmt.Sprintf("%s\x00%d", sessionId, flowId)
}
// registerHubIfAbsent 注册 hub;同键已有则返回现有 hub(候选丢弃,返回 created=false)。
// 保证同一进程内同 session+flow 同时只有一个 hub 对象,后续连接统一订阅到它。
func registerHubIfAbsent(sessionId string, flowId int64, hub *execHub) (existing *execHub, created bool) {
key := hubKey(sessionId, flowId)
hubRegMu.Lock()
defer hubRegMu.Unlock()
if h, ok := hubReg[key]; ok {
return h, false
}
hubReg[key] = hub
return hub, true
}
// getExecHub 返回已注册的 hub(无则 nil)
func getExecHub(sessionId string, flowId int64) *execHub {
hubRegMu.Lock()
defer hubRegMu.Unlock()
return hubReg[hubKey(sessionId, flowId)]
}
// ====================== 订阅 / 发布 ======================
func (h *execHub) Subscribe(conn *wsCommon.WsConnection) {
h.mu.Lock()
defer h.mu.Unlock()
h.subs[conn] = struct{}{}
}
func (h *execHub) Unsubscribe(conn *wsCommon.WsConnection) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.subs, conn)
}
// Publish 广播消息到所有订阅连接(跳过已关闭连接;WriteJSON 自带写锁,并发安全)
func (h *execHub) Publish(msg *wsCommon.WsPushMsg) {
h.mu.Lock()
conns := make([]*wsCommon.WsConnection, 0, len(h.subs))
for c := range h.subs {
conns = append(conns, c)
}
h.mu.Unlock()
for _, c := range conns {
if c.IsClosed() {
h.Unsubscribe(c)
continue
}
_ = writeJSON(c, msg)
}
}
// ReportStart / ReportComplete 实现 ProgressReporter:节点进度广播
func (h *execHub) ReportStart(nodeId, nodeName string, nodeIndex, nodeCount int) {
h.Publish(&wsCommon.WsPushMsg{
Type: "node_start",
Message: fmt.Sprintf("开始执行(%d/%d): %s ", nodeIndex, nodeCount, nodeName),
Data: map[string]interface{}{
"nodeId": nodeId, "nodeName": nodeName,
"nodeIndex": nodeIndex, "nodeCount": nodeCount,
},
})
}
func (h *execHub) ReportComplete(nodeId, nodeName string, nodeIndex, nodeCount int) {
h.Publish(&wsCommon.WsPushMsg{
Type: "node_complete",
Message: fmt.Sprintf("执行完成(%d/%d): %s ", nodeIndex, nodeCount, nodeName),
Data: map[string]interface{}{
"nodeId": nodeId, "nodeName": nodeName,
"nodeIndex": nodeIndex, "nodeCount": nodeCount,
},
})
}
// ====================== 接管 / 取消 ======================
// SetCancel 预留取消函数(候选 hub 在尚未确定执行方时设置,供用户提前取消)
func (h *execHub) SetCancel(cancel context.CancelFunc) {
h.mu.Lock()
h.cancel = cancel
h.mu.Unlock()
}
// MarkOwned 标记执行方已接管本 hub(真正开始 BuildExecution 前调用)
func (h *execHub) MarkOwned() {
h.mu.Lock()
h.owned = true
h.mu.Unlock()
}
// TryOwn 原子接管:仅当未被持有才置 owned 并设 cancel,返回是否抢到所有权。
// 并发恢复(扫描/用户附着)用它在锁/DB 前置位,避免"检查 Owned→MarkOwned"竞态下
// 后到者覆盖先到者的 cancel(用户取消会取消错 ctx)。失败者只附着订阅、不重复拉起执行。
func (h *execHub) TryOwn(cancel context.CancelFunc) bool {
h.mu.Lock()
defer h.mu.Unlock()
if h.owned {
return false
}
h.owned = true
h.cancel = cancel
return true
}
func (h *execHub) Owned() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.owned
}
// CancelByUser 用户显式取消(workflow_cancel):置 userCancelled 标志并取消执行。
// 恢复例程据此把终态写成"用户已终止执行 / retryable=0"(永久取消,不再被扫描捞起)。
func (h *execHub) CancelByUser() {
h.userCancelled.Store(true)
h.mu.Lock()
c := h.cancel
h.mu.Unlock()
if c != nil {
c()
}
}
func (h *execHub) UserCancelled() bool {
return h.userCancelled.Load()
}
// Close 关闭 hub:注销注册、退订全部连接、清其 meta、close donesync.Once,可被
// 执行方与占用方重复调用)。仅当连接 meta 仍指向本 hub 时清空(指针比较防误清新订阅)。
func (h *execHub) Close() {
h.closeOnce.Do(func() {
key := hubKey(h.sessionId, h.flowId)
hubRegMu.Lock()
if hubReg[key] == h {
delete(hubReg, key)
}
hubRegMu.Unlock()
h.mu.Lock()
conns := make([]*wsCommon.WsConnection, 0, len(h.subs))
for c := range h.subs {
conns = append(conns, c)
}
h.closed = true
h.mu.Unlock()
for _, c := range conns {
if cur, ok := wsCommon.GetMetaT[*execHub](c, "execHub"); ok && cur == h {
c.SetMeta("execHub", nil)
c.SetMeta("execCancel", nil)
}
}
close(h.done)
})
}
// Done 返回 hub 关闭通知 channel(供订阅 watcher 等退出)
func (h *execHub) Done() <-chan struct{} {
return h.done
}
// getProgressHub 从 context 取 hubreporter 即 hub 本身)
func getProgressHub(ctx context.Context) *execHub {
if h, ok := GetProgressReporter(ctx).(*execHub); ok {
return h
}
return nil
}
// subscribeConnToHub 把连接挂到 hub 上并接入取消:
// conn meta execCancel = hub.CancelByUser → 现有 workflow_cancel 处理器(handleCancel)直接生效。
// 断开连接不触发取消(用户执行经 closeCtx→execCtx 既有链取消;恢复执行脱离连接,订阅者仅观察)。
// 注意:必须转成 context.CancelFunc 再存,否则 GetMetaT[context.CancelFunc] 的类型断言
// (动态类型须与命名类型完全一致)会因方法值类型为 func() 而失败,取消静默失效。
func subscribeConnToHub(conn *wsCommon.WsConnection, hub *execHub) {
hub.Subscribe(conn)
conn.SetMeta("execHub", hub)
conn.SetMeta("execCancel", context.CancelFunc(hub.CancelByUser))
}
// execAttach 恢复例程附着的用户连接(用户点击执行发现陈旧运行中记录时传入)。
// hub 为连接已订阅的候选 hub:恢复例程复用同一实例接管(registerHubIfAbsent 按 session+flow 去重,
// 全进程同一时刻至多一个 hub 实例),避免"占位被关→重建"竞态导致连接订阅/取消丢失。
type execAttach struct {
conn *wsCommon.WsConnection
sessionId string
flowId int64
hub *execHub // 可能为 nil(调用方无候选时恢复例程自行注册)
}
+70
View File
@@ -0,0 +1,70 @@
package flow
import (
"context"
"sync"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
)
// 运行中执行跟踪:优雅关停时取消全部执行(含脱离连接的恢复执行)并等待落完终态再退出,
// 避免"程序停止 → exec 仍卡在 status=1"。
//
// 背景:WS 执行(handleExecute)的 execCtx 派生自连接 closeCtxClose() 取消连接即随之中止落终态;
// 但恢复执行(recoverExecution)的 ctx 经 context.WithoutCancel 脱离连接,程序关停时若不显式取消,
// 恢复中的 exec 不会落终态(仍 status=1),重启要等心跳陈旧 60s 才能再捞。这里登记所有运行中执行,
// SetShuttingDown 统一取消、main 等待全部落库后再退出。
var (
execRunMu sync.Mutex
execRuns = make(map[string]context.CancelFunc)
)
// trackExecRun 登记一次运行中执行,返回 finish 在落完终态(recordWorkflow 后)调用。
// 每次运行唯一 key(重试/断点续跑复用同一 execId 也不冲突)。
func trackExecRun(cancel context.CancelFunc) (finish func()) {
key := uuid.NewString()
execRunMu.Lock()
execRuns[key] = cancel
execRunMu.Unlock()
return func() {
execRunMu.Lock()
delete(execRuns, key)
execRunMu.Unlock()
}
}
// cancelAllExecRuns 优雅关停:取消所有运行中执行。恢复执行 ctx 经 WithoutCancel 脱离连接,
// 必须显式取消才能随 WS 执行一起落终态(status=3/retryable=1)。
func cancelAllExecRuns() {
execRunMu.Lock()
cancels := make([]context.CancelFunc, 0, len(execRuns))
for _, c := range execRuns {
cancels = append(cancels, c)
}
execRunMu.Unlock()
for _, c := range cancels {
c()
}
}
// WaitExecRunsDrain 等待所有运行中执行落完终态(限时),供 main 优雅关停收尾后退出进程。
// 关停后不再启动新执行(execute/reExecute/recoverExecution 顶部有 IsShuttingDown 守卫),
// 因此运行中集合只减不增,轮询安全。
func WaitExecRunsDrain(timeout time.Duration) {
deadline := time.Now().Add(timeout)
for {
execRunMu.Lock()
n := len(execRuns)
execRunMu.Unlock()
if n == 0 {
return
}
if time.Now().After(deadline) {
g.Log().Warningf(context.Background(), "优雅关停等待执行落库超时,剩余 %d 个执行", n)
return
}
time.Sleep(100 * time.Millisecond)
}
}
+152 -84
View File
@@ -12,7 +12,6 @@ import (
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"gitea.redpowerfuture.com/red-future/common/utils"
@@ -56,44 +55,7 @@ func GetProgressReporter(ctx context.Context) ProgressReporter {
return nil
}
type wsProgressReporter struct {
conn *wsCommon.WsConnection
mu sync.Mutex
}
func (r *wsProgressReporter) ReportStart(nodeId, nodeName string, nodeIndex, nodeCount int) {
if r.conn.IsClosed() {
return
}
r.mu.Lock()
msg := &wsCommon.WsPushMsg{
Type: "node_start",
Message: fmt.Sprintf("开始执行(%d/%d): %s ", nodeIndex, nodeCount, nodeName),
Data: map[string]interface{}{
"nodeId": nodeId, "nodeName": nodeName,
"nodeIndex": nodeIndex, "nodeCount": nodeCount,
},
}
r.mu.Unlock()
_ = writeJSON(r.conn, msg)
}
func (r *wsProgressReporter) ReportComplete(nodeId, nodeName string, nodeIndex, nodeCount int) {
if r.conn.IsClosed() {
return
}
r.mu.Lock()
msg := &wsCommon.WsPushMsg{
Type: "node_complete",
Message: fmt.Sprintf("执行完成(%d/%d): %s ", nodeIndex, nodeCount, nodeName),
Data: map[string]interface{}{
"nodeId": nodeId, "nodeName": nodeName,
"nodeIndex": nodeIndex, "nodeCount": nodeCount,
},
}
r.mu.Unlock()
_ = writeJSON(r.conn, msg)
}
// 进度上报由 exec_hub.go 的 execHub 实现(单执行事件中枢,可多连接订阅);wsProgressCtxKey/ProgressReporter/GetProgressReporter 保留。
// ====================== 消息处理 ======================
@@ -108,22 +70,43 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
execCtx, execCancel := context.WithCancel(ctx)
// 替换旧 cancel,设入新 cancel
// 替换旧 cancel:同一连接上重新执行时取消上一次遗留的执行
if oldCancel := getExecCancel(conn); oldCancel != nil {
oldCancel()
}
conn.SetMeta("execCancel", execCancel)
//_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: "开始执行工作流"})
// 异步执行工作流(直接 goroutine,不依赖上游 workerPool 二次排队)
go func() {
// 事件中枢:同 session+flow 已有运行中执行(本进程)则复用其 hub,让本连接附着其进度;
// 否则新建注册为候选,由本执行(execute/reExecute)或恢复例程接管
hub, _ := registerHubIfAbsent(conn.SessionId, execPayload.FlowId, newExecHub(conn.SessionId, execPayload.FlowId))
if !hub.Owned() {
// 候选/未持有:预留取消为当前连接的 execCancel(新执行 MarkOwned 后生效);
// 已持有(同 session+flow 运行中执行)则保留持有者 cancel,避免覆盖导致取消错对象
hub.SetCancel(execCancel)
}
subscribeConnToHub(conn, hub)
progressCtx := context.WithValue(execCtx, wsProgressCtxKey{}, hub)
// 登记运行中执行:优雅关停(SetShuttingDown)统一取消本执行;finish 在落完终态后解除登记,
// main 的 WaitExecRunsDrain 据此等待 WS 执行落库后再退出(避免进程退出时记录仍卡 status=1)
finish := trackExecRun(execCancel)
defer finish()
// 落库用不带取消的 ctx(保留 request 值),保证前端终止/断连后记录仍能写入
saveCtx := context.WithoutCancel(ctx)
start := time.Now()
var execId int64
var execErr error
recorded := false
owner := false // 本 goroutine 是否为执行持有者(附着/触发恢复时不持有)
// 持有者收尾:落完终态广播后关闭 hub(退订全部连接/清 meta/注销)。
// 附着路径不置 owner,由运行中的持有方统一 Closepanic 路径在下方 recover defer 中置 owner 兜底。
// 先注册此 defer → 后注册的 panic defer 先执行(先广播再 Close
defer func() {
if owner {
hub.Close()
}
}()
defer func() {
if r := recover(); r != nil {
glog.Errorf(execCtx, "workflow panic: %v", r)
@@ -135,10 +118,11 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
recordExecutionFailure(saveCtx, conn.SessionId, execPayload.FlowId, execId, execErr)
recorded = true
}
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流异常", Error: fmt.Sprintf("%v", r)})
// panic 必然发生在本 goroutine 持有的执行内(附着/恢复路径不跑 BuildExecution,不会 panic
owner = true
hub.Publish(&wsCommon.WsPushMsg{Type: "error", Message: "工作流异常", Error: fmt.Sprintf("%v", r)})
}
}()
defer conn.SetMeta("execCancel", nil)
// 会话落库:前端 sessionId 对应会话已存在则复用,否则按流程名新建
flowName := defaultSessionName
@@ -150,14 +134,18 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流会话创建失败", Error: fmt.Sprintf("%v", e)})
}
reporter := &wsProgressReporter{conn: conn}
progressCtx := context.WithValue(execCtx, wsProgressCtxKey{}, reporter)
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: fmt.Sprintf("开始执行工作流(共 %d 个节点)", len(execPayload.FlowContent.Nodes))})
execId, execErr = executeOrResume(progressCtx, conn, execPayload)
if errors.Is(execErr, errExecAlreadyRunning) {
return // 运行中/已触发恢复:不写终态(状态由后台恢复收敛)
// 已附着到运行中执行 / 已触发恢复 / 其它节点在跑:
// 连接已订阅到 hub(运行中持有者广播进度与终态并统一 CloseexecuteOrResume 对占位 hub 已 Close)。
// 本 goroutine 不落终态、不 Closeowner=false),由持有方收敛。
return
}
owner = true
if !g.IsEmpty(execId) {
hub.execId = execId
}
// in-process 重试:程序报错且未耗尽 → retry_count++ 落库(保持 status=1,前端不闪失败)→ 续跑
retryCount := 0
@@ -168,15 +156,28 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
break
}
glog.Infof(saveCtx, "工作流执行失败,自动重试 %d/%dexecId=%d: %v", retryCount, execMaxRetryCount, execId, execErr)
_, execErr = reExecute(progressCtx, execId)
// 进程内重试:exec 仍为本进程持有的 status=1 运行中,条件重置仅允许从运行中重置
_, execErr = reExecute(progressCtx, execId, *flow.FlowExecutionStatusRunning.Code())
if errors.Is(execErr, errExecAlreadyRunning) {
return // 状态已被其它路径抢占:不写终态,由对方收敛
}
}
// 错误分类落库:用户取消 → retryable=0 不重试
// 程序报错(非取消→ retryable=1retry_count 已记(重试循环内递增;UpdateRetry 失败 break 时也为当前值)
// 错误分类:程序关停取消 → retryable=1(下次启动恢复续跑)
// 用户取消 → retryable=0 不重试;
// 程序报错(非取消)→ retryable=1retry_count 已记(重试循环内递增)
// retryable/retry_count 随终态 recordWorkflow 一次原子写入,不单独 UpdateRetry
var retryable, retryCnt *int
if execErr != nil {
if errors.Is(execErr, context.Canceled) {
_ = sessionDao.ExecWorkflowDao.UpdateRetry(saveCtx, execId, 0, 0)
} else {
_ = sessionDao.ExecWorkflowDao.UpdateRetry(saveCtx, execId, 1, retryCount)
switch {
case errors.Is(execErr, context.Canceled) && IsShuttingDown():
// 关停标记置位:连接 ctx 取消来自程序优雅关停,非用户取消,
// 换错误标记让 recordWorkflow 写"程序关停中断",并可重试以便下次启动恢复
retryable, retryCnt = intptr(1), intptr(retryCount)
execErr = errInterruptedByShutdown
case errors.Is(execErr, context.Canceled):
retryable, retryCnt = intptr(0), intptr(0)
default:
retryable, retryCnt = intptr(1), intptr(retryCount)
if retryCount >= execMaxRetryCount {
// 终局清理(Task 5 裁定):重试耗尽,exec 永久失败,清 flow_async_task 孤儿缓存
_ = flowDao.FlowAsyncTaskDao.DeleteByExecution(saveCtx, execId)
@@ -185,7 +186,7 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
}
if !g.IsEmpty(execId) {
glog.Infof(saveCtx, "工作流执行完成,execId: %v", execId)
recordWorkflow(saveCtx, execId, time.Since(start), execErr)
recordWorkflow(saveCtx, execId, time.Since(start), execErr, retryable, retryCnt)
recorded = true
} else if execErr != nil {
// 查询/创建执行记录失败(拿不到 execId)时,兜底把该会话+工作流最近一条"运行中"记录标记为失败
@@ -193,11 +194,12 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
recorded = true
}
if execErr != nil {
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流执行失败", Error: execErr.Error()})
// 终态广播(发起连接 + 附着订阅者)
hub.Publish(&wsCommon.WsPushMsg{Type: "error", Message: "工作流执行失败", Error: execErr.Error()})
return
}
// 成功:把本次执行保存的结果文件路径(exec_workflow_result)一并推给前端
_ = writeJSON(conn, &wsCommon.WsPushMsg{
hub.Publish(&wsCommon.WsPushMsg{
Type: "flow_complete",
Message: "工作流执行完成",
Data: map[string]interface{}{
@@ -212,8 +214,18 @@ func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload int
// 兜底按会话+工作流查最近一条仍处于"运行中"的记录标记为失败,避免前端已报错但记录卡在 Running。
// 最近记录已是成功/失败状态则不处理(可能是上一次执行的结果,不应误改)。
func recordExecutionFailure(ctx context.Context, sessionId string, flowId int64, execId int64, runErr error) {
// 兜底失败路径同样携带 retryable 分类(与 handleExecute 一致):
// 用户取消=0;其余(程序关停中断/程序报错)可重试=1,保证任意 status=3 写库都带分类
var retryable, retryCnt *int
if runErr != nil {
if errors.Is(runErr, context.Canceled) && !IsShuttingDown() {
retryable, retryCnt = intptr(0), intptr(0)
} else {
retryable, retryCnt = intptr(1), intptr(0)
}
}
if !g.IsEmpty(execId) {
recordWorkflow(ctx, execId, 0, runErr)
recordWorkflow(ctx, execId, 0, runErr, retryable, retryCnt)
return
}
lastExec, err := sessionDao.ExecWorkflowDao.GetLatestBySessionAndFlow(ctx, sessionId, flowId)
@@ -224,11 +236,16 @@ func recordExecutionFailure(ctx context.Context, sessionId string, flowId int64,
if lastExec.Status == nil || *lastExec.Status != *flow.FlowExecutionStatusRunning.Code() {
return
}
recordWorkflow(ctx, lastExec.Id, 0, runErr)
recordWorkflow(ctx, lastExec.Id, 0, runErr, retryable, retryCnt)
}
// recordWorkflow 把一次工作流执行写入 exec_workflow/exec_workflow_result:运行记录 + 输出文件结果
func recordWorkflow(ctx context.Context, id int64, duration time.Duration, runErr error) {
// intptr 取 int 值指针(recordWorkflow 的 retryable/retryCount 参数:nil=不改动)
func intptr(v int) *int { return &v }
// recordWorkflow 把一次工作流执行写入 exec_workflow/exec_workflow_result:运行记录 + 输出文件结果。
// retryable/retryCount 非 nil 时随终态同语句原子落库,避免"先 UpdateRetry 再 Update"两步写部分生效
// 导致 retryable 与 status/error_message 不一致(关停中断场景曾出现 retryable=0 但 message=程序关停中断)
func recordWorkflow(ctx context.Context, id int64, duration time.Duration, runErr error, retryable, retryCount *int) {
// exec_workflow 状态沿用 1-运行中,2-成功,3-失败;前端结果卡片也只识别 1/2/3
// 4 会误显示为"运行中"),故取消同样记为失败,错误信息写"用户已终止执行"
// error_message 存友好提示,error 存原始错误明细
@@ -236,26 +253,37 @@ func recordWorkflow(ctx context.Context, id int64, duration time.Duration, runEr
var errorMessage, errorDetail string
if runErr != nil {
status = flow.FlowExecutionStatusFailed
if errors.Is(runErr, context.Canceled) {
switch {
case errors.Is(runErr, context.Canceled):
errorMessage = errWorkflowTerminated
} else {
case errors.Is(runErr, errInterruptedByShutdown):
errorMessage = "程序关停中断"
default:
errorMessage = "工作流执行失败"
errorDetail = runErr.Error()
}
}
_, err := sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{
Id: id,
Status: status.Code(),
Duration: int64(duration.Seconds()),
ErrorMessage: errorMessage,
Error: errorDetail,
})
if err != nil {
glog.Errorf(ctx, "exec_workflow 落库失败: %v", err)
data := map[string]any{
entity.ExecWorkflowCol.Status: *status.Code(),
}
if d := int64(duration.Seconds()); d != 0 {
data[entity.ExecWorkflowCol.Duration] = d
}
if errorMessage != "" {
data[entity.ExecWorkflowCol.ErrorMessage] = errorMessage
}
if errorDetail != "" {
data[entity.ExecWorkflowCol.Error] = errorDetail
}
if retryable != nil {
data[entity.ExecWorkflowCol.Retryable] = *retryable
data[entity.ExecWorkflowCol.RetryCount] = *retryCount
}
if err := sessionDao.ExecWorkflowDao.UpdateMap(ctx, id, data); err != nil {
glog.Errorf(ctx, "exec_workflow 终态落库失败 execId=%d: %v", id, err)
return
}
// 执行成功:重新执行复用了同一条记录,OmitEmpty 的 Update 会跳过空 error_message/error
// 需显式清空,避免上一次失败的报错残留
// 执行成功:重新执行复用了同一条记录,需显式清空,避免上一次失败的报错残留
if runErr == nil {
if _, err := sessionDao.ExecWorkflowDao.ClearError(ctx, id); err != nil {
glog.Errorf(ctx, "exec_workflow 报错信息清空失败: %v", err)
@@ -315,10 +343,20 @@ func executeOrResume(ctx context.Context, conn *wsCommon.WsConnection, req *sess
// 心跳新鲜 → 真在跑,不新建避免双跑;心跳陈旧 → 僵尸,触发后台恢复。
// 都不新建执行,恢复在后台完成,完成后状态自然收敛(spec §9)。
nowMs := time.Now().UnixMilli()
hub := getProgressHub(ctx)
owned := hub != nil && hub.Owned()
if lastExec.LastHeartbeat < nowMs-int64(heartbeatStaleAfter/time.Millisecond) {
// 心跳陈旧 = 僵尸遗留:本进程恢复例程正在跑则附着其进度;否则拉起恢复并附着。
// 候选 hub 直接交给恢复例程复用同一实例(不 close):最终执行者与连接共用一 hub,
// 取消/进度不因"占位被关→重建"竞态丢失
if !owned {
go recoverExecution(context.WithoutCancel(ctx), lastExec.Id, &execAttach{conn: conn, sessionId: conn.SessionId, flowId: req.FlowId, hub: hub})
}
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "round_start", Message: "检测到未完成执行,正在恢复", Data: map[string]interface{}{"id": lastExec.Id}})
go recoverExecution(context.WithoutCancel(ctx), lastExec.Id)
} else {
// 心跳新鲜 = 真在跑:owned → 连接已附着本进程运行中执行,直接订阅其进度;
// !owned → 其它节点在跑(无法跨节点附着)或本进程执行尚未接管(候选 hub 会被其接管)。
// 不 close 候选:避免在 execute()/恢复 TryOwn 前误关,导致执行者进度/取消无人接收
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "round_start", Message: "工作流正在执行中", Data: map[string]interface{}{"id": lastExec.Id}})
}
return lastExec.Id, errExecAlreadyRunning
@@ -328,7 +366,7 @@ func executeOrResume(ctx context.Context, conn *wsCommon.WsConnection, req *sess
"id": lastExec.Id,
}})
glog.Infof(ctx, "工作流断点续跑,execId: %v", lastExec.Id)
return reExecute(ctx, lastExec.Id)
return reExecute(ctx, lastExec.Id, *flow.FlowExecutionStatusFailed.Code())
}
glog.Infof(ctx, "工作流全新执行,lastExec: %v", lastExec)
return execute(ctx, conn, lastExec.Id, lastExec.Status, req)
@@ -353,6 +391,10 @@ func flowContentEqual(a, b *entity.FlowInfo) bool {
// execute 执行工作流(首次执行;同会话+同工作流最近一次执行为失败状态时复用该记录重新执行,不新建数据)
func execute(ctx context.Context, conn *wsCommon.WsConnection, execId int64, status flow.FlowExecutionStatus, req *sessionDto.WebSocketExecWorkflowReq) (id int64, err error) {
// 优雅关停期间不再启动新执行(避免关停后仍登记运行、落库被退出进程打断;走 Canceled 分类落终态)
if IsShuttingDown() {
return 0, context.Canceled
}
var nodeGroupId = uuid.NewString()
if g.IsEmpty(execId) {
glog.Infof(ctx, "工作流全新执行execute,无历史记录")
@@ -373,10 +415,15 @@ func execute(ctx context.Context, conn *wsCommon.WsConnection, execId int64, sta
// 需解引用按值比较,否则复用失败记录时不会重置为 Running、也不更新 RequestParams
if status != nil && *status == *flow.FlowExecutionStatusFailed.Code() {
glog.Infof(ctx, "工作流断点续跑executeexecId: %v", execId)
err = sessionDao.ExecWorkflowDao.ResetRunning(ctx, execId, nodeGroupId)
var reset bool
reset, err = sessionDao.ExecWorkflowDao.ResetRunning(ctx, execId, nodeGroupId, *flow.FlowExecutionStatusFailed.Code())
if err != nil {
return
}
if !reset {
// 已被其它路径(恢复例程/并发触发)抢先重置为运行中:放弃本次执行,状态由持有方收敛
return execId, errExecAlreadyRunning
}
// 复用失败记录时参数可能已变:把新参数落库,供后续 reExecute/恢复例程按记录参数续跑
_, err = sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{Id: execId, RequestParams: req.FlowContent})
if err != nil {
@@ -402,8 +449,13 @@ func execute(ctx context.Context, conn *wsCommon.WsConnection, execId int64, sta
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "round_start", Message: "运行开始", Data: map[string]interface{}{
"id": execId,
}})
stop := startHeartbeat(ctx, execId)
// WS 路径心跳与 BuildExecution 已共享同一 ctx(连接取消/DB 故障同生共死),无需租约丢失回调
stop := startHeartbeat(ctx, execId, nil)
defer stop()
// 接管候选 hub:本 goroutine 确认真正启动执行前标记 owned,供并发附着连接识别运行中持有者
if h := getProgressHub(ctx); h != nil && !h.Owned() {
h.MarkOwned()
}
err = BuildExecution(ctx, true, req.FlowId, execId, nodeGroupId, conn.SessionId, req.FlowContent)
if err != nil {
return
@@ -411,19 +463,35 @@ func execute(ctx context.Context, conn *wsCommon.WsConnection, execId int64, sta
return execId, nil
}
// reExecute 重新执行工作流
func reExecute(ctx context.Context, execWorkflowId int64) (id int64, err error) {
// reExecute 重新执行工作流
// prevStatus:调用方当前观察到的记录状态(失败=3 断点续跑;运行中=1 本进程自动重试),
// 传给 ResetRunning 做条件重置:仅当记录仍处于该状态时才重置(原子防双跑)。
// 状态已被其它路径抢先变更时返回 errExecAlreadyRunning,外层不写终态、由持有方收敛。
func reExecute(ctx context.Context, execWorkflowId int64, prevStatus int8) (id int64, err error) {
// 优雅关停期间不再启动新执行(返回 Canceled 让外层分类落 errInterruptedByShutdown 终态)
if IsShuttingDown() {
return 0, context.Canceled
}
flowInfo, err := sessionDao.ExecWorkflowDao.GetById(ctx, execWorkflowId)
if err != nil {
return
}
var nodeGroupId = uuid.NewString()
err = sessionDao.ExecWorkflowDao.ResetRunning(ctx, flowInfo.Id, nodeGroupId)
reset, err := sessionDao.ExecWorkflowDao.ResetRunning(ctx, flowInfo.Id, nodeGroupId, prevStatus)
if err != nil {
return
}
stop := startHeartbeat(ctx, flowInfo.Id)
if !reset {
// 状态已被其它路径(恢复例程/并发触发)抢先重置为运行中:放弃续跑
return flowInfo.Id, errExecAlreadyRunning
}
// WS 路径心跳与 BuildExecution 已共享同一 ctx(连接取消/DB 故障同生共死),无需租约丢失回调
stop := startHeartbeat(ctx, flowInfo.Id, nil)
defer stop()
// 接管候选 hub:确认续跑启动前标记 owned(与 execute 一致)
if h := getProgressHub(ctx); h != nil && !h.Owned() {
h.MarkOwned()
}
err = BuildExecution(ctx, false, flowInfo.FlowId, flowInfo.Id, nodeGroupId, flowInfo.SessionId, flowInfo.RequestParams)
if err != nil {
return
@@ -150,6 +150,14 @@ func HttpCallResultLambda(ctx context.Context, nodeInput *flowDto.NodeExecutionI
}
}
}
// 后台恢复续跑无 HTTP 请求时(ctx 携带合成 user、无 token),补充 X-User-Info 供下游内部服务
// GetUserInfo 识别租户;正常执行 ctx 无 user(仅 request 有 Authorization),不会注入,
// 避免把内部身份透传给任意外部 URL。与 gateway/model.go requestHeaders 的恢复兜底保持一致
if headers["X-User-Info"] == "" {
if u := ctx.Value("user"); u != nil {
headers["X-User-Info"] = gconv.String(u)
}
}
}
g.Log().Debugf(ctx, "httpCallResultLambda: body: %v", body)
@@ -1,33 +0,0 @@
package flow
import (
"testing"
"ai-agent/workflow/model/entity"
)
func TestPlanSegmentResumePositionBased(t *testing.T) {
params := []map[string]any{{"a": 1}, {"a": 2}, {"a": 3}}
saved := map[int]entity.SegmentRef{0: {Key: "video", URL: "url0"}, 2: {Key: "video", URL: "url2"}}
idxList, needGen := planSegmentResume(params, saved)
if len(idxList) != 3 || len(needGen) != 3 {
t.Fatalf("长度应等于段数")
}
if needGen[0] || !needGen[1] || needGen[2] {
t.Fatalf("位置0/2已保存应复用,位置1应生成: %v", needGen)
}
}
func TestMergeSegmentOutputsOrder(t *testing.T) {
idxList := []int{0, 1, 2}
needGen := []bool{false, true, false}
newRes := [][]map[string]any{nil, {{"video": "new1"}}, nil}
saved := map[int]entity.SegmentRef{0: {Key: "video", URL: "url0"}, 2: {Key: "video", URL: "url2"}}
merged := mergeSegmentOutputs(idxList, needGen, newRes, saved)
if len(merged) != 3 {
t.Fatalf("应合并 3 条: %v", merged)
}
if merged[0]["video"] != "url0" || merged[1]["video"] != "new1" || merged[2]["video"] != "url2" {
t.Fatalf("合并顺序应为 url0,new1,url2: %v", merged)
}
}
@@ -205,7 +205,8 @@ func getMediaTask(ctx context.Context, kind TaskKind, taskID string) (*MergeTask
return res, nil
}
// requestHeaders 透传当前请求头(含 Authorization / X-User-Info),供内部服务鉴权使用
// requestHeaders 透传当前请求头(含 Authorization / X-User-Info),供内部服务鉴权使用
// 后台恢复续跑无 HTTP 请求时(ctx 携带合成 user),补充 X-User-Info 供下游 GetUserInfo 识别租户
func requestHeaders(ctx context.Context) map[string]string {
headers := make(map[string]string)
if r := g.RequestFromCtx(ctx); r != nil {
@@ -215,6 +216,11 @@ func requestHeaders(ctx context.Context) map[string]string {
}
}
}
if headers["X-User-Info"] == "" {
if u := ctx.Value("user"); u != nil {
headers["X-User-Info"] = gconv.String(u)
}
}
return headers
}
+169 -30
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sync/atomic"
"time"
"ai-agent/workflow/consts/flow"
@@ -12,6 +13,7 @@ import (
"ai-agent/workflow/model/entity"
"gitea.redpowerfuture.com/red-future/common/beans"
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"
@@ -23,12 +25,36 @@ const (
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 次尝试)
)
// errExecAlreadyRunning 用户触发时该执行已在运行(本节点/其它节点后台恢复),不新建执行
var errExecAlreadyRunning = errors.New("工作流正在执行中,不重复执行")
// errInterruptedByShutdown 程序优雅关停导致连接 ctx 取消时的错误标记(区别于用户主动取消)。
// 落库为 status=3 + retryable=1,下次启动恢复扫描捞起续跑。
var errInterruptedByShutdown = errors.New("程序关停中断")
// shuttingDown 优雅关停标记:程序收到退出信号后置位。
// 用于区分"程序关停导致的 WS 连接取消"与"用户主动取消",避免前者被误分类为不可重试。
var shuttingDown atomic.Bool
// SetShuttingDown 置位优雅关停标记并取消所有运行中执行(main 信号处理在 Close() 前调用)。
// 恢复执行 ctx 经 WithoutCancel 脱离连接,必须在此显式取消,否则程序关停时恢复中的 exec
// 不会落终态(仍 status=1);取消后 BuildExecution 随之中止、走恢复错误分支写 status=3/retryable=1。
func SetShuttingDown() {
shuttingDown.Store(true)
cancelAllExecRuns()
}
// IsShuttingDown 是否处于优雅关停
func IsShuttingDown() bool {
return shuttingDown.Load()
}
// shouldRetry 错误分类(spec §3):用户取消不重试;其余(模型/DB/网络/超时/panic)程序报错重试
func shouldRetry(err error) bool {
return err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, errExecAlreadyRunning)
@@ -56,6 +82,10 @@ func StartRecoveryLoop(ctx context.Context) {
// 恢复运行在无 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()
@@ -65,30 +95,64 @@ func scanAndRecover(ctx context.Context) {
return
}
for _, r := range rows {
go recoverExecution(context.WithoutCancel(scanCtx), r.Id)
// 扫描触发无附着连接(attach=nil):hub 在确认可恢复后按 exec 的 session+flow 建立
go recoverExecution(context.WithoutCancel(scanCtx), r.Id, nil)
}
}
// recoverExecution 统一恢复例程(spec §7):抢锁 → 判定 → 置运行中续跑。
// 两个触发源共用:启动/周期扫描、executeOrResume 对僵尸行的用户触发。
func recoverExecution(parentCtx context.Context, execId int64) {
lock := NewRedisLock(fmt.Sprintf("workflow:exec:recover:%d", execId), int64(recoverLockTTL/time.Second))
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 2*time.Hour)
defer cancel()
// 两个触发源共用:启动/周期扫描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
}
ok, err := lock.Acquire(ctx)
// 事件中枢(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)
}
lock := NewRedisLock(fmt.Sprintf("workflow:exec:recover:%d", execId), int64(recoverLockTTL/time.Second))
// 抢锁/前置判定用独立短超时 ctx:该阶段只做 Redis SET + 一次 DB 读 + 一次条件重置,应毫秒级完成
lockCtx, lockCancel := context.WithTimeout(context.WithoutCancel(parentCtx), recoverLockPhase)
defer lockCancel()
ok, err := lock.Acquire(lockCtx)
if err != nil {
g.Log().Errorf(ctx, "恢复抢锁失败 execId=%d: %v", execId, err)
g.Log().Errorf(lockCtx, "恢复抢锁失败 execId=%d: %v", execId, err)
return
}
if !ok {
g.Log().Infof(ctx, "恢复锁被其它节点持有,跳过 execId=%d", execId)
g.Log().Infof(lockCtx, "恢复锁被其它节点持有,跳过 execId=%d", execId)
return
}
defer lock.Release(context.Background())
// 抢锁后重读:此刻租户未知(该 exec 可能属于任意租户),必须跨租户读
exec, err := sessionDao.ExecWorkflowDao.GetByIdNoTenant(ctx, execId)
exec, err := sessionDao.ExecWorkflowDao.GetByIdNoTenant(lockCtx, execId)
if err != nil || exec == nil {
return
}
@@ -96,37 +160,102 @@ func recoverExecution(parentCtx context.Context, execId int64) {
return // 锁等待期间状态已变化(其它节点已恢复/用户取消/已耗尽)
}
stop := startHeartbeat(ctx, execId)
defer stop()
// 事件中枢(scan 路径,无附着连接):此刻才知道 exec 的 session+flow,建 hub 供后续用户连接附着
//(所有权在重置成功后统一接管,见下)
if attach == nil {
hub, _ = registerHubIfAbsent(exec.SessionId, exec.FlowId, newExecHub(exec.SessionId, exec.FlowId))
}
// 置运行中并续跑(BuildExecution false → 断点续跑:checkpoint 有则从中继续,无则从头 + 段/memo 复用)
// 置运行中并续跑(BuildExecution false → 断点续跑:checkpoint 有则从中继续,无则从头 + 段/memo 复用)
// 心跳在条件重置成功后才启动:避免对未抢到重置权的 exec 空跑心跳
saveCtx := context.WithoutCancel(parentCtx)
// 恢复无 HTTP 用户,但图节点 lambda 的 INSERTnode_execution/flow_async_task/segment_result)走
// insertHook 硬性要求 user;用 exec 所属租户合成系统用户 ctx(保留 span),供后续落库使用
userCtx := context.WithValue(saveCtx, "user", &beans.User{UserName: "workflow-recover", TenantId: exec.TenantId})
userCtx := context.WithValue(saveCtx, "user", &beans.User{UserName: exec.Creator, TenantId: exec.TenantId})
nodeGroupId := uuid.NewString() // 置运行中与图执行的节点组标识须一致
if err := sessionDao.ExecWorkflowDao.ResetRunning(userCtx, execId, nodeGroupId); err != nil {
g.Log().Errorf(ctx, "恢复置运行中失败 execId=%d: %v", execId, err)
// 条件重置(原子防与用户断点续跑双跑):仅当仍可恢复(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
}
err = BuildExecution(userCtx, false, exec.FlowId, execId, nodeGroupId, exec.SessionId, exec.RequestParams)
if err != nil {
// 续跑失败:错误分类落库(retryable/retry_count),留给下一轮扫描决定是否再恢复
if errors.Is(err, context.Canceled) {
_ = sessionDao.ExecWorkflowDao.UpdateRetry(userCtx, execId, 0, exec.RetryCount)
if !reset {
// 状态已被其它路径(用户 reExecute/execute 或其它节点恢复)抢先重置:放弃续跑,状态由持有方收敛,不落终态
g.Log().Infof(lockCtx, "execId=%d 已被其它路径抢先重置为运行中,跳过恢复", execId)
return
}
// 事件中枢所有权:此时已抢到锁+重置权,本 goroutine 是实际执行者,接管 hubTryOwn 原子置 owned+cancel)。
// 已有持有者(同 session+flow 另有执行在跑)则本恢复不持有 hub:锁与重置权已到手,放弃会留 status=1
// 无主,继续执行但进度不广播(恢复仍正常完成落终态)。
if hub != nil {
if !hub.TryOwn(topCancel) {
hub = nil
} else {
_ = sessionDao.ExecWorkflowDao.UpdateRetry(userCtx, execId, 1, exec.RetryCount+1)
// 终局清理(Task 5 用户裁定):非用户取消且重试耗尽 → 执行永久失败
// 清理该 exec 残留的 flow_async_task 孤儿缓存,避免未来复用同一 execId 的运行误取到过期 done 结果
if exec.RetryCount+1 >= execMaxRetryCount {
_ = flowDao.FlowAsyncTaskDao.DeleteByExecution(userCtx, execId)
defer hub.Close()
// 恢复建立前用户已取消(占位期 workflow_cancel 只置标志、cancel 尚未设置):立即中止
// 走下方 UserCancelled 分类写永久取消(retryable=0
if hub.UserCancelled() {
topCancel()
}
}
recordWorkflow(userCtx, execId, 0, err)
}
// 执行与心跳绑定同一 execCtx(带 recoverExecTimeout 上限):
// 心跳停止(超时/进程死/租约丢失)与 BuildExecution 中止必须同步,否则出现
// "心跳已过期但执行还活着" 的窗口,被其它节点扫描恢复导致双跑。
// 心跳连续失败达陈旧阈值时回调 execCancel 中止执行(心跳与执行同生共死)。
// 从函数顶部登记的 topCtx 派生执行 ctx:注入用户信息(供 insertHook 落库)+ 12h 执行超时上限。
// 关停时 cancelAllExecRuns 取消 topCancel → 本 ctx 随之取消,BuildExecution 中止后走下方错误分类落终态。
execCtx, execCancel := context.WithTimeout(context.WithValue(topCtx, "user", &beans.User{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,恢复扫描不再捞起,
// 杜绝"取消→恢复→再取消"循环;清 flow_async_task 孤儿缓存,落"用户已终止执行"
if hub != nil && hub.UserCancelled() {
retryable, retryCnt := 0, 0
_ = flowDao.FlowAsyncTaskDao.DeleteByExecution(userCtx, execId)
recordWorkflow(userCtx, execId, 0, context.Canceled, &retryable, &retryCnt)
hub.Publish(&wsCommon.WsPushMsg{Type: "error", Message: errWorkflowTerminated})
return
}
// 续跑失败:恢复例程无用户,任意错误(含执行超时/租约丢失取消/模型/DB/网络/panic
// 一律 retryable=1 交下一轮扫描决定是否再恢复;重试耗尽才终局失败。
retryable, retryCnt := 1, exec.RetryCount+1
// 优雅关停导致的取消:换错误标记让 recordWorkflow 写"程序关停中断"(与 WS 路径一致),
// 仍 retryable=1 下次启动扫描捞起续跑;非关停的取消(租约丢失/执行超时)保留原错误
if errors.Is(err, context.Canceled) && IsShuttingDown() {
err = errInterruptedByShutdown
}
// 终局清理(Task 5 用户裁定):重试耗尽 → 执行永久失败,
// 清理该 exec 残留的 flow_async_task 孤儿缓存,避免未来复用同一 execId 的运行误取到过期 done 结果
if exec.RetryCount+1 >= execMaxRetryCount {
_ = flowDao.FlowAsyncTaskDao.DeleteByExecution(userCtx, execId)
}
recordWorkflow(userCtx, execId, 0, err, &retryable, &retryCnt)
// 终态广播(附着连接;scan 路径无连接则无人接收)
if hub != nil {
hub.Publish(&wsCommon.WsPushMsg{Type: "error", Message: "工作流执行失败", Error: err.Error()})
}
return
}
// 续跑成功:recordWorkflow 落 status=2BuildExecution 已清理 checkpoint/segment_result/flow_async_task
recordWorkflow(userCtx, execId, 0, nil)
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),
}})
}
}
// isRecoverable 判定可恢复(spec §3):僵尸运行中(status=1 且心跳陈旧)或可重试失败(status=3 且 retryable=1 且未耗尽)。
@@ -147,19 +276,29 @@ func isRecoverable(exec *entity.ExecWorkflow, nowMs int64) bool {
}
// startHeartbeat 后台心跳 goroutine:每 30s touch last_heartbeat,返回 stop 函数。
// 覆盖正常执行与恢复执行,崩溃前最后一次心跳即崩溃近似时间戳(spec §4)
func startHeartbeat(ctx context.Context, execId int64) func() {
// 覆盖正常执行与恢复执行,崩溃前最后一次心跳即崩溃近似时间戳(spec §4)
// onLeaseLost:心跳连续失败达到陈旧阈值(租约丢失)时回调——调用方应取消执行 ctx,
// 使心跳与执行同生共死,避免"心跳已过期但执行还活着"的窗口被其它节点恢复导致双跑。
func startHeartbeat(ctx context.Context, execId int64, onLeaseLost func()) func() {
stopCh := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(heartbeatInterval)
defer ticker.Stop()
var consecutiveFail int
for {
select {
case <-ticker.C:
if err := sessionDao.ExecWorkflowDao.TouchHeartbeat(ctx, execId); err != nil {
g.Log().Warningf(ctx, "心跳落库失败 execId=%d: %v", execId, err)
consecutiveFail++
if onLeaseLost != nil && consecutiveFail >= heartbeatMaxFail {
g.Log().Errorf(ctx, "心跳连续失败 %d 次,租约丢失,中止执行 execId=%d", consecutiveFail, execId)
onLeaseLost()
}
} else {
consecutiveFail = 0
}
case <-stopCh:
return
@@ -1,60 +0,0 @@
package flow
import (
"context"
"errors"
"testing"
"time"
"ai-agent/workflow/consts/flow"
"ai-agent/workflow/model/entity"
)
func TestIsRecoverable(t *testing.T) {
now := time.Now().UnixMilli()
staleBefore := now - int64(heartbeatStaleAfter/time.Millisecond) - 1
fresh := now // 心跳新鲜
running := func(hb int64) *entity.ExecWorkflow {
return &entity.ExecWorkflow{Status: flow.FlowExecutionStatusRunning.Code(), LastHeartbeat: hb}
}
failed := func(retryable, retryCount int) *entity.ExecWorkflow {
return &entity.ExecWorkflow{Status: flow.FlowExecutionStatusFailed.Code(), Retryable: retryable, RetryCount: retryCount}
}
cases := []struct {
name string
exec *entity.ExecWorkflow
want bool
}{
{"nil", nil, false},
{"status=1 心跳陈旧 → 恢复", running(staleBefore), true},
{"status=1 心跳新鲜 → 不恢复", running(fresh), false},
{"status=1 心跳为0(老数据)→ 恢复", running(0), true},
{"status=3 retryable=1 未耗尽 → 恢复", failed(1, 0), true},
{"status=3 retryable=1 已耗尽 → 不恢复", failed(1, execMaxRetryCount), false},
{"status=3 retryable=0(用户取消)→ 不恢复", failed(0, 0), false},
{"status=2 成功 → 不恢复", &entity.ExecWorkflow{Status: flow.FlowExecutionStatusSuccess.Code()}, false},
}
for _, c := range cases {
if got := isRecoverable(c.exec, now); got != c.want {
t.Fatalf("%s: got %v want %v", c.name, got, c.want)
}
}
}
func TestShouldRetry(t *testing.T) {
canceled := context.Canceled
if shouldRetry(canceled) {
t.Fatalf("用户取消不应重试")
}
if shouldRetry(nil) {
t.Fatalf("nil 不应重试")
}
if shouldRetry(errExecAlreadyRunning) {
t.Fatalf("运行中标记不应重试")
}
if !shouldRetry(errors.New("模型调用失败")) {
t.Fatalf("程序报错应重试")
}
}
-52
View File
@@ -1,52 +0,0 @@
package flow
import (
"context"
"os"
"testing"
"github.com/gogf/gf/v2/frame/g"
)
// TestRedisLockMutualExclusion 验证互斥与防误删(需 AI_AGENT_TEST_REDIS=1 且本地 Redis 可用)
func TestRedisLockMutualExclusion(t *testing.T) {
if os.Getenv("AI_AGENT_TEST_REDIS") != "1" {
t.Skip("skip: AI_AGENT_TEST_REDIS not set")
}
ctx := context.Background()
key := "workflow:exec:recover:test-lock"
// 清理残留
g.Redis().Del(ctx, key)
l1 := NewRedisLock(key, 60)
l2 := NewRedisLock(key, 60)
ok1, err := l1.Acquire(ctx)
if err != nil || !ok1 {
t.Fatalf("l1 acquire should succeed: ok=%v err=%v", ok1, err)
}
ok2, err := l2.Acquire(ctx)
if err != nil {
t.Fatalf("l2 acquire err: %v", err)
}
if ok2 {
t.Fatalf("l2 不应抢到已被 l1 持有的锁")
}
// l2 尝试释放 l1 的锁:token 不匹配,不应删除
if err := l2.Release(ctx); err != nil {
t.Fatalf("l2 release err: %v", err)
}
ok2, _ = l2.Acquire(ctx)
if ok2 {
t.Fatalf("l2 释放他人锁后不应能抢到(l1 锁应仍在)")
}
// l1 释放后 l2 可抢到
if err := l1.Release(ctx); err != nil {
t.Fatalf("l1 release err: %v", err)
}
ok2, err = l2.Acquire(ctx)
if err != nil || !ok2 {
t.Fatalf("l1 释放后 l2 应可抢到: ok=%v err=%v", ok2, err)
}
l2.Release(ctx)
g.Redis().Del(ctx, key)
}