Files
ragflow/internal/harness/core/concurrency_test.go
grandpig 17e3e34e78 refactor: use WaitGroup.Go to simplify code (#16539)
### Summary

Adopt sync.WaitGroup.Go (Go 1.25) to simplify tracked goroutine
spawning. This replaces the error-prone trio of wg.Add(1), go func(),
and defer wg.Done() with a single, self-contained call.

More info: https://github.com/golang/go/issues/63796

Signed-off-by: grandpig <grandpig@outlook.com>
2026-07-02 13:41:53 +08:00

405 lines
10 KiB
Go

package core
import (
"context"
"errors"
"sync"
"testing"
"time"
"ragflow/internal/harness/core/schema"
)
// ================================================================
// Concurrency tests for agentcore concurrent components
// ================================================================
// ---- tools_node.go: concurrent tool execution via mockTool ----
// TestToolsNode_ConcurrentInvoke verifies concurrent tool Invoke calls are safe.
func TestToolsNode_ConcurrentInvoke(t *testing.T) {
tool := &mockTool{name: "conc_tool", desc: "concurrency test tool"}
ctx := context.Background()
var wg sync.WaitGroup
errs := make(chan error, 20)
for i := 0; i < 20; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
_, err := tool.Invoke(ctx, `{"id":`+string(rune('0'+id%10))+`}`)
errs <- err
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("concurrent tool invoke failed: %v", err)
}
}
}
// ---- workflow.go: parallel sub-agent execution ----
// TestWorkflow_ParallelAgentConcurrency verifies parallel sub-agents
// run safely when invoked concurrently.
func TestWorkflow_ParallelAgentConcurrency(t *testing.T) {
m1 := &mockModel{}
for i := 0; i < 10; i++ {
m1.addResp("par_a_result")
}
m2 := &mockModel{}
for i := 0; i < 10; i++ {
m2.addResp("par_b_result")
}
a1 := NewReActAgent(&ReActConfig[*schema.Message]{Model: m1}).WithName("par_conc_a")
a2 := NewReActAgent(&ReActConfig[*schema.Message]{Model: m2}).WithName("par_conc_b")
ctx := context.Background()
par, err := NewParallel(ctx, &ParallelConfig{
Name: "par_conc_test",
SubAgents: []Agent{a1, a2},
})
if err != nil {
t.Fatalf("NewParallel: %v", err)
}
var wg sync.WaitGroup
errs := make(chan error, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
runner := NewTypedRunner(RunnerConfig[*schema.Message]{Agent: par})
iter := runner.Run(ctx, []*schema.Message{schema.UserMessage("parallel conc test")})
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
errs <- ev.Err
return
}
}
errs <- nil
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("parallel workflow error: %v", err)
}
}
}
// ---- AgentLoop concurrent Push/Stop ----
// TestTurnLoop_ConcurrentPushStop verifies AgentLoop handles concurrent
// Push and Stop operations safely.
func TestTurnLoop_ConcurrentPushStop(t *testing.T) {
ctx := context.Background()
loop := NewAgentLoop[*schema.Message](AgentLoopConfig[*schema.Message]{
GenInput: func(_ context.Context, l *AgentLoop[*schema.Message], items []*schema.Message) (*GenInputResult[*schema.Message], error) {
return &GenInputResult[*schema.Message]{
Input: &AgentInput{Messages: items},
Consumed: items,
Remaining: nil,
}, nil
},
PrepareAgent: func(_ context.Context, _ *AgentLoop[*schema.Message], consumed []*schema.Message) (Agent, error) {
m := &mockModel{}
m.addResp("turn loop conc response")
return NewReActAgent(&ReActConfig[*schema.Message]{Model: m}).WithName("conc_loop"), nil
},
})
var wg sync.WaitGroup
// Concurrent Push from multiple goroutines
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
loop.Push(schema.UserMessage("concurrent item"))
}(i)
}
wg.Wait()
loop.Run(ctx)
loop.Stop()
state := loop.Wait()
if state.ExitReason != nil && !errors.As(state.ExitReason, new(*CancelError)) {
t.Logf("turn loop exit: %v", state.ExitReason)
}
}
// ---- ReActAgent concurrent Run ----
// TestReActAgent_ConcurrentRun verifies multiple agents can run concurrently.
func TestReActAgent_ConcurrentRun(t *testing.T) {
ctx := context.Background()
var wg sync.WaitGroup
errs := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
m := &mockModel{}
m.addResp("concurrent result")
m.addResp("concurrent result")
agent := NewReActAgent(&ReActConfig[*schema.Message]{Model: m}).WithName("conc_agent")
iter := agent.Run(ctx, &AgentInput{
Messages: []*schema.Message{schema.UserMessage("concurrent run test")},
})
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
errs <- ev.Err
return
}
}
errs <- nil
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("concurrent agent run failed: %v", err)
}
}
}
// ---- Runner concurrent execution ----
// TestRunner_ConcurrentInstances verifies multiple Runner instances
// executing concurrently don't interfere.
func TestRunner_ConcurrentInstances(t *testing.T) {
ctx := context.Background()
var wg sync.WaitGroup
errs := make(chan error, 8)
for i := 0; i < 8; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
m := &mockModel{}
m.addResp("runner conc result")
m.addResp("runner conc result")
agent := NewReActAgent(&ReActConfig[*schema.Message]{Model: m}).WithName("runner_conc")
runner := NewTypedRunner(RunnerConfig[*schema.Message]{Agent: agent})
iter := runner.Run(ctx, []*schema.Message{schema.UserMessage("runner conc test")})
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
errs <- ev.Err
return
}
}
errs <- nil
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("concurrent runner failed: %v", err)
}
}
}
// ---- Tool-related concurrency ----
// TestTool_ConcurrentAgent verifies AgentTool with concurrent parent agents.
func TestTool_ConcurrentAgent(t *testing.T) {
innerM := &mockModel{}
innerM.addResp("inner agent result")
innerAgent := NewReActAgent(&ReActConfig[*schema.Message]{Model: innerM}).WithName("inner_conc").WithDescription("inner agent")
ctx := context.Background()
agentTool := NewAgentTool(ctx, innerAgent)
parentM := &forcedToolModel{
inner: &mockModel{},
toolCalls: []schema.ToolCall{{ID: "conc_tc", Function: schema.ToolCallFunction{Name: "inner_conc", Arguments: "{}"}}},
finalResp: "parent done",
firstCall: true,
}
parent := NewReActAgent(&ReActConfig[*schema.Message]{
Model: parentM, Tools: []Tool{agentTool},
ToolsConfig: &ToolsNodeConfig{Tools: []Tool{agentTool}},
}).WithName("parent_conc")
var wg sync.WaitGroup
errs := make(chan error, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
runner := NewTypedRunner(RunnerConfig[*schema.Message]{Agent: parent})
iter := runner.Run(ctx, []*schema.Message{schema.UserMessage("use tool conc")})
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
errs <- ev.Err
return
}
}
errs <- nil
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("concurrent agent tool failed: %v", err)
}
}
}
// ---- Sequential workflow concurrency ----
// TestWorkflow_SequentialConcurrent verifies multiple sequential workflows
// run concurrently without interference.
func TestWorkflow_SequentialConcurrent(t *testing.T) {
ctx := context.Background()
var wg sync.WaitGroup
errs := make(chan error, 6)
for i := 0; i < 6; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
m1 := &mockModel{}
m1.addResp("seq_a_conc")
m1.addResp("seq_a_conc")
m2 := &mockModel{}
m2.addResp("seq_b_conc")
m2.addResp("seq_b_conc")
a1 := NewReActAgent(&ReActConfig[*schema.Message]{Model: m1}).WithName("seq_conc_a")
a2 := NewReActAgent(&ReActConfig[*schema.Message]{Model: m2}).WithName("seq_conc_b")
seq, err := NewSequential(ctx, &SequentialConfig{
Name: "seq_conc",
SubAgents: []Agent{a1, a2},
})
if err != nil {
errs <- err
return
}
runner := NewTypedRunner(RunnerConfig[*schema.Message]{Agent: seq})
iter := runner.Run(ctx, []*schema.Message{schema.UserMessage("seq conc test")})
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
errs <- ev.Err
return
}
}
errs <- nil
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Errorf("sequential workflow conc error: %v", err)
}
}
}
// ---- Cancel concurrency ----
// TestCancel_ConcurrentTrigger verifies cancel can be triggered concurrently.
func TestCancel_ConcurrentTrigger(t *testing.T) {
m := newCancelTestChatModel(nil)
m.addResp("will be cancelled")
m.setDelay(100 * time.Millisecond)
agent := NewReActAgent(&ReActConfig[*schema.Message]{Model: m}).WithName("cancel_conc")
cancelOpt, cancelFunc := WithCancel()
store := newCancelTestStore()
runner := NewTypedRunner(RunnerConfig[*schema.Message]{Agent: agent, CheckPointStore: store})
ctx := context.Background()
iter := runner.Run(ctx, []*schema.Message{schema.UserMessage("cancel conc test")}, cancelOpt)
// Trigger cancel from multiple goroutines
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Go(func() {
cancelFunc(WithCancelMode(CancelImmediate))
})
}
wg.Wait()
// Drain
time.Sleep(20 * time.Millisecond)
for {
ev, ok := iter.Next()
if !ok {
break
}
if ev.Err != nil {
t.Logf("cancel err: %v", ev.Err)
break
}
}
}
// ---- Interrupt concurrency ----
// TestInterrupt_Concurrent verifies interrupt state can be read concurrently.
func TestInterrupt_Concurrent(t *testing.T) {
ctx := context.Background()
model := &forcedToolModel{
inner: &mockModel{},
toolCalls: []schema.ToolCall{{ID: "ci", Function: schema.ToolCallFunction{Name: "ci_tool", Arguments: "{}"}}},
finalResp: "ci done",
firstCall: true,
}
tool := &mockTool{name: "ci_tool", desc: "concurrent interrupt tool"}
agent := NewReActAgent(&ReActConfig[*schema.Message]{
Model: model, Tools: []Tool{tool},
ToolsConfig: &ToolsNodeConfig{Tools: []Tool{tool}},
}).WithName("ci_agent")
store := newCancelTestStore()
runner := NewTypedRunner(RunnerConfig[*schema.Message]{Agent: agent, CheckPointStore: store})
iter := runner.Run(ctx, []*schema.Message{schema.UserMessage("ci test")})
// Single consumer — iter.Next() is not safe for concurrent access.
for {
_, ok := iter.Next()
if !ok {
break
}
}
}