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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
272 lines
8.7 KiB
Go
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
|
|
}
|