Files
Maven/cmd/mavend/factenrichment.go
T
kami 08f3db318f Query Praxis by canonical entity ref and back off enrichment retries (#272)
Add an entity-scoped attention capability: the subject is resolved to a
canonical Nexus entity_id, the id travels to Praxis as a query scope
instead of being dropped after resolution, and Maven's own facts already
tagged with the same id join the answer. Ambiguous, unknown, degraded and
no-Nexus cases each get a distinct reply and never a scoped query without
a scope.

Give the fact-enrichment worker per-fact exponential backoff capped at an
hour and a status report of pending/in-backoff/worst-attempt counts, so a
long Nexus outage shows as a visible backlog rather than facts that
silently never got tagged. Nothing is ever given up on.
2026-08-01 06:52:25 +04:00

177 lines
5.0 KiB
Go

// 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
}