Files
patrickandClaude Sonnet 5 2a084cbdc8 FDN-11: ressourcenlimits-f-r-asynchrone-verarbeitungsjobs
Basis: feature/fdn-09-verschluesselung-at-rest-schluesselverwaltung (enthaelt
FDN-04, wovon FDN-11 laut Ticket abhaengt; fdn-10 wurde NICHT als Basis
genommen, da FDN-04 kein Vorfahre von fdn-10 ist).

Ergaenzt harte, konfigurierbare Obergrenzen je Verarbeitungsjob (Zeit,
Speicher, Eingabegroesse/Seitenzahl) um internal/jobqueue: Ueberschreitung
fuehrt zum kontrollierten Abbruch in die Dead-Letter-Queue statt zur
Blockade des Workers, inkl. Nutzerbenachrichtigung ueber ein neues
Notifier-Interface.

- limits.go: Limits-Struct, DefaultLimits, Vorab-Pruefung von
  size_bytes/pages aus den Job-Metadaten vor Handler-Start
- notifier.go: Notifier-Interface + LogNotifier als Standard
- queue.go: Queue.FailPermanently fuer sofortigen DLQ-Transfer (kein
  Backoff/Retry bei Ressourcenlimit-Verstoss)
- worker.go: Worker.runWithLimits fuehrt Handler mit Zeit-Timeout und
  Speicher-Sampling (runtime.MemStats, prozessweite Annaeherung) aus und
  kehrt bei Ueberschreitung sofort zurueck statt zu blockieren

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPsyieCYwvbUBsnN598SbP
2026-09-17 12:30:17 +02:00

273 lines
8.8 KiB
Go

