Ch2
This commit is contained in:
@@ -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 "", "", ""
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type Store struct {
|
||||
analysisDetailMu sync.Mutex
|
||||
analysisDetailVersion uint64
|
||||
analysisDetailCache DetailedGraphAnalysis
|
||||
researchTaskMu sync.Mutex
|
||||
}
|
||||
|
||||
func ID(parts ...string) string {
|
||||
|
||||
Reference in New Issue
Block a user