- persist: formatTime 定宽;loadLatestSQL 补 rowid 兜底;New 时归一化历史行(避免 LoadLatest 挑到更旧检查点) - channel/session: 写入定宽;listSessionSQL 补 rowid 兜底;核实无 INSERT 吃 DDL 默认值,故只加注释不改默认值 - 新增回归测试并做变异验证(改回 RFC3339Nano 即 FAIL)
427 lines
12 KiB
Go
427 lines
12 KiB
Go
package persist
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
dbpkg "git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
)
|
|
|
|
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// newTestStore opens an isolated in-memory SQLite database and returns a Store
|
|
// backed by it. Each call owns a private database, so parallel tests do not
|
|
// interfere with one another.
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
db, err := dbpkg.Open(ctx, ":memory:", testLogger())
|
|
if err != nil {
|
|
t.Fatalf("open in-memory db: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
s, err := New(ctx, db, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("New(store) error: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestStore_SaveLoad_正常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
cp := &Checkpoint{
|
|
ID: "cp-1",
|
|
AgentID: "agent-a",
|
|
Step: 3,
|
|
State: []byte(`{"turn":3,"msg":"hello"}`),
|
|
}
|
|
if err := s.Save(ctx, cp); err != nil {
|
|
t.Fatalf("Save error: %v", err)
|
|
}
|
|
|
|
got, err := s.Load(ctx, "cp-1")
|
|
if err != nil {
|
|
t.Fatalf("Load error: %v", err)
|
|
}
|
|
if got.ID != cp.ID || got.AgentID != cp.AgentID || got.Step != cp.Step {
|
|
t.Fatalf("round-trip mismatch: got %+v", got)
|
|
}
|
|
if string(got.State) != string(cp.State) {
|
|
t.Fatalf("state mismatch: got %q want %q", got.State, cp.State)
|
|
}
|
|
if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
|
|
t.Fatalf("timestamps not set: created=%v updated=%v", got.CreatedAt, got.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
func TestStore_Load_Missing_异常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
if _, err := s.Load(ctx, "does-not-exist"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Load(missing) err = %v, want wrap of ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_LoadLatest_正常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
// Save steps in ascending order; the latest save must be the newest step.
|
|
for step := 1; step <= 3; step++ {
|
|
cp := &Checkpoint{
|
|
ID: fmt.Sprintf("cp-%d", step),
|
|
AgentID: "agent-a",
|
|
Step: step,
|
|
State: []byte(fmt.Sprintf(`{"step":%d}`, step)),
|
|
}
|
|
if err := s.Save(ctx, cp); err != nil {
|
|
t.Fatalf("Save(step %d) error: %v", step, err)
|
|
}
|
|
}
|
|
|
|
got, err := s.LoadLatest(ctx, "agent-a")
|
|
if err != nil {
|
|
t.Fatalf("LoadLatest error: %v", err)
|
|
}
|
|
if got.Step != 3 {
|
|
t.Fatalf("LoadLatest step = %d, want 3", got.Step)
|
|
}
|
|
if got.ID != "cp-3" {
|
|
t.Fatalf("LoadLatest id = %q, want cp-3", got.ID)
|
|
}
|
|
}
|
|
|
|
func TestStore_LoadLatest_Missing_异常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
if _, err := s.LoadLatest(ctx, "no-such-agent"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("LoadLatest(missing) err = %v, want wrap of ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_List_正常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
// agent-a gets steps 1 and 2; agent-b gets a single step.
|
|
for _, cp := range []*Checkpoint{
|
|
{ID: "a-1", AgentID: "agent-a", Step: 1, State: []byte(`{"step":1}`)},
|
|
{ID: "a-2", AgentID: "agent-a", Step: 2, State: []byte(`{"step":2}`)},
|
|
{ID: "b-1", AgentID: "agent-b", Step: 1, State: []byte(`{"step":1}`)},
|
|
} {
|
|
if err := s.Save(ctx, cp); err != nil {
|
|
t.Fatalf("Save(%s) error: %v", cp.ID, err)
|
|
}
|
|
}
|
|
|
|
listA, err := s.List(ctx, "agent-a")
|
|
if err != nil {
|
|
t.Fatalf("List(agent-a) error: %v", err)
|
|
}
|
|
if len(listA) != 2 {
|
|
t.Fatalf("List(agent-a) length = %d, want 2", len(listA))
|
|
}
|
|
if listA[0].Step != 1 || listA[1].Step != 2 {
|
|
t.Fatalf("List(agent-a) not ascending by step: %+v", listA)
|
|
}
|
|
|
|
listB, err := s.List(ctx, "agent-b")
|
|
if err != nil {
|
|
t.Fatalf("List(agent-b) error: %v", err)
|
|
}
|
|
if len(listB) != 1 {
|
|
t.Fatalf("List(agent-b) length = %d, want 1", len(listB))
|
|
}
|
|
|
|
// An agent with no checkpoints returns an empty, non-nil slice.
|
|
none, err := s.List(ctx, "no-such-agent")
|
|
if err != nil {
|
|
t.Fatalf("List(none) error: %v", err)
|
|
}
|
|
if none == nil {
|
|
t.Fatalf("List(none) returned nil slice, want empty non-nil")
|
|
}
|
|
if len(none) != 0 {
|
|
t.Fatalf("List(none) length = %d, want 0", len(none))
|
|
}
|
|
}
|
|
|
|
func TestStore_Save_Upsert_正常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
// A clearly past timestamp: the store must preserve it across the upsert
|
|
// while refreshing UpdatedAt to the current time.
|
|
originalCreated := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC)
|
|
first := &Checkpoint{
|
|
ID: "cp-1",
|
|
AgentID: "agent-a",
|
|
Step: 1,
|
|
State: []byte(`{"step":1}`),
|
|
CreatedAt: originalCreated,
|
|
}
|
|
if err := s.Save(ctx, first); err != nil {
|
|
t.Fatalf("Save(first) error: %v", err)
|
|
}
|
|
|
|
// A second save with the same ID must keep the original CreatedAt while
|
|
// refreshing step, state and UpdatedAt.
|
|
second := &Checkpoint{
|
|
ID: "cp-1",
|
|
AgentID: "agent-a",
|
|
Step: 2,
|
|
State: []byte(`{"step":2}`),
|
|
}
|
|
if err := s.Save(ctx, second); err != nil {
|
|
t.Fatalf("Save(second) error: %v", err)
|
|
}
|
|
|
|
got, err := s.Load(ctx, "cp-1")
|
|
if err != nil {
|
|
t.Fatalf("Load error: %v", err)
|
|
}
|
|
if got.Step != 2 {
|
|
t.Fatalf("upsert step = %d, want 2", got.Step)
|
|
}
|
|
if string(got.State) != `{"step":2}` {
|
|
t.Fatalf("upsert state = %q, want step 2", got.State)
|
|
}
|
|
if !got.CreatedAt.Equal(originalCreated) {
|
|
t.Fatalf("upsert created_at = %v, want preserved %v", got.CreatedAt, originalCreated)
|
|
}
|
|
if got.UpdatedAt.Before(got.CreatedAt) {
|
|
t.Fatalf("upsert updated_at %v is before created_at %v", got.UpdatedAt, got.CreatedAt)
|
|
}
|
|
|
|
// Only one row must remain for the ID after the upsert.
|
|
all, err := s.List(ctx, "agent-a")
|
|
if err != nil {
|
|
t.Fatalf("List(after upsert) error: %v", err)
|
|
}
|
|
if len(all) != 1 {
|
|
t.Fatalf("upsert left %d rows, want 1", len(all))
|
|
}
|
|
}
|
|
|
|
func TestStore_Delete_正常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
cp := &Checkpoint{ID: "cp-1", AgentID: "agent-a", Step: 1, State: []byte(`{}`)}
|
|
if err := s.Save(ctx, cp); err != nil {
|
|
t.Fatalf("Save error: %v", err)
|
|
}
|
|
if err := s.Delete(ctx, "cp-1"); err != nil {
|
|
t.Fatalf("Delete error: %v", err)
|
|
}
|
|
if _, err := s.Load(ctx, "cp-1"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Load after delete err = %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_Delete_Missing_异常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
if err := s.Delete(ctx, "never-existed"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("Delete(missing) err = %v, want wrap of ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_Save_SQLInjection_安全(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
// Malicious strings must be treated as opaque data, never executed as SQL.
|
|
cp := &Checkpoint{
|
|
ID: "1'; DROP TABLE checkpoints;--",
|
|
AgentID: "agent'; DELETE FROM checkpoints;--",
|
|
Step: 1,
|
|
State: []byte(`{"x":"'); DROP TABLE checkpoints;--"}`),
|
|
}
|
|
if err := s.Save(ctx, cp); err != nil {
|
|
t.Fatalf("Save(injection) error: %v", err)
|
|
}
|
|
|
|
got, err := s.Load(ctx, cp.ID)
|
|
if err != nil {
|
|
t.Fatalf("Load(injection id) error: %v", err)
|
|
}
|
|
if got.ID != cp.ID || got.AgentID != cp.AgentID {
|
|
t.Fatalf("injection round-trip mismatch: got %+v", got)
|
|
}
|
|
if string(got.State) != string(cp.State) {
|
|
t.Fatalf("injection state mismatch: got %q", got.State)
|
|
}
|
|
|
|
// The table must still exist and hold exactly the injected row.
|
|
var tableCount int
|
|
if err := s.db.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='checkpoints'").Scan(&tableCount); err != nil {
|
|
t.Fatalf("query sqlite_master: %v", err)
|
|
}
|
|
if tableCount != 1 {
|
|
t.Fatalf("checkpoints table missing after injection, count=%d", tableCount)
|
|
}
|
|
var rowCount int
|
|
if err := s.db.QueryRowContext(ctx, "SELECT count(*) FROM checkpoints").Scan(&rowCount); err != nil {
|
|
t.Fatalf("count checkpoints: %v", err)
|
|
}
|
|
if rowCount != 1 {
|
|
t.Fatalf("checkpoints row count = %d, want 1", rowCount)
|
|
}
|
|
}
|
|
|
|
func TestStore_SaveConcurrent_并发(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
const agents = 8
|
|
const perAgent = 5
|
|
|
|
var wg sync.WaitGroup
|
|
errCh := make(chan error, agents*perAgent)
|
|
for a := 0; a < agents; a++ {
|
|
agentID := fmt.Sprintf("agent-%d", a)
|
|
for step := 0; step < perAgent; step++ {
|
|
wg.Add(1)
|
|
go func(id string, st int) {
|
|
defer wg.Done()
|
|
cp := &Checkpoint{
|
|
ID: fmt.Sprintf("cp-%s-%d", id, st),
|
|
AgentID: id,
|
|
Step: st,
|
|
State: []byte(fmt.Sprintf(`{"agent":%q,"step":%d}`, id, st)),
|
|
}
|
|
if err := s.Save(ctx, cp); err != nil {
|
|
errCh <- err
|
|
}
|
|
}(agentID, step)
|
|
}
|
|
}
|
|
wg.Wait()
|
|
close(errCh)
|
|
for err := range errCh {
|
|
t.Fatalf("concurrent save failed: %v", err)
|
|
}
|
|
|
|
for a := 0; a < agents; a++ {
|
|
agentID := fmt.Sprintf("agent-%d", a)
|
|
all, err := s.List(ctx, agentID)
|
|
if err != nil {
|
|
t.Fatalf("List(%s) error: %v", agentID, err)
|
|
}
|
|
if len(all) != perAgent {
|
|
t.Fatalf("List(%s) length = %d, want %d", agentID, len(all), perAgent)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStore_New_NilDB_异常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
if _, err := New(ctx, nil, testLogger()); !errors.Is(err, ErrNilDB) {
|
|
t.Fatalf("New(nil db) err = %v, want wrap of ErrNilDB", err)
|
|
}
|
|
}
|
|
|
|
func TestStore_Save_MissingID_异常(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
if err := s.Save(ctx, &Checkpoint{AgentID: "a", Step: 1, State: []byte(`{}`)}); !errors.Is(err, ErrMissingID) {
|
|
t.Fatalf("Save(blank id) err = %v, want wrap of ErrMissingID", err)
|
|
}
|
|
}
|
|
|
|
// TestStore_LoadLatest_变宽时间戳_取较晚 is the regression for the variable-width
|
|
// timestamp bug on the durable-checkpoint recovery path. time.RFC3339Nano drops
|
|
// trailing fractional zeros, so the older checkpoint was once stored as
|
|
// ":05.83Z" and the later one as ":05.8309789Z". SQLite compares updated_at as
|
|
// plain text, and "…83Z" ranks above "…8309789Z", so LoadLatest used to recover
|
|
// the older checkpoint — a silent wrong resume. The older checkpoint also
|
|
// carries the larger step, so the step DESC tiebreak cannot mask the bad order.
|
|
func TestStore_LoadLatest_变宽时间戳_取较晚(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
const (
|
|
olderRaw = "2026-09-07T03:20:05.83Z" // X.8300000 under RFC3339Nano
|
|
newerRaw = "2026-09-07T03:20:05.8309789Z" // X.8309789 under RFC3339Nano
|
|
)
|
|
if _, err := s.db.ExecContext(ctx, insertLegacyCheckpointSQL,
|
|
"cp-older", "agent-a", 9, `{"step":9}`, olderRaw, olderRaw); err != nil {
|
|
t.Fatalf("insert older checkpoint: %v", err)
|
|
}
|
|
if _, err := s.db.ExecContext(ctx, insertLegacyCheckpointSQL,
|
|
"cp-newer", "agent-a", 2, `{"step":2}`, newerRaw, newerRaw); err != nil {
|
|
t.Fatalf("insert newer checkpoint: %v", err)
|
|
}
|
|
|
|
// Re-open so the fixed-width normalization repairs the legacy rows.
|
|
if _, err := New(ctx, s.db, testLogger()); err != nil {
|
|
t.Fatalf("New (normalize): %v", err)
|
|
}
|
|
|
|
got, err := s.LoadLatest(ctx, "agent-a")
|
|
if err != nil {
|
|
t.Fatalf("LoadLatest error: %v", err)
|
|
}
|
|
if got.ID != "cp-newer" {
|
|
t.Fatalf("LoadLatest = %q (step %d), want cp-newer (the later updated_at)", got.ID, got.Step)
|
|
}
|
|
if want := time.Date(2026, 9, 7, 3, 20, 5, 830978900, time.UTC); !got.UpdatedAt.Equal(want) {
|
|
t.Fatalf("LoadLatest updated_at = %v, want %v", got.UpdatedAt, want)
|
|
}
|
|
}
|
|
|
|
// TestStore_Save_定宽时间戳 pins that every write uses the fixed-width layout, so
|
|
// text ordering equals time ordering for rows created by the current code.
|
|
func TestStore_Save_定宽时间戳(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
s := newTestStore(t)
|
|
|
|
if err := s.Save(ctx, &Checkpoint{ID: "cp-1", AgentID: "agent-a", Step: 1, State: []byte(`{}`)}); err != nil {
|
|
t.Fatalf("Save error: %v", err)
|
|
}
|
|
var created, updated string
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT created_at, updated_at FROM checkpoints WHERE id = ?`, "cp-1").
|
|
Scan(&created, &updated); err != nil {
|
|
t.Fatalf("read stored timestamps: %v", err)
|
|
}
|
|
if len(created) != storedTimeLen || len(updated) != storedTimeLen {
|
|
t.Errorf("stored timestamps = %q/%q (%d/%d bytes), want fixed width %d",
|
|
created, updated, len(created), len(updated), storedTimeLen)
|
|
}
|
|
}
|