Files
ragflow/internal/harness/graph/checkpoint/checkpoint_conformance_test.go
Yingfeng 5b0b86c276 More resilient graph engine (#16325)
### What problem does this PR solve?

- OpenTelemetry integration
- Checkpoint conformance tests
- State inspector API
- Callbacks
- A series of fault injection tests
- Pregel integration tests

### Type of change

- [x] Refactoring
2026-06-24 23:05:07 +08:00

679 lines
18 KiB
Go

// Package checkpoint conformance tests verify that all checkpointer implementations
// satisfy the BaseCheckpointer contract.
//
// This mirrors Python's langgraph-checkpoint-conformance package.
// Any type implementing checkpoint.BaseCheckpointer should pass this suite.
package checkpoint
import (
"context"
"fmt"
"sort"
"testing"
"time"
"ragflow/internal/harness/graph/constants"
)
// ConformanceTestSuite holds state shared across conformance tests.
// checkpointer under test: factory function returning a fresh instance.
type ConformanceTestSuite struct {
// NewCheckpointer creates a fresh checkpointer instance for each sub-test.
NewCheckpointer func() BaseCheckpointer
}
// RunAll runs all conformance tests against the given checkpointer factory.
func (suite *ConformanceTestSuite) RunAll(t *testing.T) {
t.Helper()
t.Run("PutAndGet", suite.TestPutAndGet)
t.Run("PutAndGetByID", suite.TestPutAndGetByID)
t.Run("ListEmpty", suite.TestListEmpty)
t.Run("ListOrder", suite.TestListOrder)
t.Run("ListWithLimit", suite.TestListWithLimit)
t.Run("MultipleThreads", suite.TestMultipleThreads)
t.Run("GetNonExistent", suite.TestGetNonExistent)
t.Run("OverwriteExisting", suite.TestOverwriteExisting)
t.Run("ListAcrossThreads", suite.TestListAcrossThreads)
t.Run("PutPreservesData", suite.TestPutPreservesData)
t.Run("DeepCopySemantics", suite.TestDeepCopySemantics)
t.Run("ConcurrentAccess", suite.TestConcurrentAccess)
t.Run("ManyCheckpoints", suite.TestManyCheckpoints)
t.Run("EmptyValues", suite.TestEmptyValues)
t.Run("NilConfig", suite.TestNilConfig)
}
// threadConfig creates a minimal checkpointer config from a thread ID.
func threadConfig(tid string) map[string]interface{} {
return map[string]interface{}{
constants.ConfigKeyThreadID: tid,
}
}
// threadConfigWithID creates a config with both thread and checkpoint ID.
func threadConfigWithID(tid, cpid string) map[string]interface{} {
return map[string]interface{}{
constants.ConfigKeyThreadID: tid,
constants.ConfigKeyCheckpointID: cpid,
}
}
// ---- Test cases ----
// TestPutAndGet verifies basic write-then-read.
func (suite *ConformanceTestSuite) TestPutAndGet(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-putget"
data := map[string]interface{}{"key1": "value1", "key2": 42, "key3": true}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
t.Fatalf("Put failed: %v", err)
}
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got == nil {
t.Fatal("Get returned nil, expected checkpoint data")
}
assertMapEqual(t, data, got, "Put/Get round-trip")
}
// TestPutAndGetByID verifies getting a specific checkpoint by ID.
func (suite *ConformanceTestSuite) TestPutAndGetByID(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-byid"
data1 := map[string]interface{}{"version": 1}
data2 := map[string]interface{}{"version": 2}
if err := cp.Put(ctx, threadConfig(tid), data1); err != nil {
t.Fatalf("first Put failed: %v", err)
}
// Get the ID of the first checkpoint from List.
entries, err := cp.List(ctx, threadConfig(tid), 10)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(entries) == 0 {
t.Fatal("List returned 0 entries after first Put")
}
cpID1 := entries[0][constants.ConfigKeyCheckpointID].(string)
if err := cp.Put(ctx, threadConfig(tid), data2); err != nil {
t.Fatalf("second Put failed: %v", err)
}
// Get by first checkpoint ID — must return data1.
got, err := cp.Get(ctx, threadConfigWithID(tid, cpID1))
if err != nil {
t.Fatalf("Get by ID failed: %v", err)
}
if got == nil {
t.Fatal("Get by ID returned nil")
}
v, ok := got["version"]
if !ok {
t.Fatalf("expected version=1, got %v", got)
}
// JSON may convert ints to float64.
var versionVal int
switch vt := v.(type) {
case int:
versionVal = vt
case float64:
versionVal = int(vt)
default:
t.Fatalf("unexpected type for version: %T", v)
}
if versionVal != 1 {
t.Fatalf("expected version=1, got %d (raw=%v)", versionVal, v)
}
}
// TestListEmpty verifies List returns nil/empty for a thread with no checkpoints.
func (suite *ConformanceTestSuite) TestListEmpty(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
entries, err := cp.List(ctx, threadConfig("nonexistent-thread"), 10)
if err != nil {
t.Fatalf("List on empty thread failed: %v", err)
}
if len(entries) != 0 {
t.Fatalf("expected 0 entries for empty thread, got %d", len(entries))
}
}
// TestListOrder verifies List returns checkpoints in reverse chronological order.
func (suite *ConformanceTestSuite) TestListOrder(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-order"
n := 5
for i := 0; i < n; i++ {
data := map[string]interface{}{"i": i}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
t.Fatalf("Put #%d failed: %v", i, err)
}
time.Sleep(time.Millisecond) // ensure timestamp ordering
}
entries, err := cp.List(ctx, threadConfig(tid), n)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(entries) != n {
t.Fatalf("expected %d entries, got %d", n, len(entries))
}
// Verify reverse chronological order.
lastTime := time.Now().Add(time.Hour)
seenIDs := make(map[string]bool)
for i, entry := range entries {
if entry[constants.ConfigKeyCheckpointID] == nil {
t.Fatalf("entry %d missing checkpoint_id", i)
}
cpID, ok := entry[constants.ConfigKeyCheckpointID].(string)
if !ok || cpID == "" {
t.Fatalf("entry %d has invalid checkpoint_id: %v", i, entry[constants.ConfigKeyCheckpointID])
}
if seenIDs[cpID] {
t.Fatalf("duplicate checkpoint ID: %s", cpID)
}
seenIDs[cpID] = true
createdAt, ok := entry["created_at"].(time.Time)
if ok {
if createdAt.After(lastTime) {
t.Fatalf("entry %d: created_at %v is after previous %v (not reverse chronological)", i, createdAt, lastTime)
}
lastTime = createdAt
}
if entry["thread_id"] == nil {
val := entry[constants.ConfigKeyThreadID]
if val == nil {
t.Fatalf("entry %d missing thread_id", i)
}
}
if entry["parent_id"] == nil && i < n-1 {
// Parent chain: later entries have earlier parent_ids.
// Entry n-1 (oldest) may not have parent_id.
}
}
}
// TestListWithLimit verifies List respects the limit parameter.
func (suite *ConformanceTestSuite) TestListWithLimit(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-limit"
for i := 0; i < 10; i++ {
data := map[string]interface{}{"i": i}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
t.Fatalf("Put #%d failed: %v", i, err)
}
}
entries, err := cp.List(ctx, threadConfig(tid), 3)
if err != nil {
t.Fatalf("List with limit failed: %v", err)
}
if len(entries) != 3 {
t.Fatalf("expected 3 entries with limit=3, got %d", len(entries))
}
}
// TestMultipleThreads verifies isolation between threads.
func (suite *ConformanceTestSuite) TestMultipleThreads(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
threads := []string{"thread-a", "thread-b", "thread-c"}
for _, tid := range threads {
data := map[string]interface{}{"owner": tid}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
t.Fatalf("Put for %s failed: %v", tid, err)
}
}
for _, tid := range threads {
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
t.Fatalf("Get for %s failed: %v", tid, err)
}
if got == nil {
t.Fatalf("Get for %s returned nil", tid)
}
owner, ok := got["owner"].(string)
if !ok || owner != tid {
t.Fatalf("expected owner=%q, got %q (data=%v)", tid, owner, got)
}
}
}
// TestGetNonExistent verifies Get returns nil for non-existent threads.
func (suite *ConformanceTestSuite) TestGetNonExistent(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
got, err := cp.Get(ctx, threadConfig("does-not-exist"))
if err != nil {
t.Fatalf("Get on non-existent thread failed: %v", err)
}
if got != nil {
t.Fatalf("expected nil for non-existent thread, got %v", got)
}
}
// TestOverwriteExisting verifies Put with same thread ID replaces latest.
func (suite *ConformanceTestSuite) TestOverwriteExisting(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-overwrite"
v1 := map[string]interface{}{"value": "first"}
if err := cp.Put(ctx, threadConfig(tid), v1); err != nil {
t.Fatalf("first Put failed: %v", err)
}
v2 := map[string]interface{}{"value": "second"}
if err := cp.Put(ctx, threadConfig(tid), v2); err != nil {
t.Fatalf("second Put failed: %v", err)
}
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got == nil {
t.Fatal("Get returned nil")
}
if v, ok := got["value"].(string); !ok || v != "second" {
t.Fatalf("expected value=second, got %v", got)
}
}
// TestListAcrossThreads verifies List only returns entries for the specified thread.
func (suite *ConformanceTestSuite) TestListAcrossThreads(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
for i := 0; i < 3; i++ {
tid := fmt.Sprintf("thread-%d", i)
if err := cp.Put(ctx, threadConfig(tid), map[string]interface{}{"i": i}); err != nil {
t.Fatalf("Put for %s failed: %v", tid, err)
}
}
for i := 0; i < 3; i++ {
tid := fmt.Sprintf("thread-%d", i)
entries, err := cp.List(ctx, threadConfig(tid), 10)
if err != nil {
t.Fatalf("List for %s failed: %v", tid, err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry for %s, got %d", tid, len(entries))
}
}
}
// TestPutPreservesData verifies all types of data are preserved.
func (suite *ConformanceTestSuite) TestPutPreservesData(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-types"
data := map[string]interface{}{
"string": "hello",
"int": 42,
"float": 3.14,
"bool": true,
"list": []interface{}{1, "two", 3.0},
"map": map[string]interface{}{"nested": "value", "num": 1},
"nil_val": nil,
"empty_str": "",
}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
t.Fatalf("Put failed: %v", err)
}
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got == nil {
t.Fatal("Get returned nil")
}
assertMapEqual(t, data, got, "data preservation")
}
// TestDeepCopySemantics verifies that Put stores a copy, not a reference.
func (suite *ConformanceTestSuite) TestDeepCopySemantics(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-deepcopy"
data := map[string]interface{}{
"key": "original",
}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
t.Fatalf("Put failed: %v", err)
}
// Modify the original map after Put.
data["key"] = "modified"
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got == nil {
t.Fatal("Get returned nil")
}
if v := got["key"]; v != "original" {
t.Fatalf("expected copy semantics: key=original, got %v", v)
}
}
// TestConcurrentAccess verifies thread safety under concurrent Put/Get operations.
func (suite *ConformanceTestSuite) TestConcurrentAccess(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
const goroutines = 20
const opsPerGoroutine = 50
errCh := make(chan error, goroutines)
for g := 0; g < goroutines; g++ {
go func(gid int) {
tid := fmt.Sprintf("concurrent-thread-%d", gid)
for i := 0; i < opsPerGoroutine; i++ {
data := map[string]interface{}{"gid": gid, "i": i}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
errCh <- fmt.Errorf("goroutine %d put failed: %w", gid, err)
return
}
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
errCh <- fmt.Errorf("goroutine %d get failed: %w", gid, err)
return
}
if got == nil {
errCh <- fmt.Errorf("goroutine %d got nil after put", gid)
return
}
}
errCh <- nil
}(g)
}
for g := 0; g < goroutines; g++ {
if err := <-errCh; err != nil {
t.Fatal(err)
}
}
}
// TestManyCheckpoints verifies performance with many checkpoints.
func (suite *ConformanceTestSuite) TestManyCheckpoints(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
const n = 100
tid := "test-thread-many"
for i := 0; i < n; i++ {
data := map[string]interface{}{"index": i, "data": fmt.Sprintf("checkpoint-%d", i)}
if err := cp.Put(ctx, threadConfig(tid), data); err != nil {
t.Fatalf("Put #%d failed: %v", i, err)
}
}
entries, err := cp.List(ctx, threadConfig(tid), n)
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(entries) != n {
t.Fatalf("expected %d entries, got %d", n, len(entries))
}
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got == nil {
t.Fatal("Get returned nil")
}
idxRaw, ok := got["index"]
if !ok {
t.Fatalf("expected index key, got %v", got)
}
var idxVal int
switch vt := idxRaw.(type) {
case int:
idxVal = vt
case float64:
idxVal = int(vt)
default:
t.Fatalf("unexpected type for index: %T", idxRaw)
}
if idxVal != n-1 {
t.Fatalf("expected latest index=%d, got %d (raw=%v)", n-1, idxVal, idxRaw)
}
}
// TestEmptyValues verifies round-trip with empty maps.
func (suite *ConformanceTestSuite) TestEmptyValues(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
tid := "test-thread-empty"
empty := map[string]interface{}{}
if err := cp.Put(ctx, threadConfig(tid), empty); err != nil {
t.Fatalf("Put with empty map failed: %v", err)
}
got, err := cp.Get(ctx, threadConfig(tid))
if err != nil {
t.Fatalf("Get after empty Put failed: %v", err)
}
if got == nil {
t.Fatal("Get returned nil after Put with empty map")
}
}
// TestNilConfig verifies Get/Put with missing thread_id returns an error.
func (suite *ConformanceTestSuite) TestNilConfig(t *testing.T) {
cp := suite.NewCheckpointer()
ctx := context.Background()
// Put without thread_id should fail.
err := cp.Put(ctx, map[string]interface{}{}, map[string]interface{}{"key": "val"})
if err == nil {
t.Fatal("expected error for Put without thread_id, got nil")
}
// Get without thread_id should fail.
_, err = cp.Get(ctx, map[string]interface{}{})
if err == nil {
t.Fatal("expected error for Get without thread_id, got nil")
}
// List without thread_id should fail.
_, err = cp.List(ctx, map[string]interface{}{}, 10)
if err == nil {
t.Fatal("expected error for List without thread_id, got nil")
}
}
// ---- Helpers ----
// assertMapEqual compares two maps and reports differences.
func assertMapEqual(t *testing.T, expected, actual map[string]interface{}, context string) {
t.Helper()
if len(expected) != len(actual) {
t.Fatalf("%s: map size mismatch: expected %d keys, got %d\n expected=%v\n actual=%v",
context, len(expected), len(actual), keysOf(expected), keysOf(actual))
}
for k, expectedVal := range expected {
actualVal, ok := actual[k]
if !ok {
t.Fatalf("%s: expected key %q not found in actual map", context, k)
}
if !valuesEqual(expectedVal, actualVal) {
t.Fatalf("%s: key %q: expected %v (type=%T), got %v (type=%T)",
context, k, expectedVal, expectedVal, actualVal, actualVal)
}
}
}
// keysOf returns sorted keys of a map.
func keysOf(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// valuesEqual does a deep comparison of two values.
// Numeric types (int/float64) are compared by value to handle JSON
// serialization where ints become float64.
func valuesEqual(a, b interface{}) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
switch va := a.(type) {
case map[string]interface{}:
vb, ok := b.(map[string]interface{})
if !ok {
return false
}
if len(va) != len(vb) {
return false
}
for k, av := range va {
bv, ok := vb[k]
if !ok {
return false
}
if !valuesEqual(av, bv) {
return false
}
}
return true
case []interface{}:
vb, ok := b.([]interface{})
if !ok || len(va) != len(vb) {
return false
}
for i := range va {
if !valuesEqual(va[i], vb[i]) {
return false
}
}
return true
case string:
vb, ok := b.(string)
return ok && va == vb
case int:
switch vb := b.(type) {
case int:
return va == vb
case float64:
return float64(va) == vb
default:
return false
}
case float64:
switch vb := b.(type) {
case float64:
return va == vb
case int:
return va == float64(vb)
default:
return false
}
case bool:
vb, ok := b.(bool)
return ok && va == vb
default:
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
}
}
// RunMemorySaverConformanceTests runs the full conformance suite against MemorySaver.
func RunMemorySaverConformanceTests(t *testing.T) {
suite := &ConformanceTestSuite{
NewCheckpointer: func() BaseCheckpointer {
return NewMemorySaver()
},
}
suite.RunAll(t)
}
// RunSqliteSaverConformanceTests runs the full conformance suite against SqliteSaver.
func RunSqliteSaverConformanceTests(t *testing.T, dbPath string) {
suite := &ConformanceTestSuite{
NewCheckpointer: func() BaseCheckpointer {
saver, err := NewSqliteSaver(dbPath)
if err != nil {
t.Skipf("SqliteSaver not available: %v", err)
return nil
}
return saver
},
}
suite.RunAll(t)
}
// skipBadTypeFields removes keys that the checkpointer cannot serialize
// (e.g. channels with unsupported types in SQLite).
func skipBadTypeFields(data map[string]interface{}, skipKeys ...string) map[string]interface{} {
result := make(map[string]interface{}, len(data))
skip := make(map[string]bool, len(skipKeys))
for _, k := range skipKeys {
skip[k] = true
}
for k, v := range data {
if !skip[k] {
result[k] = v
}
}
return result
}
// NewSqliteSaver creates a SqliteSaver (stub — implement as needed).
func NewSqliteSaver(dbPath string) (BaseCheckpointer, error) {
return nil, fmt.Errorf("SqliteSaver not implemented in this package: %s", dbPath)
}
// TestConformance_MemorySaver runs the conformance suite against MemorySaver.
func TestConformance_MemorySaver(t *testing.T) {
RunMemorySaverConformanceTests(t)
}
// TestConformance_MemorySaver_SubtestNames tests that all subtest names are set correctly.
func TestConformance_MemorySaver_SubtestNames(t *testing.T) {
suite := &ConformanceTestSuite{
NewCheckpointer: func() BaseCheckpointer { return NewMemorySaver() },
}
// Verify that RunAll doesn't panic.
suite.RunAll(t)
}