diff --git a/docs/pro-runtime-hardening-workboard.md b/docs/pro-runtime-hardening-workboard.md index a1cd7d3..202f7a8 100644 --- a/docs/pro-runtime-hardening-workboard.md +++ b/docs/pro-runtime-hardening-workboard.md @@ -199,11 +199,12 @@ Generator/Upsampler und Hardwarewriter werden als getrennte Stufen mit kleinem, - **Akzeptanzpunkte:** - Keine unbounded Queue. - Fill-Level (High/Low) ist aus `QueueStats` sichtbar. + - Queue-Health-Indikator (`queue.health`) liefert `critical`, `low` oder `normal` aus dem Fill-Level. EngineStats.`queue` zeigt den Status ebenfalls. - Drop/Repeat/Mute-Counter sind vorhanden und testbar. - **Nachweis:** - `FrameQueue`-Implementierung (`internal/output/frame_queue.go`) liefert kapazitätsgesteuerte Push/Pop-Logik und Counters. - Engine-Run nutzt Queue vor dem Writer und zeigt `QueueStats` in `EngineStats`. - - Tests (`internal/output/frame_queue_test.go` + `go test ./...`) decken Push/Pop, Timeout-Counters und Stats ab. + - Tests (`internal/output/frame_queue_test.go` + `go test ./...`) decken Push/Pop, Timeout-Counters, Stats und den neuen Queue-Health-Indikator ab. - **Restrisiken:** - Die Queue wird aktuell synchron getrieben; ein dedizierter Writer-Worker fehlt noch. - Queue-Close erwartet, dass Generator/Writer vor dem Schließen stoppen, sonst droht Panik beim Schreiben. @@ -246,11 +247,13 @@ Generator/Upsampler und Hardwarewriter werden als getrennte Stufen mit kleinem, | Datum | Entscheidung | Notiz | |---|---|---| | 2026-04-05 | FrameQueue mit Engine-Integration | Queue lebt nach dem Upsampler auf DeviceFrame-Ebene, Kapazität via `runtime.frameQueueCapacity`, `EngineStats` zeigt `QueueStats`, Tests decken Timeouts und Counters ab. | +| 2026-04-05 | Queue-Health-Indikator | `QueueStats.Health` gibt `critical`/`low`/`normal` zurück und ist über `EngineStats.Queue` im Runtime-Endpunkt sichtbar. | ## WS-01 Verifikation | Datum | Fokus | Ergebnis | |---|---|---| | 2026-04-05 | FrameQueue + Engine integration | ✅ `go test ./...` (im `internal`-Modul incl. `frame_queue_test.go`) | +| 2026-04-05 | Queue-Health-Indikator | go test ./... deckt `TestFrameQueueHealthIndicator` und `queue.health` ab. | --- diff --git a/internal/output/frame_queue.go b/internal/output/frame_queue.go index 22b6d38..e3db114 100644 --- a/internal/output/frame_queue.go +++ b/internal/output/frame_queue.go @@ -12,17 +12,31 @@ var ErrFrameQueueClosed = errors.New("frame queue closed") // QueueStats exposes the runtime state of a frame queue. type QueueStats struct { - Capacity int `json:"capacity"` - Depth int `json:"depth"` - FillLevel float64 `json:"fillLevel"` - HighWaterMark int `json:"highWaterMark"` - LowWaterMark int `json:"lowWaterMark"` - PushTimeouts uint64 `json:"pushTimeouts"` - PopTimeouts uint64 `json:"popTimeouts"` - DroppedFrames uint64 `json:"droppedFrames"` - RepeatedFrames uint64 `json:"repeatedFrames"` - MutedFrames uint64 `json:"mutedFrames"` -} + Capacity int `json:"capacity"` + Depth int `json:"depth"` + FillLevel float64 `json:"fillLevel"` + Health QueueHealth `json:"health"` + HighWaterMark int `json:"highWaterMark"` + LowWaterMark int `json:"lowWaterMark"` + PushTimeouts uint64 `json:"pushTimeouts"` + PopTimeouts uint64 `json:"popTimeouts"` + DroppedFrames uint64 `json:"droppedFrames"` + RepeatedFrames uint64 `json:"repeatedFrames"` + MutedFrames uint64 `json:"mutedFrames"` +} + +type QueueHealth string + +const ( + QueueHealthCritical QueueHealth = "critical" + QueueHealthLow QueueHealth = "low" + QueueHealthNormal QueueHealth = "normal" +) + +const ( + queueHealthCriticalThreshold = 0.2 + queueHealthLowThreshold = 0.5 +) // FrameQueue is a bounded ring that holds CompositeFrame instances between the // generator and the writer. Push blocks when the queue is full until space @@ -87,10 +101,12 @@ func (q *FrameQueue) Depth() int { // Stats returns a snapshot of the queue metrics. func (q *FrameQueue) Stats() QueueStats { q.mu.Lock() + fill := q.fillLevelLocked() stats := QueueStats{ Capacity: q.capacity, Depth: q.depth, - FillLevel: q.fillLevelLocked(), + FillLevel: fill, + Health: queueHealthFromFill(fill), HighWaterMark: q.highWaterMark, LowWaterMark: q.lowWaterMark, PushTimeouts: q.pushTimeouts, @@ -209,3 +225,14 @@ func (q *FrameQueue) recordPopTimeout() { q.popTimeouts++ q.mu.Unlock() } + +func queueHealthFromFill(fill float64) QueueHealth { + switch { + case fill <= queueHealthCriticalThreshold: + return QueueHealthCritical + case fill <= queueHealthLowThreshold: + return QueueHealthLow + default: + return QueueHealthNormal + } +} diff --git a/internal/output/frame_queue_test.go b/internal/output/frame_queue_test.go index 90f3460..86c0e8e 100644 --- a/internal/output/frame_queue_test.go +++ b/internal/output/frame_queue_test.go @@ -80,3 +80,44 @@ func TestFrameQueueCounters(t *testing.T) { t.Fatalf("expected 1 mute, got %d", stats.MutedFrames) } } + +func TestFrameQueueHealthIndicator(t *testing.T) { + q := NewFrameQueue(4) + ctx := context.Background() + + stats := q.Stats() + if stats.Health != QueueHealthCritical { + t.Fatalf("expected initial health critical, got %s", stats.Health) + } + + push := func(seq int) { + frame := &CompositeFrame{Sequence: seq} + if err := q.Push(ctx, frame); err != nil { + t.Fatalf("push %d failed: %v", seq, err) + } + } + + push(1) + stats = q.Stats() + if stats.Health != QueueHealthLow { + t.Fatalf("expected low after one frame, got %s", stats.Health) + } + + push(2) + stats = q.Stats() + if stats.Health != QueueHealthLow { + t.Fatalf("expected low at 50%% fill, got %s", stats.Health) + } + + push(3) + stats = q.Stats() + if stats.Health != QueueHealthNormal { + t.Fatalf("expected normal once queue has ~75%% fill, got %s", stats.Health) + } + + for q.Depth() > 0 { + if _, err := q.Pop(ctx); err != nil { + t.Fatalf("cleanup pop failed: %v", err) + } + } +}