fix(engine): resolve parallel execution races and improve max steps semantics
- Fix data race in runParallelBranches where first error could be masked - Improve MaxSteps to allow exactly N steps - Consolidate Resume validation logic and fix silent overwrite in Graph AddNode/AddEdge - Add sync.RWMutex to Engine for thread-safe configuration - Fix P2 issues: Add immediate check for router output, add mutex to hooks example, add context timeout to translation example, and update docs for Checkpointer.Delete
This commit is contained in:
1 parent
d2d8eb5a3a
commit
5782504351
10 files changed
+287
-47
No files matched your search
+65
-7
@@ -15,50 +15,88 @@ type Checkpointer[S any] interface {
|
||||
Save(ctx context.Context, threadID string, thread *Thread[S]) error
|
||||
// Load 读取之前保存的线程快照,用于恢复执行。
|
||||
Load(ctx context.Context, threadID string) (*Thread[S], error)
|
||||
// Delete 删除指定线程的快照文件,用于工作流完结后的清理。
|
||||
Delete(ctx context.Context, threadID string) error
|
||||
}
|
||||
|
||||
// FileCheckpointer 实现了基于本地 JSON 文件的状态持久化。
|
||||
type FileCheckpointer[S any] struct {
|
||||
dir string // 存放状态文件的文件夹路径
|
||||
dir string // 存放状态文件的文件夹路径
|
||||
pretty bool // 是否使用缩进格式(方便人工阅读,但体积更大)
|
||||
}
|
||||
|
||||
// FileCheckpointerOption 是 FileCheckpointer 的可选配置项。
|
||||
type FileCheckpointerOption func(*fileCheckpointerConfig)
|
||||
|
||||
type fileCheckpointerConfig struct {
|
||||
pretty bool
|
||||
}
|
||||
|
||||
// WithPrettyJSON 开启 JSON 缩进格式(方便人工阅读,适合开发调试阶段)。
|
||||
// 默认关闭,使用紧凑格式以减少磁盘 I/O 和文件体积。
|
||||
func WithPrettyJSON() FileCheckpointerOption {
|
||||
return func(c *fileCheckpointerConfig) {
|
||||
c.pretty = true
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileCheckpointer 创建一个文件检查点管理器。
|
||||
func NewFileCheckpointer[S any](dir string) (*FileCheckpointer[S], error) {
|
||||
// 支持通过 opts 传入可选配置(如 WithPrettyJSON())。
|
||||
func NewFileCheckpointer[S any](dir string, opts ...FileCheckpointerOption) (*FileCheckpointer[S], error) {
|
||||
// 确保文件夹存在,不存在则创建
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create checkpoint dir: %w", err)
|
||||
}
|
||||
|
||||
return &FileCheckpointer[S]{dir: dir}, nil
|
||||
cfg := &fileCheckpointerConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
return &FileCheckpointer[S]{dir: dir, pretty: cfg.pretty}, nil
|
||||
}
|
||||
|
||||
// Save 将 Thread 序列化为 JSON 并写入本地文件。
|
||||
// 采用“先写临时文件再原子重命名”的方式,防止写入中途程序崩溃导致文件损坏。
|
||||
// 采用"先写临时文件再原子重命名"的方式,防止写入中途程序崩溃导致文件损坏。
|
||||
// 若存在上次崩溃残留的临时文件,会在写入前自动清理。
|
||||
func (fc *FileCheckpointer[S]) Save(ctx context.Context, threadID string, thread *Thread[S]) error {
|
||||
if err := validateThreadID(threadID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(fc.dir, threadID+".json")
|
||||
tmpPath := path + ".tmp"
|
||||
|
||||
// 使用 MarshalIndent 方便人工阅读生成的 JSON 文件
|
||||
data, err := json.MarshalIndent(thread, "", " ")
|
||||
// 序列化:生产环境默认使用紧凑格式以减少磁盘 I/O;开发阶段可用 WithPrettyJSON() 开启缩进
|
||||
var data []byte
|
||||
var err error
|
||||
if fc.pretty {
|
||||
data, err = json.MarshalIndent(thread, "", " ")
|
||||
} else {
|
||||
data, err = json.Marshal(thread)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal thread: %w", err)
|
||||
}
|
||||
|
||||
// 清理上次崩溃可能残留的临时文件(忽略不存在的错误)
|
||||
_ = os.Remove(tmpPath)
|
||||
|
||||
// 原子写入:先写临时文件,再 Rename 替换,确保断电/崩溃安全
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
// Rename 失败时,尝试清理临时文件,避免残留
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load 从本地文件中读取 JSON 并反序列化为 Thread。
|
||||
// 若加载到一个已完结(IsFinished == true)的线程,会包装 ErrAlreadyFinished 作为额外信息返回,
|
||||
// 方便调用方做幂等处理(注意:此时 thread 依然有效,err 可通过 errors.Is 判断)。
|
||||
func (fc *FileCheckpointer[S]) Load(ctx context.Context, threadID string) (*Thread[S], error) {
|
||||
if err := validateThreadID(threadID); err != nil {
|
||||
return nil, err
|
||||
@@ -76,9 +114,29 @@ func (fc *FileCheckpointer[S]) Load(ctx context.Context, threadID string) (*Thre
|
||||
return nil, fmt.Errorf("failed to unmarshal thread: %w", err)
|
||||
}
|
||||
|
||||
// 如果加载到已完结的线程,附带哨兵错误提示,方便调用方感知
|
||||
if thread.IsFinished {
|
||||
return &thread, fmt.Errorf("thread %q has already finished: %w", threadID, ErrAlreadyFinished)
|
||||
}
|
||||
|
||||
return &thread, nil
|
||||
}
|
||||
|
||||
// Delete 删除指定 threadID 对应的快照文件。
|
||||
// 通常在工作流完结后调用,防止快照文件无限积累占用磁盘空间。
|
||||
// 若文件不存在,静默返回 nil(幂等操作)。
|
||||
func (fc *FileCheckpointer[S]) Delete(ctx context.Context, threadID string) error {
|
||||
if err := validateThreadID(threadID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(fc.dir, threadID+".json")
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to delete checkpoint file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateThreadID 校验 threadID 的合法性,防止路径穿越(path traversal)攻击。
|
||||
// threadID 中不允许出现目录分隔符或 ".." 序列。
|
||||
func validateThreadID(id string) error {
|
||||
|
||||
Reference in new issue
Block a user