Files
glpi-neural-brain/internal/graph/research_tasks.go
2026-08-07 17:05:03 +02:00

343 lines
13 KiB
Go

package graph
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/local/glpi-neural-brain/internal/model"
)
const researchTaskColumns = `id,dedupe_key,topic,reason,requested_by,status,priority,seed_node_ids_json,questions_json,queries_de_json,queries_en_json,attempts,max_attempts,evidence_count,article_created,article_title,article_path,outcome,last_error,metadata_json,created_at_ns,updated_at_ns,available_at_ns,lease_until_ns,started_at_ns,completed_at_ns`
func normalizeResearchTask(task model.ResearchTask) model.ResearchTask {
now := time.Now().UTC()
task.ID = strings.TrimSpace(task.ID)
if task.ID == "" {
task.ID = ID("research-task", task.DedupeKey, task.Topic, now.Format(time.RFC3339Nano))
}
task.DedupeKey = strings.TrimSpace(task.DedupeKey)
task.Topic = strings.TrimSpace(task.Topic)
task.Reason = strings.TrimSpace(task.Reason)
task.RequestedBy = strings.TrimSpace(task.RequestedBy)
if task.RequestedBy == "" {
task.RequestedBy = "brain"
}
if task.Status == "" {
task.Status = "queued"
}
if task.Priority < 0 {
task.Priority = 0
}
if task.Priority > 1 {
task.Priority = 1
}
if task.MaxAttempts < 1 {
task.MaxAttempts = 3
}
if task.CreatedAt.IsZero() {
task.CreatedAt = now
}
if task.UpdatedAt.IsZero() {
task.UpdatedAt = now
}
if task.AvailableAt.IsZero() {
task.AvailableAt = now
}
if task.Metadata == nil {
task.Metadata = map[string]any{}
}
task.SeedNodeIDs = uniqueExact(task.SeedNodeIDs)
task.Questions = uniqueExact(task.Questions)
task.QueriesDE = uniqueExact(task.QueriesDE)
task.QueriesEN = uniqueExact(task.QueriesEN)
return task
}
func uniqueExact(values []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
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")
}
if len(task.Questions) == 0 && len(task.QueriesDE) == 0 && len(task.QueriesEN) == 0 {
task.Questions = []string{task.Topic}
}
if task.DedupeKey != "" && cooldown > 0 {
cutoff := time.Now().UTC().Add(-cooldown).UnixNano()
var existingID string
err := s.db.QueryRowContext(ctx, `SELECT id FROM research_tasks WHERE dedupe_key=? AND updated_at_ns>=? AND status NOT IN ('cancelled','failed') ORDER BY updated_at_ns DESC LIMIT 1`, task.DedupeKey, cutoff).Scan(&existingID)
if err == nil {
existing, getErr := s.GetResearchTask(ctx, existingID)
return existing, false, getErr
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return model.ResearchTask{}, false, err
}
}
seedJSON, _ := json.Marshal(task.SeedNodeIDs)
questionsJSON, _ := json.Marshal(task.Questions)
queriesDEJSON, _ := json.Marshal(task.QueriesDE)
queriesENJSON, _ := json.Marshal(task.QueriesEN)
metadataJSON, _ := json.Marshal(task.Metadata)
_, err := s.db.ExecContext(ctx, `INSERT INTO research_tasks(`+researchTaskColumns+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
task.ID, task.DedupeKey, task.Topic, task.Reason, task.RequestedBy, task.Status, task.Priority,
string(seedJSON), string(questionsJSON), string(queriesDEJSON), string(queriesENJSON), task.Attempts, task.MaxAttempts,
task.EvidenceCount, boolInt(task.ArticleCreated), task.ArticleTitle, task.ArticlePath, task.Outcome, task.LastError, string(metadataJSON),
task.CreatedAt.UnixNano(), task.UpdatedAt.UnixNano(), task.AvailableAt.UnixNano(), timeNS(task.LeaseUntil), timeNS(task.StartedAt), timeNS(task.CompletedAt))
if err != nil {
return model.ResearchTask{}, false, fmt.Errorf("enqueue research task: %w", err)
}
return task, true, nil
}
func (s *Store) GetResearchTask(ctx context.Context, id string) (model.ResearchTask, error) {
row := s.db.QueryRowContext(ctx, `SELECT `+researchTaskColumns+` FROM research_tasks WHERE id=?`, id)
return scanResearchTask(row)
}
func (s *Store) ListResearchTasks(ctx context.Context, limit int, statuses ...string) ([]model.ResearchTask, error) {
if limit < 1 || limit > 500 {
limit = 100
}
query := `SELECT ` + researchTaskColumns + ` FROM research_tasks`
args := []any{}
if len(statuses) > 0 {
placeholders := make([]string, 0, len(statuses))
for _, status := range statuses {
status = strings.TrimSpace(status)
if status == "" {
continue
}
placeholders = append(placeholders, "?")
args = append(args, status)
}
if len(placeholders) > 0 {
query += ` WHERE status IN (` + strings.Join(placeholders, ",") + `)`
}
}
query += ` ORDER BY CASE status WHEN 'running' THEN 0 WHEN 'reserved' THEN 1 WHEN 'queued' THEN 2 WHEN 'deferred' THEN 3 ELSE 4 END, priority DESC, updated_at_ns DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.ResearchTask{}
for rows.Next() {
task, err := scanResearchTask(rows)
if err != nil {
return nil, err
}
out = append(out, task)
}
return out, rows.Err()
}
func (s *Store) LeaseNextResearchTask(ctx context.Context, minPriority float64, lease time.Duration) (model.ResearchTask, bool, error) {
now := time.Now().UTC()
if lease <= 0 {
lease = 45 * time.Minute
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return model.ResearchTask{}, false, err
}
defer tx.Rollback()
var id string
err = tx.QueryRowContext(ctx, `SELECT id FROM research_tasks WHERE status IN ('queued','deferred') AND priority>=? AND available_at_ns<=? AND attempts<max_attempts ORDER BY priority DESC, created_at_ns ASC LIMIT 1`, minPriority, now.UnixNano()).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return model.ResearchTask{}, false, nil
}
if err != nil {
return model.ResearchTask{}, false, err
}
res, err := tx.ExecContext(ctx, `UPDATE research_tasks SET status='reserved', attempts=attempts+1, lease_until_ns=?, started_at_ns=CASE WHEN started_at_ns=0 THEN ? ELSE started_at_ns END, updated_at_ns=? WHERE id=? AND status IN ('queued','deferred')`, now.Add(lease).UnixNano(), now.UnixNano(), now.UnixNano(), id)
if err != nil {
return model.ResearchTask{}, false, err
}
rows, _ := res.RowsAffected()
if rows != 1 {
return model.ResearchTask{}, false, nil
}
if _, err := tx.ExecContext(ctx, `INSERT INTO research_task_attempts(task_id,attempt,status,message,metadata_json,created_at_ns) SELECT id,attempts,'reserved','Task wurde vom autonomen Worker reserviert','{}',? FROM research_tasks WHERE id=?`, now.UnixNano(), id); err != nil {
return model.ResearchTask{}, false, err
}
if err := tx.Commit(); err != nil {
return model.ResearchTask{}, false, err
}
task, err := s.GetResearchTask(ctx, id)
return task, err == nil, err
}
func (s *Store) MarkResearchTaskRunning(ctx context.Context, id string) error {
now := time.Now().UTC().UnixNano()
result, err := s.db.ExecContext(ctx, `UPDATE research_tasks SET status='running',updated_at_ns=? WHERE id=? AND status='reserved'`, now, id)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows != 1 {
return fmt.Errorf("research task %q is no longer reserved", id)
}
return nil
}
func (s *Store) CompleteResearchTask(ctx context.Context, task model.ResearchTask) error {
now := time.Now().UTC()
task.Status = "completed"
task.UpdatedAt = now
task.CompletedAt = now
metadataJSON, _ := json.Marshal(task.Metadata)
_, err := s.db.ExecContext(ctx, `UPDATE research_tasks SET status='completed',evidence_count=?,article_created=?,article_title=?,article_path=?,outcome=?,last_error='',metadata_json=?,lease_until_ns=0,completed_at_ns=?,updated_at_ns=? WHERE id=?`, task.EvidenceCount, boolInt(task.ArticleCreated), task.ArticleTitle, task.ArticlePath, task.Outcome, string(metadataJSON), now.UnixNano(), now.UnixNano(), task.ID)
if err == nil {
_, _ = s.db.ExecContext(ctx, `INSERT INTO research_task_attempts(task_id,attempt,status,message,metadata_json,created_at_ns) SELECT id,attempts,'completed',?, ?, ? FROM research_tasks WHERE id=?`, task.Outcome, string(metadataJSON), now.UnixNano(), task.ID)
}
return err
}
func (s *Store) FailResearchTask(ctx context.Context, task model.ResearchTask, retryDelay time.Duration) error {
now := time.Now().UTC()
status := "failed"
available := now
if task.Attempts < task.MaxAttempts {
status = "deferred"
if retryDelay <= 0 {
retryDelay = time.Duration(task.Attempts*task.Attempts) * 10 * time.Minute
}
available = now.Add(retryDelay)
}
_, err := s.db.ExecContext(ctx, `UPDATE research_tasks SET status=?,last_error=?,outcome=?,available_at_ns=?,lease_until_ns=0,updated_at_ns=? WHERE id=?`, status, task.LastError, task.Outcome, available.UnixNano(), now.UnixNano(), task.ID)
if err == nil {
_, _ = s.db.ExecContext(ctx, `INSERT INTO research_task_attempts(task_id,attempt,status,message,metadata_json,created_at_ns) SELECT id,attempts,?,?, '{}',? FROM research_tasks WHERE id=?`, status, task.LastError, now.UnixNano(), task.ID)
}
return err
}
func (s *Store) CancelResearchTask(ctx context.Context, id string) (bool, error) {
now := time.Now().UTC().UnixNano()
res, err := s.db.ExecContext(ctx, `UPDATE research_tasks SET status='cancelled',lease_until_ns=0,completed_at_ns=?,updated_at_ns=? WHERE id=? AND status IN ('queued','deferred','reserved')`, now, now, id)
if err != nil {
return false, err
}
rows, _ := res.RowsAffected()
return rows == 1, nil
}
func (s *Store) ResetExpiredResearchTaskLeases(ctx context.Context) (int64, error) {
now := time.Now().UTC().UnixNano()
res, err := s.db.ExecContext(ctx, `UPDATE research_tasks SET status='deferred',available_at_ns=?,lease_until_ns=0,last_error='Worker-Lease ist abgelaufen; Aufgabe wurde erneut eingeplant',updated_at_ns=? WHERE status IN ('reserved','running') AND lease_until_ns>0 AND lease_until_ns<?`, now, now, now)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (s *Store) CountResearchTasksCompletedSince(ctx context.Context, since time.Time) (int, error) {
var count int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM research_tasks WHERE status='completed' AND completed_at_ns>=?`, since.UnixNano()).Scan(&count)
return count, err
}
func (s *Store) ResearchTaskCounts(ctx context.Context) (map[string]int, error) {
rows, err := s.db.QueryContext(ctx, `SELECT status,COUNT(*) FROM research_tasks GROUP BY status`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var status string
var count int
if err := rows.Scan(&status, &count); err != nil {
return nil, err
}
out[status] = count
}
return out, rows.Err()
}
type rowScanner interface {
Scan(dest ...any) error
}
func scanResearchTask(row rowScanner) (model.ResearchTask, error) {
var task model.ResearchTask
var seedJSON, questionsJSON, queriesDEJSON, queriesENJSON, metadataJSON string
var articleCreated int
var created, updated, available, lease, started, completed int64
err := row.Scan(&task.ID, &task.DedupeKey, &task.Topic, &task.Reason, &task.RequestedBy, &task.Status, &task.Priority,
&seedJSON, &questionsJSON, &queriesDEJSON, &queriesENJSON, &task.Attempts, &task.MaxAttempts, &task.EvidenceCount,
&articleCreated, &task.ArticleTitle, &task.ArticlePath, &task.Outcome, &task.LastError, &metadataJSON,
&created, &updated, &available, &lease, &started, &completed)
if err != nil {
return model.ResearchTask{}, err
}
_ = json.Unmarshal([]byte(seedJSON), &task.SeedNodeIDs)
_ = json.Unmarshal([]byte(questionsJSON), &task.Questions)
_ = json.Unmarshal([]byte(queriesDEJSON), &task.QueriesDE)
_ = json.Unmarshal([]byte(queriesENJSON), &task.QueriesEN)
_ = json.Unmarshal([]byte(metadataJSON), &task.Metadata)
if task.Metadata == nil {
task.Metadata = map[string]any{}
}
task.ArticleCreated = articleCreated != 0
task.CreatedAt = fromNS(created)
task.UpdatedAt = fromNS(updated)
task.AvailableAt = fromNS(available)
task.LeaseUntil = fromNS(lease)
task.StartedAt = fromNS(started)
task.CompletedAt = fromNS(completed)
return task, nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func timeNS(value time.Time) int64 {
if value.IsZero() {
return 0
}
return value.UTC().UnixNano()
}
func fromNS(value int64) time.Time {
if value <= 0 {
return time.Time{}
}
return time.Unix(0, value).UTC()
}