feat(hitl): 增量1 —— JetStream KV checkpoint store(持久化中断地基)
HITL 持久化中断/恢复的地基:compose checkpoint 需要一个可持久化、抗重启的 存储后端。dispatcher 是"只说 NATS、无 DB"的纯净设计,故复用既有 JetStream (bus.js)开 KV 桶,不引 Redis、不破坏架构原则。 - shared/bus: 新增 KVHandle + Bus.Checkpoints(bucket, ttl)。薄封装把 NATS 细节(ErrKeyNotFound→ok=false、Delete 幂等)挡在 bus 内,对外是朴素 Get/Put/Delete;bus 无需反向依赖 eino。File 存储 + 桶级 TTL 兜底清理。 - dispatcher/eino: checkpointStore 把 CheckpointKV 适配成 compose.CheckPointStore (Get/Set + 可选 Delete)。CheckpointKV 是最小接口,bus.KVHandle 结构化满足。 - 测试: 内存桩往返(Set→Get→Delete→miss)+ 编译期契约断言 `var _ CheckpointKV = (*bus.KVHandle)(nil)` 钉死 bus↔eino 隐式契约。 零爆炸半径(纯新增)。go test ./... 全绿。 下一步增量2:审批节点改 compose.Interrupt + Orchestrator 识别中断置 waiting。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// CheckpointKV 是 compose checkpoint 的最小持久化后端:朴素的键值 + 软缺失语义。
|
||||
// 生产由 bus 的 JetStream KV(*bus.KVHandle)实现(结构化满足,无需反向依赖 bus);
|
||||
// 单测用内存桩。键 = checkPointID(dispatcher 取 task_id),值 = compose 序列化的图状态。
|
||||
type CheckpointKV interface {
|
||||
Get(ctx context.Context, key string) (val []byte, ok bool, err error)
|
||||
Put(ctx context.Context, key string, val []byte) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// checkpointStore 把 CheckpointKV 适配成 Eino compose.CheckPointStore(Get/Set),
|
||||
// 并实现可选的 CheckPointDeleter(Delete)——审批恢复 / 终态后显式删 checkpoint,
|
||||
// 避免 KV 堆积;桶级 TTL 再兜底清理被遗弃的中断(审批人始终不处理)。
|
||||
//
|
||||
// 中断模型:审批节点 compose.Interrupt 时,compose 把整图状态写进本 store(key=task_id);
|
||||
// dispatcher 据此置 waiting 并返回(释放 goroutine)。决定到达后以同 key 重入图 → 从断点恢复。
|
||||
type checkpointStore struct{ kv CheckpointKV }
|
||||
|
||||
func newCheckpointStore(kv CheckpointKV) *checkpointStore { return &checkpointStore{kv: kv} }
|
||||
|
||||
// Get 读 checkpoint;不存在返回 (nil,false,nil),由 compose 视作"无断点、全新执行"。
|
||||
func (s *checkpointStore) Get(ctx context.Context, checkPointID string) ([]byte, bool, error) {
|
||||
return s.kv.Get(ctx, checkPointID)
|
||||
}
|
||||
|
||||
// Set 持久化一次中断的图状态。
|
||||
func (s *checkpointStore) Set(ctx context.Context, checkPointID string, checkPoint []byte) error {
|
||||
return s.kv.Put(ctx, checkPointID, checkPoint)
|
||||
}
|
||||
|
||||
// Delete 显式清理 checkpoint(实现 compose 的可选 CheckPointDeleter,结构化匹配)。
|
||||
func (s *checkpointStore) Delete(ctx context.Context, checkPointID string) error {
|
||||
return s.kv.Delete(ctx, checkPointID)
|
||||
}
|
||||
|
||||
// 编译期确认实现了 compose.CheckPointStore(Get/Set)。Delete 经结构化断言由运行时识别。
|
||||
var _ compose.CheckPointStore = (*checkpointStore)(nil)
|
||||
@@ -0,0 +1,67 @@
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/bus"
|
||||
)
|
||||
|
||||
// memKV 是 CheckpointKV 的内存桩(并发安全),用于不依赖 NATS 的单测。
|
||||
type memKV struct {
|
||||
mu sync.Mutex
|
||||
m map[string][]byte
|
||||
}
|
||||
|
||||
func newMemKV() *memKV { return &memKV{m: map[string][]byte{}} }
|
||||
|
||||
func (k *memKV) Get(_ context.Context, key string) ([]byte, bool, error) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
v, ok := k.m[key]
|
||||
return v, ok, nil
|
||||
}
|
||||
func (k *memKV) Put(_ context.Context, key string, val []byte) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
k.m[key] = append([]byte(nil), val...)
|
||||
return nil
|
||||
}
|
||||
func (k *memKV) Delete(_ context.Context, key string) error {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
delete(k.m, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// bus.KVHandle(JetStream KV 后端)须结构化满足 CheckpointKV——钉死 bus 与 eino 的隐式契约,
|
||||
// 任一侧改了 Get/Put/Delete 签名都会在这里编译失败。
|
||||
var _ CheckpointKV = (*bus.KVHandle)(nil)
|
||||
|
||||
// TestCheckpointStoreRoundtrip 验证 compose.CheckPointStore 适配:Set→Get 命中、Delete 后 Get 落空。
|
||||
func TestCheckpointStoreRoundtrip(t *testing.T) {
|
||||
s := newCheckpointStore(newMemKV())
|
||||
ctx := context.Background()
|
||||
const id = "task_42"
|
||||
|
||||
if _, ok, err := s.Get(ctx, id); err != nil || ok {
|
||||
t.Fatalf("空 store 应未命中: ok=%v err=%v", ok, err)
|
||||
}
|
||||
|
||||
cp := []byte("compose-serialized-state")
|
||||
if err := s.Set(ctx, id, cp); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
got, ok, err := s.Get(ctx, id)
|
||||
if err != nil || !ok || string(got) != string(cp) {
|
||||
t.Fatalf("Get 应回放原状态: got=%q ok=%v err=%v", got, ok, err)
|
||||
}
|
||||
|
||||
if err := s.Delete(ctx, id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, ok, _ := s.Get(ctx, id); ok {
|
||||
t.Fatalf("Delete 后不应再命中")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user