Files
nexarch/ai/internal/queue/queue.go
rootandClaude Sonnet 5 6933e310dc
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
CI / Core-Modul (Build/Vet/Test) (push) 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 / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) 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 (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
Core-Schnittstellen-Vertragstests / contract-tests (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
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 / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
QA-08: last-leistungstest
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-17 09:16:56 +00:00

272 lines
8.7 KiB
Go

// Package queue implementiert ENG-05: eine Postgres-gestuetzte Warteschlange
// fuer Inferenz-Aufrufe, damit lange KI-Anfragen den Anfrage-Pfad nicht
// blockieren (kein Redis, Techstack-Vorgabe). Struktur folgt dem etablierten
// Muster aus dms/internal/jobqueue und connect/internal/syncjob (Go-Module
// teilen keine internal/-Pakete, daher eigenstaendige Kopie).
package queue
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/limits"
)
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
StatusDeadLetter = "dead_letter"
)
var ErrNotFound = errors.New("queue: job nicht gefunden")
var ErrNoJobAvailable = errors.New("queue: kein job verfuegbar")
// ErrQueueFull ist QA-08s definiertes Ueberlastverhalten (AK3): Enqueue
// lehnt neue Jobs kontrolliert ab, statt die Warteschlange unbegrenzt
// anwachsen zu lassen, wenn bereits limits.MaxPendingJobs Jobs warten.
var ErrQueueFull = errors.New("queue: warteschlange ueberlastet, bitte spaeter erneut versuchen")
// Kind unterscheidet die Art des Inferenz-Aufrufs im Payload.
type Kind string
const (
KindChat Kind = "chat"
KindCompletion Kind = "completion"
KindEmbedding Kind = "embedding"
)
type Job struct {
ID string
JobType Kind
Payload json.RawMessage
Status string
Attempts int
MaxAttempts int
Result json.RawMessage
LastError *string
TenantSlug string
}
const DefaultMaxAttempts = 5
// QuotaEnforcer ist die schmale Schnittstelle zu GOV-04 (ai/internal/quota):
// blockiert Enqueue, wenn die Tenant-Quota bereits erreicht ist. Als
// Interface hier statt Import, damit queue (ENG-05) nicht von quota
// abhaengt — quota.Store erfuellt diese Schnittstelle strukturell.
type QuotaEnforcer interface {
Enforce(ctx context.Context, tenantSlug string) error
}
type Queue struct {
pool *pgxpool.Pool
staleLockAfter time.Duration
limits limits.Limits
quota QuotaEnforcer
}
func NewQueue(pool *pgxpool.Pool, staleLockAfter time.Duration) *Queue {
return &Queue{pool: pool, staleLockAfter: staleLockAfter, limits: limits.FromEnv()}
}
// SetLimits ueberschreibt die aus der Umgebung geladenen Obergrenzen (ENG-07),
// vor allem fuer Tests mit kleinen, deterministischen Werten.
func (q *Queue) SetLimits(l limits.Limits) { q.limits = l }
// SetQuotaEnforcer verknuepft die Queue mit GOV-04s Kostenkontrolle; ohne
// Aufruf bleibt Enqueue unveraendert unbegrenzt (Rueckwaertskompatibilitaet).
func (q *Queue) SetQuotaEnforcer(e QuotaEnforcer) { q.quota = e }
type EnqueueOptions struct {
IdempotencyKey string
MaxAttempts int
TenantSlug string
}
// Enqueue reiht einen Inferenz-Aufruf asynchron ein (AK1). Bei gesetztem
// IdempotencyKey liefert ein erneuter Aufruf mit demselben Key die ID des
// bereits vorhandenen Jobs statt eines Duplikats. Ist ein QuotaEnforcer
// gesetzt und TenantSlug angegeben, wird die Tenant-Quota VOR dem Anlegen
// des Jobs geprueft (GOV-04 AK2) — eine ueberschrittene Quota verhindert
// zuverlaessig, dass ueberhaupt erst neue Arbeit entsteht.
func (q *Queue) Enqueue(ctx context.Context, jobType Kind, payload any, opts EnqueueOptions) (string, error) {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("queue: payload serialisieren: %w", err)
}
if err := q.limits.CheckSize(len(payloadJSON)); err != nil {
return "", err
}
if q.quota != nil && opts.TenantSlug != "" {
if err := q.quota.Enforce(ctx, opts.TenantSlug); err != nil {
return "", err
}
}
if q.limits.MaxPendingJobs > 0 {
depth, err := q.Depth(ctx)
if err != nil {
return "", err
}
if depth.Pending >= int64(q.limits.MaxPendingJobs) {
return "", ErrQueueFull
}
}
maxAttempts := opts.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = DefaultMaxAttempts
}
var idempotencyKey any
if opts.IdempotencyKey != "" {
idempotencyKey = opts.IdempotencyKey
}
var tenantSlug any
if opts.TenantSlug != "" {
tenantSlug = opts.TenantSlug
}
var id string
err = q.pool.QueryRow(ctx, `
INSERT INTO inference_jobs (job_type, payload, idempotency_key, max_attempts, tenant_slug)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (idempotency_key) DO UPDATE SET job_type = inference_jobs.job_type
RETURNING id
`, string(jobType), payloadJSON, idempotencyKey, maxAttempts, tenantSlug).Scan(&id)
if err != nil {
return "", fmt.Errorf("queue: job einreihen: %w", err)
}
return id, nil
}
// Dequeue holt genau einen abholbaren Job und sperrt ihn atomar (FOR UPDATE
// SKIP LOCKED erlaubt mehrere gleichzeitige Worker ohne Doppelabholung).
func (q *Queue) Dequeue(ctx context.Context, workerID string) (*Job, error) {
tx, err := q.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("queue: transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
SELECT id, job_type, payload, status, attempts, max_attempts, last_error, COALESCE(tenant_slug, '')
FROM inference_jobs
WHERE (
(status = 'pending' AND available_at <= now())
OR (status = 'processing' AND locked_at <= now() - ($1 * interval '1 second'))
)
ORDER BY available_at
FOR UPDATE SKIP LOCKED
LIMIT 1
`, q.staleLockAfter.Seconds())
var j Job
var jobType string
if err := row.Scan(&j.ID, &jobType, &j.Payload, &j.Status, &j.Attempts, &j.MaxAttempts, &j.LastError, &j.TenantSlug); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNoJobAvailable
}
return nil, fmt.Errorf("queue: naechsten job lesen: %w", err)
}
j.JobType = Kind(jobType)
if _, err := tx.Exec(ctx, `
UPDATE inference_jobs
SET status = 'processing', attempts = attempts + 1, locked_at = now(), locked_by = $2, updated_at = now()
WHERE id = $1
`, j.ID, workerID); err != nil {
return nil, fmt.Errorf("queue: job sperren: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("queue: dequeue committen: %w", err)
}
j.Status = StatusProcessing
j.Attempts++
return &j, nil
}
func (q *Queue) Complete(ctx context.Context, jobID string, result any) error {
resultJSON, err := json.Marshal(result)
if err != nil {
return fmt.Errorf("queue: ergebnis serialisieren: %w", err)
}
tag, err := q.pool.Exec(ctx, `
UPDATE inference_jobs SET status = 'succeeded', result = $2, locked_at = NULL, locked_by = NULL, updated_at = now()
WHERE id = $1
`, jobID, resultJSON)
if err != nil {
return fmt.Errorf("queue: job abschliessen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// Fail markiert einen Job als fehlgeschlagen (AK2): sind die maximalen
// Versuche erreicht, wandert der Job in die Dead-Letter-Queue, sonst wird er
// mit Backoff erneut eingeplant. Die dabei anwachsende attempts-Spalte ist
// die Rueckverfolgung, die das Ticket fordert (jeder Versuch ist gezaehlt
// und der letzte Fehler bleibt bis zum naechsten Versuch sichtbar).
func (q *Queue) Fail(ctx context.Context, jobID string, cause error) error {
errMsg := cause.Error()
tag, err := q.pool.Exec(ctx, `
UPDATE inference_jobs
SET status = CASE WHEN attempts >= max_attempts THEN 'dead_letter' ELSE 'pending' END,
available_at = now() + (LEAST(attempts, 10) * interval '5 seconds'),
locked_at = NULL, locked_by = NULL, last_error = $2, updated_at = now()
WHERE id = $1
`, jobID, errMsg)
if err != nil {
return fmt.Errorf("queue: fehlschlag erfassen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func (q *Queue) Status(ctx context.Context, jobID string) (*Job, error) {
var j Job
var jobType string
err := q.pool.QueryRow(ctx, `
SELECT id, job_type, payload, status, attempts, max_attempts, result, last_error, COALESCE(tenant_slug, '')
FROM inference_jobs WHERE id = $1
`, jobID).Scan(&j.ID, &jobType, &j.Payload, &j.Status, &j.Attempts, &j.MaxAttempts, &j.Result, &j.LastError, &j.TenantSlug)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("queue: job-status lesen: %w", err)
}
j.JobType = Kind(jobType)
return &j, nil
}
// Depth liefert den beobachtbaren Warteschlangenstand (AK3): Anzahl Jobs je
// Status, fuer ein Monitoring-Endpunkt oder Metrik-Export geeignet.
type Depth struct {
Pending int64
Processing int64
DeadLetter int64
}
func (q *Queue) Depth(ctx context.Context) (Depth, error) {
var d Depth
err := q.pool.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE status = 'pending'),
count(*) FILTER (WHERE status = 'processing'),
count(*) FILTER (WHERE status = 'dead_letter')
FROM inference_jobs
`).Scan(&d.Pending, &d.Processing, &d.DeadLetter)
if err != nil {
return Depth{}, fmt.Errorf("queue: warteschlangenstand lesen: %w", err)
}
return d, nil
}