package jobqueue
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeNotifier struct {
mu sync.Mutex
calls []string
}
func (f *fakeNotifier) NotifyJobAborted(_ context.Context, job *Job, reason string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, job.ID+": "+reason)
return nil
}
func (f *fakeNotifier) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
// TestWorker_InputLimitExceeded_AbortsWithoutRunningHandler ist Akzeptanz-
// kriterium 1+2 sowie Pruefung 1 aus dem Ticket: eine Datei, die die
// Seitenobergrenze ueberschreitet, fuehrt zum kontrollierten Abbruch
// (Dead-Letter-Queue), OHNE dass der Handler ueberhaupt gestartet wird.
// Zugleich Pruefung 3 (Nutzerbenachrichtigung).
func TestWorker_InputLimitExceeded_AbortsWithoutRunningHandler(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "convert", map[string]any{"pages": 5000}, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
var handlerCalled int32
notifier := &fakeNotifier{}
w := NewWorker(q, "worker-1", nil, time.Millisecond, func(ctx context.Context, job *Job) error {
atomic.AddInt32(&handlerCalled, 1)
return nil
}, WithDefaultLimits(Limits{MaxDuration: time.Second, MaxPages: 100}), WithNotifier(notifier))
w.RunOnce(ctx)
if atomic.LoadInt32(&handlerCalled) != 0 {
t.Fatalf("handler wurde trotz Seitenlimit-Ueberschreitung aufgerufen")
}
status, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.Status != StatusDeadLetter {
t.Fatalf("status = %q, want %q", status.Status, StatusDeadLetter)
}
if status.LastError == nil || !strings.Contains(*status.LastError, "seitenzahl") {
t.Fatalf("last_error = %v, want hinweis auf seitenzahl", status.LastError)
}
if notifier.count() != 1 {
t.Fatalf("notifier.calls = %d, want 1 (nutzer muss informiert werden)", notifier.count())
}
}
// TestWorker_HandlerExceedsMaxDuration_AbortsControlled ist Akzeptanz-
// kriterium 2: ein Handler, der die Zeitobergrenze ueberschreitet
// (und den Kontext ignoriert), wird kontrolliert abgebrochen. RunOnce kehrt
// dabei nahe der Obergrenze zurueck statt erst nach Handler-Ende — der
// Worker wird also nicht blockiert (Akzeptanzkriterium 3).
func TestWorker_HandlerExceedsMaxDuration_AbortsControlled(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "ocr", nil, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
notifier := &fakeNotifier{}
w := NewWorker(q, "worker-1", nil, time.Millisecond, func(ctx context.Context, job *Job) error {
// Ignoriert absichtlich ctx.Done(), simuliert einen entgleisten Job
// (z.B. hängende Konvertierungsbibliothek).
time.Sleep(2 * time.Second)
return nil
}, WithDefaultLimits(Limits{MaxDuration: 50 * time.Millisecond}), WithNotifier(notifier))
start := time.Now()
w.RunOnce(ctx)
elapsed := time.Since(start)
if elapsed > 500*time.Millisecond {
t.Fatalf("RunOnce dauerte %s, Worker wurde durch entgleisten Job blockiert (wollte < 500ms)", elapsed)
}
status, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.Status != StatusDeadLetter {
t.Fatalf("status = %q, want %q", status.Status, StatusDeadLetter)
}
if status.LastError == nil || !strings.Contains(*status.LastError, "verarbeitungszeit") {
t.Fatalf("last_error = %v, want hinweis auf verarbeitungszeit", status.LastError)
}
if notifier.count() != 1 {
t.Fatalf("notifier.calls = %d, want 1", notifier.count())
}
}
// TestWorker_MemoryLimitExceeded_AbortsControlled ist Akzeptanzkriterium 1/2
// fuer die Speicherobergrenze: ein Handler, der kontinuierlich Speicher
// allokiert, wird abgebrochen bevor er die MaxDuration erreicht.
func TestWorker_MemoryLimitExceeded_AbortsControlled(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
id, err := q.Enqueue(ctx, "convert", nil, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
notifier := &fakeNotifier{}
w := NewWorker(q, "worker-1", nil, time.Millisecond, func(ctx context.Context, job *Job) error {
buf := make([][]byte, 0)
for i := 0; i < 500; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
buf = append(buf, make([]byte, 2*1024*1024)) // 2 MiB je Schritt
time.Sleep(5 * time.Millisecond)
}
return nil
}, WithDefaultLimits(Limits{MaxDuration: 10 * time.Second, MaxMemoryBytes: 8 * 1024 * 1024}), WithNotifier(notifier))
start := time.Now()
w.RunOnce(ctx)
elapsed := time.Since(start)
if elapsed > 3*time.Second {
t.Fatalf("RunOnce dauerte %s, wollte deutlich unter der MaxDuration abbrechen", elapsed)
}
status, err := q.Status(ctx, id)
if err != nil {
t.Fatalf("status: %v", err)
}
if status.Status != StatusDeadLetter {
t.Fatalf("status = %q, want %q (last_error=%v)", status.Status, StatusDeadLetter, status.LastError)
}
if notifier.count() != 1 {
t.Fatalf("notifier.calls = %d, want 1", notifier.count())
}
}
// TestWorker_ExceedingJob_DoesNotBlockNextJobOnSameWorker ist Pruefung 2
// (in reduzierter, deterministischer Form): derselbe Worker verarbeitet nach
// einem kontrolliert abgebrochenen, entgleisten Job sofort den naechsten
// regulaeren Job weiter, statt auf das (im Hintergrund weiterlaufende)
// Handler-Ende des ersten Jobs zu warten.
func TestWorker_ExceedingJob_DoesNotBlockNextJobOnSameWorker(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
slowID, err := q.Enqueue(ctx, "slow-job", nil, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue slow: %v", err)
}
time.Sleep(5 * time.Millisecond) // available_at-Reihenfolge sichern
normalID, err := q.Enqueue(ctx, "normal-job", nil, EnqueueOptions{})
if err != nil {
t.Fatalf("enqueue normal: %v", err)
}
var normalHandled int32
w := NewWorker(q, "worker-1", nil, time.Millisecond, func(ctx context.Context, job *Job) error {
if job.JobType == "slow-job" {
time.Sleep(2 * time.Second) // ignoriert ctx absichtlich
return nil
}
atomic.AddInt32(&normalHandled, 1)
return nil
},
WithJobTypeLimits("slow-job", Limits{MaxDuration: 30 * time.Millisecond}),
WithDefaultLimits(Limits{MaxDuration: time.Second}),
)
start := time.Now()
w.RunOnce(ctx) // slow-job: bricht nach ~30ms kontrolliert ab
w.RunOnce(ctx) // normal-job: muss sofort verarbeitet werden
elapsed := time.Since(start)
if elapsed > 1*time.Second {
t.Fatalf("beide RunOnce-Aufrufe dauerten zusammen %s, normal-job wurde durch slow-job blockiert", elapsed)
}
if atomic.LoadInt32(&normalHandled) != 1 {
t.Fatalf("normal-job wurde nicht verarbeitet")
}
slowStatus, err := q.Status(ctx, slowID)
if err != nil {
t.Fatalf("status slow: %v", err)
}
if slowStatus.Status != StatusDeadLetter {
t.Fatalf("slow-job status = %q, want %q", slowStatus.Status, StatusDeadLetter)
}
normalStatus, err := q.Status(ctx, normalID)
if err != nil {
t.Fatalf("status normal: %v", err)
}
if normalStatus.Status != StatusSucceeded {
t.Fatalf("normal-job status = %q, want %q", normalStatus.Status, StatusSucceeded)
}
}
// TestWorker_ExceedingJobDoesNotBlockParallelRegularJobs ist der Lasttest
// aus Pruefung 2: mehrere Worker-Aufrufe (mehrere Worker-Prozesse simulierend,
// vgl. FDN-04-Design "mehrinstanzfaehig") laufen gleichzeitig gegen dieselbe
// Queue; ein ueberschreitender Job (Seitenlimit) blockiert die parallele
// Verarbeitung der regulaeren Jobs nicht.
func TestWorker_ExceedingJobDoesNotBlockParallelRegularJobs(t *testing.T) {
pool := setupTest(t)
q := NewQueue(pool, time.Minute)
ctx := context.Background()
if _, err := q.Enqueue(ctx, "convert", map[string]any{"pages": 99999}, EnqueueOptions{}); err != nil {
t.Fatalf("enqueue oversized: %v", err)
}
const normalJobs = 10
for i := 0; i < normalJobs; i++ {
if _, err := q.Enqueue(ctx, "convert", map[string]any{"pages": 1}, EnqueueOptions{}); err != nil {
t.Fatalf("enqueue normal %d: %v", i, err)
}
}
var completed int32
notifier := &fakeNotifier{}
w := NewWorker(q, "worker-1", nil, time.Millisecond, func(ctx context.Context, job *Job) error {
atomic.AddInt32(&completed, 1)
return nil
}, WithDefaultLimits(Limits{MaxDuration: time.Second, MaxPages: 100}), WithNotifier(notifier))
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < normalJobs+1; i++ {
wg.Add(1)
go func() {
defer wg.Done()
w.RunOnce(ctx)
}()
}
wg.Wait()
elapsed := time.Since(start)
if elapsed > 2*time.Second {
t.Fatalf("parallele verarbeitung dauerte %s, uebergrosser job hat andere jobs blockiert", elapsed)
}
if atomic.LoadInt32(&completed) != normalJobs {
t.Fatalf("completed = %d, want %d", completed, normalJobs)
}
if notifier.count() != 1 {
t.Fatalf("notifier.calls = %d, want 1 (nur der uebergrosse job)", notifier.count())
}
}