// factenrichment.go — resolves the Subject of entity-aware facts against // Nexus, turning free-text ("the espresso machine", "Kate") into a canonical // entity_id (Vikunja #279). Mirrors Praxis's async enrichment worker // (internal/enrichment/worker.go there): a plain poll-queue-update loop, no // shared state with the tick loop beyond the store and ecosystemWiring both // already hold. package main import ( "context" "log" "sync" "time" "github.com/kami/maven/internal/store" ) // factEnrichmentWorker polls store.PendingFactResolutions and resolves each // one's Subject through ecosystemWiring.resolveEntityReference. A nil nexus // client (ecosystem.nexus unconfigured) makes every tick a no-op rather than // erroring — entity-aware memory degrades to "facts just aren't tagged", // never to a crash loop. type factEnrichmentWorker struct { store *store.Store eco *ecosystemWiring interval time.Duration batch int // facts resolved per tick; keeps a single slow tick bounded now func() time.Time // Retry state for facts whose resolution failed transiently. Kept in // memory rather than in the DB: a restart legitimately retries // everything, and the backoff exists to spare a struggling Nexus, not // to be durable. A fact is never given up on — degraded means slower, // not dropped. mu sync.Mutex attempt map[int64]int // fact id → consecutive failures nextTry map[int64]time.Time // fact id → earliest retry skipped int // facts held back by backoff on the last tick } // enrichmentBackoff is the wait before retrying a fact after n consecutive // failures, capped so a long Nexus outage still retries about hourly. func enrichmentBackoff(n int) time.Duration { d := time.Minute for i := 1; i < n && d < time.Hour; i++ { d *= 2 } if d > time.Hour { d = time.Hour } return d } func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval time.Duration) *factEnrichmentWorker { return &factEnrichmentWorker{ store: st, eco: eco, interval: interval, batch: 20, now: time.Now, attempt: map[int64]int{}, nextTry: map[int64]time.Time{}, } } // enrichmentStatus is what the worker reports about its own health: how many // facts are waiting, how many are currently in backoff, and the worst retry // count seen. Degradation is reported, never hidden — a Nexus that has been // down all day must be visible as a backlog, not as facts that silently // never got tagged. type enrichmentStatus struct { Pending int InBackoff int MaxAttempts int } func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus { var st enrichmentStatus if pending, err := w.store.PendingFactResolutions(ctx, 1000); err == nil { st.Pending = len(pending) } w.mu.Lock() defer w.mu.Unlock() st.InBackoff = w.skipped for _, n := range w.attempt { if n > st.MaxAttempts { st.MaxAttempts = n } } return st } func (w *factEnrichmentWorker) run(ctx context.Context) { if w.eco == nil || w.eco.nexus == nil { log.Printf("factenrichment: nexus not configured, worker idle") <-ctx.Done() return } ticker := time.NewTicker(w.interval) defer ticker.Stop() w.tick(ctx) for { select { case <-ctx.Done(): return case <-ticker.C: w.tick(ctx) } } } func (w *factEnrichmentWorker) tick(ctx context.Context) { pending, err := w.store.PendingFactResolutions(ctx, w.batch) if err != nil { log.Printf("factenrichment: list pending: %v", err) return } skipped, failed := 0, 0 for _, f := range pending { if !w.due(f.ID) { skipped++ continue } if !w.resolveOne(ctx, f) { failed++ } } w.mu.Lock() w.skipped = skipped w.mu.Unlock() if failed > 0 { log.Printf("factenrichment: %d/%d resolutions failed this tick, %d held in backoff", failed, len(pending), skipped) } } // due reports whether a fact's backoff window has elapsed. func (w *factEnrichmentWorker) due(id int64) bool { w.mu.Lock() defer w.mu.Unlock() next, ok := w.nextTry[id] return !ok || !w.now().Before(next) } // resolveOne resolves one pending fact. It returns false when the attempt // failed transiently: the fact stays pending and is retried on a backoff. func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) bool { entityID, _, ambiguous, err := w.eco.resolveEntityReference(ctx, f.Subject, nil) if err != nil { // Transient (Nexus unreachable) — leave pending, back off, retry later. log.Printf("factenrichment: resolve fact %d subject %q: %v", f.ID, f.Subject, err) w.mu.Lock() w.attempt[f.ID]++ w.nextTry[f.ID] = w.now().Add(enrichmentBackoff(w.attempt[f.ID])) w.mu.Unlock() return false } w.mu.Lock() delete(w.attempt, f.ID) delete(w.nextTry, f.ID) w.mu.Unlock() state := store.ResolutionNotFound switch { case entityID != "": state = store.ResolutionResolved case len(ambiguous) > 0: state = store.ResolutionAmbiguous } if err := w.store.ResolveFactEntity(ctx, f.ID, entityID, state); err != nil { log.Printf("factenrichment: record resolution for fact %d: %v", f.ID, err) return false } return true }