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:
Blizzard
2026-06-29 11:39:20 +08:00
parent 03225e31a9
commit f3bf42c432
4 changed files with 364 additions and 1 deletions
+50
View File
@@ -5,6 +5,7 @@ package bus
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
@@ -622,3 +623,52 @@ func (b *Bus) ConsumeTasks(ctx context.Context, h TaskHandler) (drain func(conte
drainWait(&wg, dctx)
}, nil
}
// ---- JetStream KV:临时持久态(dispatcher compose checkpoint 等)----
// KVHandle 是一个 JetStream KV 桶的薄封装,把 NATS 细节(ErrKeyNotFound 等)挡在 bus 内,
// 对外暴露 Get([]byte,bool,error)/Put/Delete 的朴素键值语义——结构化满足调用方的最小接口,
// 故 bus 无需反向依赖 dispatcher/eino。键带桶级 TTL 自动过期,进程重启后值仍在(File 存储)。
type KVHandle struct{ kv jetstream.KeyValue }
// Checkpoints 打开(或创建)一个 KV 桶,键按 ttl 自动过期。
// 用于 compose checkpoint 这类"可丢但最好留"的执行态:dispatcher 重启可复活在途审批,
// 决定到达即恢复;TTL 兜底清理被遗弃的中断(如审批人始终不处理)。
func (b *Bus) Checkpoints(ctx context.Context, bucket string, ttl time.Duration) (*KVHandle, error) {
kv, err := b.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: bucket,
Storage: jetstream.FileStorage,
TTL: ttl,
})
if err != nil {
return nil, fmt.Errorf("kv bucket %q: %w", bucket, err)
}
return &KVHandle{kv: kv}, nil
}
// Get 取键值;键不存在返回 (nil,false,nil)(非错误),其余 IO 错误如实上抛。
func (h *KVHandle) Get(ctx context.Context, key string) ([]byte, bool, error) {
e, err := h.kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return e.Value(), true, nil
}
// Put 写入键值(覆盖)。
func (h *KVHandle) Put(ctx context.Context, key string, val []byte) error {
_, err := h.kv.Put(ctx, key, val)
return err
}
// Delete 删除键(恢复/终态后显式清理 checkpoint);键不存在视为成功(幂等)。
func (h *KVHandle) Delete(ctx context.Context, key string) error {
err := h.kv.Delete(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
}
return err
}