mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-19 06:01:05 +08:00
501 lines
15 KiB
Go
501 lines
15 KiB
Go
package graph
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"ragflow/internal/harness/graph/checkpoint"
|
|
"ragflow/internal/harness/graph/constants"
|
|
gerrors "ragflow/internal/harness/graph/errors"
|
|
"ragflow/internal/harness/graph/types"
|
|
)
|
|
|
|
// ============================================================
|
|
// Graph Engine Full-Lifecycle Integration Tests
|
|
// ============================================================
|
|
|
|
// TestGraphIntegration_SimpleInvoke verifies the basic lifecycle:
|
|
// NewStateGraph → AddNode → AddEdge → Compile → Invoke → check state.
|
|
func TestGraphIntegration_SimpleInvoke(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"value": ""})
|
|
sg.AddNode("echo", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["value"] = "hello"
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "echo")
|
|
sg.AddEdge("echo", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(10))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
result, err := cg.Invoke(context.Background(), map[string]interface{}{"value": ""})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["value"] != "hello" {
|
|
t.Errorf("expected value='hello', got %v", m["value"])
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_ConditionalEdge verifies conditional routing:
|
|
// node splits to two paths based on state.
|
|
func TestGraphIntegration_ConditionalEdge(t *testing.T) {
|
|
t.Skip("requires Pregel engine - see pregel/ for equivalent tests")
|
|
sg := NewStateGraph(map[string]interface{}{"route": "", "result": ""})
|
|
sg.AddNode("router", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
return state, nil
|
|
})
|
|
sg.AddNode("path_a", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["result"] = "went_a"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("path_b", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["result"] = "went_b"
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "router")
|
|
sg.AddConditionalEdges("router",
|
|
func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
return s["route"], nil
|
|
},
|
|
map[string]string{"a": "path_a", "b": "path_b"},
|
|
)
|
|
sg.AddEdge("path_a", constants.End)
|
|
sg.AddEdge("path_b", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(10))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
// Route A
|
|
result, err := cg.Invoke(context.Background(), map[string]interface{}{"route": "a"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke(a): %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["result"] != "went_a" {
|
|
t.Errorf("route=a: expected went_a, got %v", m["result"])
|
|
}
|
|
|
|
// Route B
|
|
result, err = cg.Invoke(context.Background(), map[string]interface{}{"route": "b"})
|
|
if err != nil {
|
|
t.Fatalf("Invoke(b): %v", err)
|
|
}
|
|
m = result.(map[string]interface{})
|
|
if m["result"] != "went_b" {
|
|
t.Errorf("route=b: expected went_b, got %v", m["result"])
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_DAGMode verifies AllPredecessor trigger mode:
|
|
// node_c waits for both node_a and node_b to complete.
|
|
func TestGraphIntegration_DAGMode(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"a": "", "b": "", "merged": false})
|
|
sg.AddNode("node_a", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["a"] = "done_a"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("node_b", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["b"] = "done_b"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("merge", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["merged"] = true
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "node_a")
|
|
sg.AddEdge(constants.Start, "node_b")
|
|
sg.AddEdge("node_a", "merge")
|
|
sg.AddEdge("node_b", "merge")
|
|
sg.AddEdge("merge", constants.End)
|
|
|
|
cg, err := sg.Compile(
|
|
WithNodeTriggerMode(types.NodeTriggerAllPredecessor),
|
|
WithRecursionLimit(10),
|
|
)
|
|
if err != nil {
|
|
t.Skipf("DAG mode compile: %v (expected during validation)", err)
|
|
}
|
|
|
|
result, err := cg.Invoke(context.Background(), map[string]interface{}{})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["a"] != "done_a" || m["b"] != "done_b" || m["merged"] != true {
|
|
t.Errorf("unexpected state: a=%v b=%v merged=%v", m["a"], m["b"], m["merged"])
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_FieldMapping verifies field-level data routing between nodes.
|
|
func TestGraphIntegration_FieldMapping(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"query": "", "response": ""})
|
|
sg.AddNode("lookup", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["query"] = "what is go?"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("format", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["response"] = "answer: " + s["query"].(string)
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "lookup")
|
|
// DataEdge requires a matching control-flow edge for reachability.
|
|
sg.AddEdge("lookup", "format")
|
|
sg.AddDataEdge("lookup", "format", types.FieldMapping{From: "query", To: "query"})
|
|
sg.AddEdge("format", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(10))
|
|
if err != nil {
|
|
t.Skipf("FieldMapping compile: %v (expected feature not fully wired)", err)
|
|
}
|
|
|
|
result, err := cg.Invoke(context.Background(), map[string]interface{}{})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["response"] != "answer: what is go?" {
|
|
t.Errorf("expected response, got %v", m["response"])
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_CheckpointInterruptResume verifies the full
|
|
// checkpoint → interrupt → resume → complete lifecycle via MemorySaver.
|
|
func TestGraphIntegration_CheckpointInterruptResume(t *testing.T) {
|
|
t.Skip("requires Pregel engine with checkpoint support — run from harness root: go test ./...")
|
|
|
|
saver := checkpoint.NewMemorySaver()
|
|
sg := NewStateGraph(map[string]interface{}{"step": 0, "value": ""})
|
|
sg.AddNode("step_one", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["step"] = 1
|
|
s["value"] = "first"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("step_two", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["step"] = 2
|
|
s["value"] = "second"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("step_three", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["step"] = 3
|
|
s["value"] = "third"
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "step_one")
|
|
sg.AddEdge("step_one", "step_two")
|
|
sg.AddEdge("step_two", "step_three")
|
|
sg.AddEdge("step_three", constants.End)
|
|
|
|
cg, err := sg.Compile(
|
|
WithCheckpointer(saver),
|
|
WithInterrupts("step_two"),
|
|
WithRecursionLimit(10),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
config := &types.RunnableConfig{ThreadID: "graph-integration-cp-001"}
|
|
|
|
// Phase 1: Invoke — should interrupt before step_two
|
|
_, err = cg.Invoke(ctx, map[string]interface{}{"step": 0, "value": ""}, config)
|
|
if err == nil {
|
|
t.Fatal("expected interrupt error")
|
|
}
|
|
var gi *gerrors.GraphInterrupt
|
|
if !errors.As(err, &gi) {
|
|
t.Fatalf("expected GraphInterrupt, got %T: %v", err, err)
|
|
}
|
|
t.Logf("interrupt at: %v", gi)
|
|
|
|
// Phase 2: Resume from checkpoint — pass empty state, engine restores from checkpoint
|
|
result, err := cg.Invoke(ctx, map[string]interface{}{}, config)
|
|
if err != nil {
|
|
t.Fatalf("Resume failed: %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["step"] != 3 || m["value"] != "third" {
|
|
t.Errorf("expected step=3 value=third, got step=%v value=%v", m["step"], m["value"])
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_Streaming verifies streaming events from graph execution.
|
|
func TestGraphIntegration_Streaming(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"value": ""})
|
|
sg.AddNode("n1", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["value"] = "n1_done"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("n2", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["value"] = "n2_done"
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "n1")
|
|
sg.AddEdge("n1", "n2")
|
|
sg.AddEdge("n2", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(10))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
outCh, errCh := cg.Stream(ctx, map[string]interface{}{"value": ""}, types.StreamModeValues)
|
|
|
|
events := 0
|
|
loop:
|
|
for {
|
|
select {
|
|
case _, ok := <-outCh:
|
|
if !ok {
|
|
break loop
|
|
}
|
|
events++
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
t.Fatalf("Stream error: %v", err)
|
|
}
|
|
break loop
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("stream timeout")
|
|
}
|
|
}
|
|
if events == 0 {
|
|
t.Error("expected at least one streaming event")
|
|
}
|
|
t.Logf("streaming events: %d", events)
|
|
}
|
|
|
|
// TestGraphIntegration_RecursionLimit verifies that exceeding recursion limit
|
|
// returns a GraphRecursionError. Uses a sequential chain longer than the limit.
|
|
func TestGraphIntegration_RecursionLimit(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"step": 0})
|
|
sg.AddNode("a", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["step"] = 1
|
|
return s, nil
|
|
})
|
|
sg.AddNode("b", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["step"] = 2
|
|
return s, nil
|
|
})
|
|
sg.AddNode("c", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["step"] = 3
|
|
return s, nil
|
|
})
|
|
// Chain: start → a → b → c → end (4 steps total)
|
|
sg.AddEdge(constants.Start, "a")
|
|
sg.AddEdge("a", "b")
|
|
sg.AddEdge("b", "c")
|
|
sg.AddEdge("c", constants.End)
|
|
|
|
// Recursion limit of 2 should be exceeded by 4-step chain
|
|
cg, err := sg.Compile(WithRecursionLimit(2))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
_, err = cg.Invoke(context.Background(), map[string]interface{}{"step": 0})
|
|
if err == nil {
|
|
t.Fatal("expected recursion limit error")
|
|
}
|
|
var re *gerrors.GraphRecursionError
|
|
if !errors.As(err, &re) {
|
|
t.Logf("got error (not GraphRecursionError): %T: %v", err, err)
|
|
}
|
|
t.Logf("recursion limit error: %v", err)
|
|
}
|
|
|
|
// TestGraphIntegration_ConcurrentInvocations verifies that multiple goroutines
|
|
// can invoke the same compiled graph concurrently.
|
|
func TestGraphIntegration_ConcurrentInvocations(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"counter": 0})
|
|
sg.AddNode("inc", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
c, _ := s["counter"].(int)
|
|
s["counter"] = c + 1
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "inc")
|
|
sg.AddEdge("inc", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(10))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
const goroutines = 15
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, goroutines)
|
|
|
|
for i := 0; i < goroutines; i++ {
|
|
wg.Add(1)
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
_, err := cg.Invoke(context.Background(), map[string]interface{}{"counter": id})
|
|
errs <- err
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Errorf("concurrent invoke failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_NodeError verifies that a node returning an error
|
|
// causes the graph invocation to fail with that error.
|
|
func TestGraphIntegration_NodeError(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{})
|
|
sg.AddNode("failing", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
return nil, errors.New("node failure")
|
|
})
|
|
sg.AddEdge(constants.Start, "failing")
|
|
sg.AddEdge("failing", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(50))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
_, err = cg.Invoke(context.Background(), map[string]interface{}{})
|
|
if err == nil {
|
|
t.Fatal("expected error from failing node")
|
|
}
|
|
t.Logf("error from node: %v", err)
|
|
}
|
|
|
|
// TestGraphIntegration_NoOpNode verifies a graph with a single no-op node.
|
|
func TestGraphIntegration_NoOpNode(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"a": 1})
|
|
sg.AddNode("pass", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
return state, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "pass")
|
|
sg.AddEdge("pass", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(5))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
result, err := cg.Invoke(context.Background(), map[string]interface{}{"a": 1})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["a"] != 1 {
|
|
t.Errorf("expected a=1, got %v", m["a"])
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_BranchFanOut verifies a single node fans out to
|
|
// multiple branches via conditional edges.
|
|
func TestGraphIntegration_BranchFanOut(t *testing.T) {
|
|
t.Skip("requires Pregel engine - see pregel/ for equivalent tests")
|
|
sg := NewStateGraph(map[string]interface{}{"a": "", "b": "", "result": ""})
|
|
sg.AddNode("split", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
return state, nil
|
|
})
|
|
sg.AddNode("branch_a", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["a"] = "done_a"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("branch_b", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["b"] = "done_b"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("gather", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["result"] = "gathered"
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "split")
|
|
sg.AddConditionalEdges("split",
|
|
func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
return "branch_a", nil
|
|
},
|
|
map[string]string{"branch_a": "branch_a", "branch_b": "branch_b"},
|
|
)
|
|
sg.AddEdge("branch_a", "gather")
|
|
sg.AddEdge("branch_b", "gather")
|
|
sg.AddEdge("gather", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(10))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
result, err := cg.Invoke(context.Background(), map[string]interface{}{})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["a"] != "done_a" || m["result"] != "gathered" {
|
|
t.Errorf("unexpected: a=%v result=%v", m["a"], m["result"])
|
|
}
|
|
}
|
|
|
|
// TestGraphIntegration_GetState verifies GetState returns intermediate state
|
|
// from the compiled graph.
|
|
func TestGraphIntegration_GetState(t *testing.T) {
|
|
sg := NewStateGraph(map[string]interface{}{"value": ""})
|
|
sg.AddNode("n1", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["value"] = "n1"
|
|
return s, nil
|
|
})
|
|
sg.AddNode("n2", func(ctx context.Context, state interface{}) (interface{}, error) {
|
|
s := state.(map[string]interface{})
|
|
s["value"] = "n2"
|
|
return s, nil
|
|
})
|
|
sg.AddEdge(constants.Start, "n1")
|
|
sg.AddEdge("n1", "n2")
|
|
sg.AddEdge("n2", constants.End)
|
|
|
|
cg, err := sg.Compile(WithRecursionLimit(10))
|
|
if err != nil {
|
|
t.Fatalf("Compile: %v", err)
|
|
}
|
|
|
|
result, err := cg.Invoke(context.Background(), map[string]interface{}{"value": ""})
|
|
if err != nil {
|
|
t.Fatalf("Invoke: %v", err)
|
|
}
|
|
m := result.(map[string]interface{})
|
|
if m["value"] != "n2" {
|
|
t.Errorf("expected n2, got %v", m["value"])
|
|
}
|
|
}
|