Files
ai-agent/workflow/service/flow/exec_record.go
T
19904408334 d699f7ce14 feat(workflow): 增加工作流计费与执行生命周期管理
- 新增计费模块:执行开始建单、终态结算/取消/失败处理,支持按条/按秒/按token计费
- 新增执行生命周期跟踪:优雅关停时取消运行中执行并等待落库
- 新增异步任务等待/通知机制(Wait/Notify)
- 重构执行记录落库与进度上报,统一失败分类与重试语义
- 重命名文件:async_task.go→async.go、flow_checkpoint_store.go→exec_checkpoint.go、flow_graph_util.go→exec_record.go
- 更新 .gitignore 与数据库密码配置
2026-09-03 13:22:22 +08:00

264 lines
11 KiB
Go
Raw 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"
"gitea.redpowerfuture.com/red-future/common/oss"
"github.com/cloudwego/eino/compose"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
"ai-agent/gateway"
"ai-agent/workflow/consts/flow"
"ai-agent/workflow/consts/node"
nodeDao "ai-agent/workflow/dao/node"
sessionDao "ai-agent/workflow/dao/session"
flowDto "ai-agent/workflow/model/dto/flow"
nodeDto "ai-agent/workflow/model/dto/node"
"ai-agent/workflow/model/entity"
)
// ====================== 执行记录落库 ======================
// recordExecutionFailure 记录一次失败状态。有 execId 直接更新该记录;拿不到 execId
// executeOrResume 在创建记录后、返回前 panic,或查询/创建执行记录失败)时,
// 兜底按会话+工作流查最近一条仍处于"运行中"的记录标记为失败,避免前端已报错但记录卡在 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, retryable, retryCnt)
return
}
lastExec, err := sessionDao.ExecWorkflowDao.GetLatestBySessionAndFlow(ctx, sessionId, flowId)
if err != nil || lastExec == nil {
glog.Errorf(ctx, "兜底标记失败状态失败: sessionId=%s flowId=%d err=%v", sessionId, flowId, err)
return
}
if lastExec.Status == nil || *lastExec.Status != *flow.FlowExecutionStatusRunning.Code() {
return
}
recordWorkflow(ctx, lastExec.Id, 0, runErr, retryable, retryCnt)
}
// 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 存原始错误明细
status := flow.FlowExecutionStatusSuccess
var errorMessage, errorDetail string
if runErr != nil {
status = flow.FlowExecutionStatusFailed
switch {
case errors.Is(runErr, context.Canceled):
errorMessage = errWorkflowTerminated
case errors.Is(runErr, errInterruptedByShutdown):
errorMessage = "程序关停中断"
default:
errorMessage = "工作流执行失败"
errorDetail = runErr.Error()
}
}
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
}
// 执行成功:重新执行复用了同一条记录,需显式清空,避免上一次失败的报错残留
if runErr == nil {
if _, err := sessionDao.ExecWorkflowDao.ClearError(ctx, id); err != nil {
glog.Errorf(ctx, "exec_workflow 报错信息清空失败: %v", err)
}
}
// 工作流计费:终态结算(成功→Settle/用户取消→Cancel/永久失败→Fail/可恢复→跳过)。
// recordWorkflow 汇聚全部路径(WS/恢复/panic),此处一处接线全覆盖;计费错误仅记日志不拖垮落库
settleBilling(ctx, id, runErr, retryable, retryCount)
}
// workflowResultFileUrls 查询指定工作流执行保存的结果文件路径(带文件前缀,与 session/get 返回一致)
func workflowResultFileUrls(ctx context.Context, execId int64) []string {
results, err := sessionDao.ExecWorkflowResultDao.ListByExecId(ctx, execId)
if err != nil {
glog.Errorf(ctx, "查询工作流结果路径失败: %v", err)
return nil
}
prefix, _ := oss.GetFileAddressPrefix(ctx)
urls := make([]string, 0, len(results))
for _, r := range results {
if r.ResultFileUrl != "" {
urls = append(urls, prefix+r.ResultFileUrl)
}
}
return urls
}
// ====================== 节点执行记录 ======================
// BuildNodeExecutionInput 构建节点执行入参,包含中断恢复逻辑
func BuildNodeExecutionInput(ctx context.Context, input any, flowNode entity.FlowNode) (*flowDto.FlowExecutionInput, *flowDto.NodeExecutionInput, error) {
execInput := new(flowDto.FlowExecutionInput)
wasInterrupted, _, _ := compose.GetInterruptState[any](ctx)
if wasInterrupted {
if err := compose.ProcessState(ctx, func(_ context.Context, s *flowDto.NodeExecutionState) error {
execInput = s.SavedFlowInput
return nil
}); err != nil {
return nil, nil, fmt.Errorf("节点:%v 进程状态读取失败: %v", flowNode.Name, err)
}
// 兼容旧 checkpoint(无 SavedFlowInput 时,降级使用 input 参数)
if execInput == nil {
var ok bool
execInput, ok = input.(*flowDto.FlowExecutionInput)
if !ok {
return nil, nil, fmt.Errorf("节点:%v 进程状态为空,节点入参类型不匹配", flowNode.Name)
}
if g.IsEmpty(execInput) {
return nil, nil, fmt.Errorf("节点:%v 进程状态为空,节点入参参数为空", flowNode.Name)
}
}
// 续跑必定非全新执行:checkpoint 恢复的 SavedFlowInput 里 ForceNewRun 是上次(fresh)执行留下的 true,
// 不清则 ModelLambda 误走"清段重生成"而非复用已成功段
execInput.ForceNewRun = false
} else {
var ok bool
execInput, ok = input.(*flowDto.FlowExecutionInput)
if !ok {
return nil, nil, fmt.Errorf("节点:%v 入参类型不匹配", flowNode.Name)
}
if g.IsEmpty(execInput) {
return nil, nil, fmt.Errorf("节点:%v 入参参数为空", flowNode.Name)
}
}
configMap := execInput.ConfigMap
currentConfig := configMap[flowNode.Id]
if currentConfig == nil {
return nil, nil, fmt.Errorf("节点:%v 节点信息为空", flowNode.Name)
}
// 构建节点执行入参
realInput := &flowDto.NodeExecutionInput{
Config: currentConfig,
Global: execInput,
}
return execInput, realInput, nil
}
// HandleSuccessfulNodeExecution 处理节点执行成功的后续操作
func HandleSuccessfulNodeExecution(ctx context.Context, execInput *flowDto.FlowExecutionInput, realInput *flowDto.NodeExecutionInput, nodeExecutionId int64, flowNode entity.FlowNode, durationMs int64) error {
// 上传输出到OSS
ossResult, err := gateway.Upload(ctx, fmt.Sprintf("nodeInput:%v.txt", time.Now().UnixMilli()), gconv.Bytes(gconv.String(realInput)))
if err != nil {
return fmt.Errorf("节点:%v 上传OSS失败: %v", realInput.Config.Name, err)
}
// 更新执行记录为成功
if err := UpdateNodeExecutionRecord(ctx, nodeExecutionId, durationMs, node.NodeExecutionStatusSuccess.Code(), ossResult, ""); err != nil {
return fmt.Errorf("节点:%v 更新成功状态错误: %v", flowNode.Name, err)
}
// 记录成功到已执行列表
RecordExecutionResult(execInput, flowNode.Id, node.NodeExecutionStatusSuccess.Code())
return nil
}
// HandleFailedNodeExecution 处理节点执行失败的后续操作
func HandleFailedNodeExecution(ctx context.Context, execInput *flowDto.FlowExecutionInput, nodeExecutionId int64, flowNode entity.FlowNode, err error, durationMs int64) error {
// 保存状态用于续跑
if stateErr := compose.ProcessState(ctx, func(_ context.Context, s *flowDto.NodeExecutionState) error {
s.CompletedNodes = append(s.CompletedNodes, flowNode.Name)
s.SavedFlowInput = execInput
s.ExecutionCount++
return nil
}); stateErr != nil {
fmt.Printf("节点:%v 进程状态保存失败: %v", flowNode.Name, stateErr)
}
if !g.IsEmpty(nodeExecutionId) {
// 更新执行记录为失败
if updateErr := UpdateNodeExecutionRecord(ctx, nodeExecutionId, durationMs, node.NodeExecutionStatusFailed.Code(), "", err.Error()); updateErr != nil {
fmt.Printf("节点:%v 更新失败状态错误: %v", flowNode.Name, updateErr)
}
}
// 触发中断
return compose.Interrupt(ctx, map[string]string{
"node": flowNode.Name,
"error": err.Error(),
})
}
// RecordExecutionResult 将节点执行结果写入 Global.ExecutedNodes
func RecordExecutionResult(execInput *flowDto.FlowExecutionInput, nodeId string, status node.NodeExecutionStatus) {
execInput.ExecutedNodes = append(execInput.ExecutedNodes, flowDto.ExecutedNode{
NodeId: nodeId,
Status: status,
})
}
// UpdateNodeExecutionRecord 更新节点执行记录
func UpdateNodeExecutionRecord(ctx context.Context, nodeExecutionId int64, durationMs int64, status node.NodeExecutionStatus, outputParamsPath string, errMsg string) error {
if _, err := nodeDao.NodeExecutionDao.Update(ctx, &nodeDto.UpdateNodeExecutionReq{
Id: nodeExecutionId,
DurationMs: durationMs,
Status: status,
OutputParamsPath: outputParamsPath,
ErrorMessage: errMsg,
}); err != nil {
return err
}
return nil
}
// CreateNodeExecutionRecord 创建节点执行记录,返回记录ID
func CreateNodeExecutionRecord(ctx context.Context, execInput *flowDto.FlowExecutionInput, flowNode entity.FlowNode, inputOssUrl string) (int64, error) {
id, err := nodeDao.NodeExecutionDao.Insert(ctx, &nodeDto.CreateNodeExecutionReq{
FlowExecutionId: execInput.ExecutionId,
NodeId: flowNode.Id,
NodeName: flowNode.Name,
NodeGroupId: execInput.NodeGroupId,
InputParamsPath: inputOssUrl,
Status: node.NodeExecutionStatusRunning.Code(),
})
if err != nil {
return 0, fmt.Errorf("节点:%v 创建节点执行记录失败: %v", flowNode.Name, err)
}
return id, nil
}