CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
AI-Schnittstellen-Vertragstests / contract-tests (push) Successful in 10m0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 57s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 5s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 5s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 19s
CI / Web (dms-browser) (push) Failing after 1m21s
CI / Web (pdf-viewer) (push) Failing after 52s
CI / Web (shl) (push) Failing after 10s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 2m57s
AI-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 12m17s
AI-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 11m55s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m6s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 21s
CI / Web (dms-browser) (pull_request) Failing after 1m30s
CI / Web (pdf-viewer) (pull_request) Failing after 1m0s
CI / Web (shl) (pull_request) Failing after 11s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 11m59s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 1m12s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m48s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 3m9s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 11m12s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
240 lines
7.4 KiB
Go
240 lines
7.4 KiB
Go
package queue
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
|
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
|
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
|
)
|
|
|
|
func newPool(t *testing.T) *pgxpool.Pool {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
|
if dsn == "" {
|
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
pool := testdb.Pool(t, dsn)
|
|
|
|
root, err := filepath.Abs("../../migrations")
|
|
if err != nil {
|
|
t.Fatalf("migrationsverzeichnis: %v", err)
|
|
}
|
|
migrations, err := migrate.Load(root)
|
|
if err != nil {
|
|
t.Fatalf("migrationen laden: %v", err)
|
|
}
|
|
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
|
t.Fatalf("migrationen anwenden: %v", err)
|
|
}
|
|
return pool
|
|
}
|
|
|
|
// AK1 + Pruefung 1: Inferenz-Aufrufe laufen asynchron, eine Lastspitze
|
|
// (viele gleichzeitige Enqueue-Aufrufe von mehreren nebenlaeufigen
|
|
// "Anfrage"-Goroutinen) blockiert nicht — jeder Enqueue-Aufruf kehrt sofort
|
|
// zurueck, unabhaengig davon ob der Worker gerade etwas verarbeitet.
|
|
func TestQueue_AsyncEnqueueUnderLoadSpike(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := NewQueue(pool, time.Minute)
|
|
|
|
const n = 50
|
|
var wg sync.WaitGroup
|
|
ids := make([]string, n)
|
|
start := time.Now()
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
id, err := q.Enqueue(context.Background(), KindChat, provider.ChatRequest{Model: "test"}, EnqueueOptions{})
|
|
if err != nil {
|
|
t.Errorf("enqueue %d: %v", i, err)
|
|
return
|
|
}
|
|
ids[i] = id
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
elapsed := time.Since(start)
|
|
if elapsed > 5*time.Second {
|
|
t.Fatalf("enqueue von %d anfragen dauerte %s, das deutet auf Blockierung hin", n, elapsed)
|
|
}
|
|
for i, id := range ids {
|
|
if id == "" {
|
|
t.Fatalf("job %d hat keine ID erhalten", i)
|
|
}
|
|
}
|
|
depth, err := q.Depth(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("depth: %v", err)
|
|
}
|
|
if depth.Pending < n {
|
|
t.Fatalf("depth.Pending = %d, erwartet mindestens %d", depth.Pending, n)
|
|
}
|
|
}
|
|
|
|
type fakeProvider struct {
|
|
calls atomic.Int64
|
|
failN int64 // erste failN Aufrufe schlagen fehl
|
|
failure error
|
|
}
|
|
|
|
func (f *fakeProvider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
|
|
n := f.calls.Add(1)
|
|
if n <= f.failN {
|
|
return provider.ChatResponse{}, f.failure
|
|
}
|
|
return provider.ChatResponse{Model: req.Model, Message: provider.Message{Role: provider.RoleAssistant, Content: "ok"}}, nil
|
|
}
|
|
func (f *fakeProvider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) {
|
|
return provider.CompletionResponse{}, nil
|
|
}
|
|
func (f *fakeProvider) Embed(ctx context.Context, req provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
|
|
return provider.EmbeddingResponse{}, nil
|
|
}
|
|
|
|
// AK2 + Pruefung 2: ein fehlgeschlagener Aufruf wird mit Rueckverfolgung
|
|
// (Versuchszaehler, letzter Fehler) wiederholt, bis er entweder gelingt oder
|
|
// die maximale Versuchszahl erreicht ist (dann dead_letter).
|
|
func TestQueue_RetryAfterFailure(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := NewQueue(pool, time.Minute)
|
|
fp := &fakeProvider{failN: 2, failure: &provider.Error{Provider: "fake", Code: provider.CodeUpstream, Message: "boom"}}
|
|
w := NewWorker(q, fp, "worker-1")
|
|
|
|
id, err := q.Enqueue(context.Background(), KindChat, provider.ChatRequest{Model: "test"}, EnqueueOptions{MaxAttempts: 5})
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
|
|
// Versuch 1: schlaegt fehl.
|
|
if err := w.ProcessOne(context.Background()); err == nil {
|
|
t.Fatal("erster Versuch haette fehlschlagen sollen")
|
|
}
|
|
st, err := q.Status(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if st.Status != StatusPending {
|
|
t.Fatalf("status nach 1. Fehlschlag = %q, erwartet pending (fuer erneuten Versuch)", st.Status)
|
|
}
|
|
if st.Attempts != 1 {
|
|
t.Fatalf("attempts = %d, erwartet 1 (Rueckverfolgung)", st.Attempts)
|
|
}
|
|
if st.LastError == nil || *st.LastError == "" {
|
|
t.Fatal("last_error sollte den letzten Fehler festhalten")
|
|
}
|
|
|
|
// Backoff nach vorne verlegen, damit der naechste Dequeue den Job sofort holt.
|
|
if _, err := pool.Exec(context.Background(), `UPDATE inference_jobs SET available_at = now() WHERE id = $1`, id); err != nil {
|
|
t.Fatalf("available_at vorziehen: %v", err)
|
|
}
|
|
|
|
// Versuch 2: schlaegt erneut fehl (failN=2).
|
|
if err := w.ProcessOne(context.Background()); err == nil {
|
|
t.Fatal("zweiter Versuch haette fehlschlagen sollen")
|
|
}
|
|
if _, err := pool.Exec(context.Background(), `UPDATE inference_jobs SET available_at = now() WHERE id = $1`, id); err != nil {
|
|
t.Fatalf("available_at vorziehen: %v", err)
|
|
}
|
|
|
|
// Versuch 3: gelingt (failN=2, dritter Aufruf laesst durch).
|
|
if err := w.ProcessOne(context.Background()); err != nil {
|
|
t.Fatalf("dritter Versuch haette gelingen sollen: %v", err)
|
|
}
|
|
st, err = q.Status(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if st.Status != StatusSucceeded {
|
|
t.Fatalf("status nach Erfolg = %q, erwartet succeeded", st.Status)
|
|
}
|
|
if st.Attempts != 3 {
|
|
t.Fatalf("attempts = %d, erwartet 3", st.Attempts)
|
|
}
|
|
}
|
|
|
|
// Erschoepfte Versuche wandern in die Dead-Letter-Queue statt endlos zu wiederholen.
|
|
func TestQueue_ExhaustedRetriesGoToDeadLetter(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := NewQueue(pool, time.Minute)
|
|
fp := &fakeProvider{failN: 1000, failure: errors.New("dauerhaft kaputt")}
|
|
w := NewWorker(q, fp, "worker-1")
|
|
|
|
id, err := q.Enqueue(context.Background(), KindChat, provider.ChatRequest{Model: "test"}, EnqueueOptions{MaxAttempts: 2})
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
for i := 0; i < 2; i++ {
|
|
_ = w.ProcessOne(context.Background())
|
|
if _, err := pool.Exec(context.Background(), `UPDATE inference_jobs SET available_at = now() WHERE id = $1`, id); err != nil {
|
|
t.Fatalf("available_at vorziehen: %v", err)
|
|
}
|
|
}
|
|
st, err := q.Status(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatalf("status: %v", err)
|
|
}
|
|
if st.Status != StatusDeadLetter {
|
|
t.Fatalf("status nach erschoepften Versuchen = %q, erwartet dead_letter", st.Status)
|
|
}
|
|
}
|
|
|
|
// AK3 + Pruefung 3: der Warteschlangenstand ist beobachtbar (fuer Monitoring).
|
|
func TestQueue_DepthIsObservable(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := NewQueue(pool, time.Minute)
|
|
|
|
before, err := q.Depth(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("depth: %v", err)
|
|
}
|
|
if _, err := q.Enqueue(context.Background(), KindEmbedding, provider.EmbeddingRequest{Model: "test"}, EnqueueOptions{}); err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
after, err := q.Depth(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("depth: %v", err)
|
|
}
|
|
if after.Pending != before.Pending+1 {
|
|
t.Fatalf("depth.Pending = %d, erwartet %d", after.Pending, before.Pending+1)
|
|
}
|
|
}
|
|
|
|
// Dequeue-Semantik: Keine doppelte Zustellung an mehrere gleichzeitig
|
|
// pollende Worker (FOR UPDATE SKIP LOCKED).
|
|
func TestQueue_DequeueIsExclusive(t *testing.T) {
|
|
pool := newPool(t)
|
|
q := NewQueue(pool, time.Minute)
|
|
id, err := q.Enqueue(context.Background(), KindChat, provider.ChatRequest{Model: "test"}, EnqueueOptions{})
|
|
if err != nil {
|
|
t.Fatalf("enqueue: %v", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
var got atomic.Int64
|
|
for i := 0; i < 5; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
j, err := q.Dequeue(context.Background(), "worker")
|
|
if err == nil && j.ID == id {
|
|
got.Add(1)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
if got.Load() != 1 {
|
|
t.Fatalf("job wurde %d mal zugestellt, erwartet genau 1", got.Load())
|
|
}
|
|
}
|