feat: concurrent engine execution with graceful degradation

- Run all local engines in parallel using goroutines + sync.WaitGroup
- Individual engine failures are captured as unresponsive_engines entries
  instead of aborting the entire search request
- Context cancellation is respected: cancelled engines report as unresponsive
- Upstream proxy failure is also gracefully handled (single unresponsive entry)
- Extract unresponsiveResponse() and emptyResponse() helpers for consistency
- Add comprehensive tests:
  - ConcurrentEngines: verifies parallelism (2x100ms engines complete in ~100ms)
  - GracefulDegradation: one engine fails, one succeeds, both represented
  - AllEnginesFail: no error returned, all engines in unresponsive_engines
  - ContextCancellation: engine respects context timeout, reports unresponsive
This commit is contained in:
Franz Kafka 2026-03-21 15:39:00 +00:00
parent 5181073a95
commit 385a7acab7
5 changed files with 355 additions and 35 deletions

View file

@ -0,0 +1,248 @@
package search
import (
"context"
"errors"
"testing"
"time"
"github.com/ashie/gosearch/internal/contracts"
"github.com/ashie/gosearch/internal/engines"
)
// mockEngine is a test engine that returns a predefined response or error.
type mockEngine struct {
name string
resp contracts.SearchResponse
err error
delay time.Duration
}
func (m *mockEngine) Name() string { return m.name }
func (m *mockEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
if m.delay > 0 {
select {
case <-ctx.Done():
return contracts.SearchResponse{}, ctx.Err()
case <-time.After(m.delay):
}
}
if m.err != nil {
return contracts.SearchResponse{}, m.err
}
return m.resp, nil
}
func TestSearch_ConcurrentEngines(t *testing.T) {
// Two engines that each take 100ms. If sequential, total would be ~200ms.
// Concurrent should be ~100ms.
registry := map[string]engines.Engine{
"fast-a": &mockEngine{
name: "fast-a",
resp: contracts.SearchResponse{
Query: "test",
NumberOfResults: 1,
Results: []contracts.MainResult{
{Title: "Result A", Engine: "fast-a"},
},
Answers: []map[string]any{},
Corrections: []string{},
Infoboxes: []map[string]any{},
Suggestions: []string{},
UnresponsiveEngines: [][2]string{},
},
delay: 100 * time.Millisecond,
},
"fast-b": &mockEngine{
name: "fast-b",
resp: contracts.SearchResponse{
Query: "test",
NumberOfResults: 1,
Results: []contracts.MainResult{
{Title: "Result B", Engine: "fast-b"},
},
Answers: []map[string]any{},
Corrections: []string{},
Infoboxes: []map[string]any{},
Suggestions: []string{},
UnresponsiveEngines: [][2]string{},
},
delay: 100 * time.Millisecond,
},
}
svc := &Service{
upstreamClient: nil,
planner: engines.NewPlanner([]string{"fast-a", "fast-b"}),
localEngines: registry,
}
req := SearchRequest{
Format: FormatJSON,
Query: "test",
Engines: []string{"fast-a", "fast-b"},
}
start := time.Now()
resp, err := svc.Search(context.Background(), req)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(resp.Results) != 2 {
t.Errorf("expected 2 results, got %d", len(resp.Results))
}
// Should complete in well under 200ms (sequential time).
if elapsed > 180*time.Millisecond {
t.Errorf("engines not running concurrently: took %v", elapsed)
}
}
func TestSearch_GracefulDegradation(t *testing.T) {
// One engine succeeds, one fails. Both should be represented:
// the failing engine in unresponsive_engines, the succeeding one in results.
registry := map[string]engines.Engine{
"good": &mockEngine{
name: "good",
resp: contracts.SearchResponse{
Query: "test",
NumberOfResults: 1,
Results: []contracts.MainResult{
{Title: "Good Result", Engine: "good"},
},
Answers: []map[string]any{},
Corrections: []string{},
Infoboxes: []map[string]any{},
Suggestions: []string{},
UnresponsiveEngines: [][2]string{},
},
},
"bad": &mockEngine{
name: "bad",
err: errors.New("connection refused"),
},
}
svc := &Service{
upstreamClient: nil,
planner: engines.NewPlanner([]string{"good", "bad"}),
localEngines: registry,
}
req := SearchRequest{
Format: FormatJSON,
Query: "test",
Engines: []string{"good", "bad"},
}
resp, err := svc.Search(context.Background(), req)
if err != nil {
t.Fatalf("search should not fail on individual engine error: %v", err)
}
// Should still have the good result.
if len(resp.Results) != 1 {
t.Errorf("expected 1 result from good engine, got %d", len(resp.Results))
}
// The bad engine should appear in unresponsive_engines.
foundBad := false
for _, ue := range resp.UnresponsiveEngines {
if ue[0] == "bad" {
foundBad = true
}
}
if !foundBad {
t.Errorf("expected 'bad' in unresponsive_engines, got %v", resp.UnresponsiveEngines)
}
}
func TestSearch_AllEnginesFail(t *testing.T) {
registry := map[string]engines.Engine{
"failing": &mockEngine{
name: "failing",
err: errors.New("timeout"),
},
}
svc := &Service{
upstreamClient: nil,
planner: engines.NewPlanner([]string{"failing"}),
localEngines: registry,
}
req := SearchRequest{
Format: FormatJSON,
Query: "test",
Engines: []string{"failing"},
}
resp, err := svc.Search(context.Background(), req)
if err != nil {
t.Fatalf("should not return error even when all engines fail: %v", err)
}
if len(resp.Results) != 0 {
t.Errorf("expected 0 results, got %d", len(resp.Results))
}
if len(resp.UnresponsiveEngines) != 1 {
t.Errorf("expected 1 unresponsive engine, got %d", len(resp.UnresponsiveEngines))
}
}
func TestSearch_ContextCancellation(t *testing.T) {
slowEngine := &mockEngine{
name: "slow",
delay: 5 * time.Second,
resp: contracts.SearchResponse{
Query: "test",
Results: []contracts.MainResult{},
Answers: []map[string]any{},
Corrections: []string{},
Infoboxes: []map[string]any{},
Suggestions: []string{},
UnresponsiveEngines: [][2]string{},
},
}
svc := &Service{
upstreamClient: nil,
planner: engines.NewPlanner([]string{"slow"}),
localEngines: map[string]engines.Engine{
"slow": slowEngine,
},
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
req := SearchRequest{
Format: FormatJSON,
Query: "test",
Engines: []string{"slow"},
}
start := time.Now()
resp, err := svc.Search(ctx, req)
elapsed := time.Since(start)
// Should not error — graceful degradation handles context cancellation.
if err != nil {
t.Fatalf("should not error on context cancel: %v", err)
}
if len(resp.Results) != 0 {
t.Errorf("expected 0 results from cancelled engine, got %d", len(resp.Results))
}
// Should complete quickly, not wait for the 5s delay.
if elapsed > 200*time.Millisecond {
t.Errorf("context cancellation not respected: took %v", elapsed)
}
// Engine should be marked unresponsive.
if len(resp.UnresponsiveEngines) != 1 {
t.Errorf("expected 1 unresponsive engine, got %d", len(resp.UnresponsiveEngines))
}
}