This commit is contained in:
2026-08-07 17:05:03 +02:00
parent 246241fcda
commit f8bf8c944e
1172 changed files with 38091 additions and 924 deletions
+42 -4
View File
@@ -797,6 +797,13 @@ func buildAnalysisRuns(events []AnalysisEventRecord) []AnalysisRun {
}
}
if phase == "start" {
// A duplicated start event for the same native run ID must never add
// another active-order entry. Keep the original start timestamp and
// attach the duplicate as an event so diagnostics stay lossless.
if existing := active[key]; existing != nil {
appendRunEvent(existing, event)
continue
}
run := newAnalysisRun(kind, key, event)
active[key] = &run
activeOrder = append(activeOrder, key)
@@ -831,6 +838,18 @@ func buildAnalysisRuns(events []AnalysisEventRecord) []AnalysisRun {
}
for _, key := range activeOrder {
if run := active[key]; run != nil {
// Old relation-research histories (written before research.completed
// existed) can contain research.results but no terminal event whenever
// no source was ingested. Do not display those historical records as
// running forever. A grace period avoids prematurely closing a live
// relation review that has just received its SearXNG results.
if run.Kind == "research" && time.Since(run.CompletedAt) > 2*time.Minute && analysisRunHasEventType(run, "research.results") {
run.Status = "success"
run.Verdict = "abgeschlossen (Legacy)"
run.Explanation = "Der ältere Lauf enthält SearXNG-Ergebnisse, aber kein explizites research.completed. Der Abschluss wurde aus dem letzten Ergebnis-Event rekonstruiert."
completed = append(completed, *run)
continue
}
run.Status = "running"
run.Verdict = "läuft"
run.Explanation = "Der Lauf ist noch nicht abgeschlossen oder sein Abschluss liegt außerhalb des gewählten Zeitfensters."
@@ -840,6 +859,18 @@ func buildAnalysisRuns(events []AnalysisEventRecord) []AnalysisRun {
return append(completed, standalone...)
}
func analysisRunHasEventType(run *AnalysisRun, typeName string) bool {
if run == nil {
return false
}
for _, event := range run.Events {
if event.Activity.Type == typeName {
return true
}
}
return false
}
func newAnalysisRun(kind, key string, event AnalysisEventRecord) AnalysisRun {
if kind == "" {
kind = kindForStandalone(event.Activity)
@@ -924,16 +955,23 @@ func classifyRunEvent(activity model.Activity) (kind, key, phase string) {
case "research.test.results", "research.test.failed":
return "searxng-test", latestSyntheticKey("searxng-test"), "end"
}
// Only the root lifecycle events open or close a research run. Nested
// operations such as article.research.fetch.started/completed are updates of
// the parent run, not independent processes. Treating every *.started as a
// new run caused the same research_id to be appended to activeOrder once per
// fetched page and made a single query appear five to seven times as
// "running" in the Analysis Center.
if strings.HasPrefix(typeName, "research.") || strings.HasPrefix(typeName, "article.research.") {
id := metadataString(activity.Metadata, "research_id")
if id != "" {
if strings.HasSuffix(typeName, ".started") {
switch typeName {
case "research.started", "article.research.started":
return "research", "research:" + id, "start"
}
if strings.HasSuffix(typeName, ".failed") || strings.HasSuffix(typeName, ".completed") || strings.HasSuffix(typeName, ".ingested") {
case "research.completed", "research.failed", "article.research.completed", "article.research.failed":
return "research", "research:" + id, "end"
default:
return "research", "research:" + id, "update"
}
return "research", "research:" + id, "update"
}
}
return "", "", ""
+70
View File
@@ -76,3 +76,73 @@ func TestEdgeUpdateChangeExplainsSimilarityRecalculation(t *testing.T) {
t.Fatalf("confidence delta missing: %+v", change)
}
}
func TestBuildAnalysisRunsTreatsResearchFetchAsNestedEvents(t *testing.T) {
start := time.Now().UTC().Add(-3 * time.Second)
meta := map[string]any{"research_id": "r-1", "research_query": "evidence tools"}
events := []AnalysisEventRecord{
{Activity: model.Activity{ID: "s", Type: "article.research.started", Timestamp: start, Metadata: meta}},
{Activity: model.Activity{ID: "f1s", Type: "article.research.fetch.started", Timestamp: start.Add(100 * time.Millisecond), Metadata: meta}},
{Activity: model.Activity{ID: "f1e", Type: "article.research.fetch.completed", Timestamp: start.Add(200 * time.Millisecond), Metadata: meta}},
{Activity: model.Activity{ID: "f2s", Type: "article.research.fetch.started", Timestamp: start.Add(300 * time.Millisecond), Metadata: meta}},
{Activity: model.Activity{ID: "f2e", Type: "article.research.fetch.completed", Timestamp: start.Add(400 * time.Millisecond), Metadata: meta}},
{Activity: model.Activity{ID: "e", Type: "article.research.completed", Timestamp: start.Add(time.Second), Metadata: meta}},
}
runs := buildAnalysisRuns(events)
if len(runs) != 1 {
t.Fatalf("expected exactly one research run, got %d: %+v", len(runs), runs)
}
if runs[0].EventCount != len(events) || runs[0].Status == "running" {
t.Fatalf("nested fetch lifecycle was not grouped correctly: %+v", runs[0])
}
}
func TestBuildAnalysisRunsDoesNotDuplicateActiveOrderForRepeatedStart(t *testing.T) {
start := time.Now().UTC().Add(-time.Second)
meta := map[string]any{"research_id": "r-dup", "research_query": "same query"}
events := []AnalysisEventRecord{
{Activity: model.Activity{ID: "s1", Type: "article.research.started", Timestamp: start, Metadata: meta}},
{Activity: model.Activity{ID: "s2", Type: "article.research.started", Timestamp: start.Add(10 * time.Millisecond), Metadata: meta}},
}
runs := buildAnalysisRuns(events)
if len(runs) != 1 {
t.Fatalf("expected one active run for duplicate start ID, got %d: %+v", len(runs), runs)
}
if runs[0].EventCount != 2 || runs[0].Status != "running" {
t.Fatalf("unexpected duplicate-start aggregation: %+v", runs[0])
}
}
func TestBuildAnalysisRunsClosesStaleLegacyRelationResearchFromResults(t *testing.T) {
start := time.Now().UTC().Add(-5 * time.Minute)
meta := map[string]any{"research_id": "legacy-r", "research_query": "legacy query"}
events := []AnalysisEventRecord{
{Activity: model.Activity{ID: "s", Type: "research.started", Timestamp: start, Metadata: meta}},
{Activity: model.Activity{ID: "r", Type: "research.results", Timestamp: start.Add(time.Second), Metadata: meta}},
}
runs := buildAnalysisRuns(events)
if len(runs) != 1 {
t.Fatalf("expected one legacy run, got %d: %+v", len(runs), runs)
}
if runs[0].Status == "running" || runs[0].Verdict != "abgeschlossen (Legacy)" {
t.Fatalf("stale legacy research should not remain running: %+v", runs[0])
}
}
func TestBuildAnalysisRunsKeepsRelationResearchOpenUntilExplicitCompletion(t *testing.T) {
start := time.Now().UTC().Add(-3 * time.Second)
meta := map[string]any{"research_id": "rel-r", "research_query": "relation query"}
events := []AnalysisEventRecord{
{Activity: model.Activity{ID: "s", Type: "research.started", Timestamp: start, Metadata: meta}},
{Activity: model.Activity{ID: "res", Type: "research.results", Timestamp: start.Add(200 * time.Millisecond), Metadata: meta}},
{Activity: model.Activity{ID: "ing", Type: "research.ingested", Timestamp: start.Add(300 * time.Millisecond), Metadata: meta}},
{Activity: model.Activity{ID: "end", Type: "research.completed", Timestamp: start.Add(time.Second), Metadata: meta}},
}
runs := buildAnalysisRuns(events)
if len(runs) != 1 {
t.Fatalf("expected one relation research run, got %d: %+v", len(runs), runs)
}
if runs[0].EventCount != 4 || runs[0].DurationMS < 900 {
t.Fatalf("research.ingested ended the run too early: %+v", runs[0])
}
}
+6
View File
@@ -76,6 +76,12 @@ func uniqueExact(values []string) []string {
}
func (s *Store) EnqueueResearchTask(ctx context.Context, task model.ResearchTask, cooldown time.Duration) (model.ResearchTask, bool, error) {
// Keep the dedupe lookup and insert atomic within this Brain process. The
// previous SELECT-then-INSERT sequence allowed two concurrent scanners/API
// callers to observe no existing task and enqueue the same SearXNG work
// twice. A process-local mutex is sufficient because one Store owns the DB.
s.researchTaskMu.Lock()
defer s.researchTaskMu.Unlock()
task = normalizeResearchTask(task)
if task.Topic == "" {
return model.ResearchTask{}, false, errors.New("research task topic is required")
+45
View File
@@ -2,6 +2,8 @@ package graph
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
@@ -98,3 +100,46 @@ func TestResearchTaskFailureDefersUntilMaxAttempts(t *testing.T) {
t.Fatalf("expected failed task after max attempts, got %+v err=%v", final, err)
}
}
func TestResearchTaskConcurrentEnqueueDeduplicates(t *testing.T) {
store, err := Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer store.Close()
ctx := context.Background()
request := model.ResearchTask{DedupeKey: "same-search", Topic: "Same SearXNG task", Questions: []string{"same question"}, Priority: .8}
var created atomic.Int64
var wg sync.WaitGroup
errs := make(chan error, 16)
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, wasCreated, enqueueErr := store.EnqueueResearchTask(ctx, request, time.Hour)
if enqueueErr != nil {
errs <- enqueueErr
return
}
if wasCreated {
created.Add(1)
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if got := created.Load(); got != 1 {
t.Fatalf("expected exactly one created task, got %d", got)
}
tasks, err := store.ListResearchTasks(ctx, 100)
if err != nil {
t.Fatal(err)
}
if len(tasks) != 1 {
t.Fatalf("expected exactly one queued task, got %d: %+v", len(tasks), tasks)
}
}
+1
View File
@@ -52,6 +52,7 @@ type Store struct {
analysisDetailMu sync.Mutex
analysisDetailVersion uint64
analysisDetailCache DetailedGraphAnalysis
researchTaskMu sync.Mutex
}
func ID(parts ...string) string {