Files
nexarch/ai/internal/queue/worker.go
patrickandClaude Sonnet 5 dc99d46992
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
AI-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
GOV-04: kostenkontrolle & quota (kopplung core lic-03)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 11:55:10 +02:00

134 lines
4.4 KiB
Go

package queue
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/limits"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
)
// UsageRecorder ist die schmale Schnittstelle zu GOV-04 (ai/internal/quota):
// zaehlt einen tatsaechlich ausgefuehrten Inferenz-Aufruf. Als Interface
// hier statt Import, damit queue (ENG-05) nicht von quota abhaengt —
// quota.Store erfuellt diese Schnittstelle strukturell.
type UsageRecorder interface {
RecordCall(ctx context.Context, tenantSlug string) error
}
// Worker holt Jobs aus der Queue und fuehrt sie ueber den Provider aus.
// Ein Ausfuehrungsfehler blockiert den Anfrage-Pfad nicht (AK1) — der
// aufrufende Code hat laengst eine Job-ID zurueckbekommen und pollt oder
// wird benachrichtigt, dieser Worker laeuft unabhaengig im Hintergrund.
type Worker struct {
queue *Queue
provider provider.Provider
id string
limits limits.Limits
usage UsageRecorder
}
func NewWorker(q *Queue, p provider.Provider, workerID string) *Worker {
return &Worker{queue: q, provider: p, id: workerID, limits: limits.FromEnv()}
}
// SetLimits ueberschreibt die maximale Verarbeitungszeit je Anfrage (ENG-07),
// vor allem fuer Tests mit kleinen, deterministischen Werten.
func (w *Worker) SetLimits(l limits.Limits) { w.limits = l }
// SetUsageRecorder verknuepft den Worker mit GOV-04s Verbrauchszaehlung; ohne
// Aufruf bleibt die Ausfuehrung unveraendert ungezaehlt.
func (w *Worker) SetUsageRecorder(u UsageRecorder) { w.usage = u }
// ProcessOne holt einen Job und fuehrt ihn aus. Liefert ErrNoJobAvailable,
// wenn die Queue leer ist (kein Fehlerzustand).
func (w *Worker) ProcessOne(ctx context.Context) error {
job, err := w.queue.Dequeue(ctx, w.id)
if err != nil {
return err
}
execCtx, cancel := w.limits.WithTimeout(ctx)
defer cancel()
result, execErr := w.execute(execCtx, job)
if execErr != nil {
if errors.Is(execErr, context.DeadlineExceeded) {
execErr = fmt.Errorf("%w: job %s", limits.ErrProcessingTimeout, job.ID)
}
if failErr := w.queue.Fail(ctx, job.ID, execErr); failErr != nil {
log.Printf("queue: fehlschlag fuer job %s konnte nicht erfasst werden: %v", job.ID, failErr)
}
return execErr
}
if completeErr := w.queue.Complete(ctx, job.ID, result); completeErr != nil {
return fmt.Errorf("queue: job %s abschliessen: %w", job.ID, completeErr)
}
// Zaehlung erst NACH erfolgreichem Abschluss (GOV-04 Pruefung 3): der
// Zaehlerstand bildet tatsaechlich ausgefuehrte Aufrufe ab, nicht
// blosse Versuche — ein Fehlschlag der Zaehlung faellt bereits
// erfolgreiche Inferenz nicht rueckwirkend zurueck, sondern wird nur
// geloggt.
if w.usage != nil && job.TenantSlug != "" {
if usageErr := w.usage.RecordCall(ctx, job.TenantSlug); usageErr != nil {
log.Printf("queue: verbrauchszaehlung fuer job %s (tenant %s) fehlgeschlagen: %v", job.ID, job.TenantSlug, usageErr)
}
}
return nil
}
func (w *Worker) execute(ctx context.Context, job *Job) (any, error) {
switch job.JobType {
case KindChat:
var req provider.ChatRequest
if err := json.Unmarshal(job.Payload, &req); err != nil {
return nil, fmt.Errorf("chat-payload dekodieren: %w", err)
}
return w.provider.Chat(ctx, req)
case KindCompletion:
var req provider.CompletionRequest
if err := json.Unmarshal(job.Payload, &req); err != nil {
return nil, fmt.Errorf("completion-payload dekodieren: %w", err)
}
return w.provider.Complete(ctx, req)
case KindEmbedding:
var req provider.EmbeddingRequest
if err := json.Unmarshal(job.Payload, &req); err != nil {
return nil, fmt.Errorf("embedding-payload dekodieren: %w", err)
}
return w.provider.Embed(ctx, req)
default:
return nil, fmt.Errorf("queue: unbekannter job-typ %q", job.JobType)
}
}
// Run pollt die Queue in einem Intervall, bis ctx beendet wird. Damit laesst
// sich eine Lastspitze (Pruefung 1) mit mehreren nebenlaeufigen Run-Aufrufen
// (mehrere Goroutinen/Worker-IDs) ohne blockierten Anfrage-Pfad abarbeiten.
func (w *Worker) Run(ctx context.Context, pollInterval time.Duration) {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for {
err := w.ProcessOne(ctx)
if errors.Is(err, ErrNoJobAvailable) {
break
}
if err != nil {
log.Printf("queue worker %s: %v", w.id, err)
}
if ctx.Err() != nil {
return
}
}
}
}
}