guenther/internal/detect/interface.go

148 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package detect
import (
"context"
"log"
"sync"
"time"
"codeberg.org/pata1704/guenther/pkg/types"
)
// AnomalyDetector is the common interface for all detection algorithms.
// Implementations must be safe for concurrent use.
type AnomalyDetector interface {
// Fit trains the model on the supplied slice of labelled-normal vectors.
Fit(vectors []types.FeatureVector) error
// Score returns an anomaly assessment for vector. It must not block.
Score(vector types.FeatureVector) (types.AnomalyResult, error)
// Update buffers vector for incremental model updates.
Update(vector types.FeatureVector) error
}
// DetectionLayer reads FeatureVectors from inputChan, scores them with the
// configured AnomalyDetector, and forwards AnomalyResults to outputChan.
//
// The layer runs a single event-loop goroutine (no additional worker pool is
// needed because detection is CPU-bound in a single model, not I/O-bound).
// Health metrics are emitted to healthChan every 5 seconds.
//
// Backpressure: if outputChan is full the result is dropped and a warning is
// logged. This prevents the detection goroutine from blocking the upstream
// TransformEngine via backpressure handling.
type DetectionLayer struct {
detector AnomalyDetector
inputChan <-chan types.FeatureVector
outputChan chan<- types.AnomalyResult
healthChan chan<- types.StageHealth
scalingController *ScalingController // optional
wg sync.WaitGroup
mu sync.Mutex
processed uint64
dropped uint64
avgLatency float64
}
// NewDetectionLayer constructs a DetectionLayer wired to the given channels.
func NewDetectionLayer(
detector AnomalyDetector,
input <-chan types.FeatureVector,
output chan<- types.AnomalyResult,
health chan<- types.StageHealth,
) *DetectionLayer {
return &DetectionLayer{
detector: detector,
inputChan: input,
outputChan: output,
healthChan: health,
}
}
// SetScalingController attaches an auto-scaling controller to the layer.
func (l *DetectionLayer) SetScalingController(sc *ScalingController) {
l.scalingController = sc
}
// Start launches the detection event loop in a background goroutine.
// The method is idempotent: calling Start twice panics (close of closed channel).
func (l *DetectionLayer) Start(ctx context.Context) {
l.wg.Go(func() {
reportTicker := time.NewTicker(5 * time.Second)
defer reportTicker.Stop()
for {
select {
case fv := <-l.inputChan:
l.handle(fv)
case <-reportTicker.C:
l.emitHealth()
case <-ctx.Done():
return
}
}
})
}
// Wait waits for the event loop to exit after context cancellation.
func (l *DetectionLayer) Wait() {
l.wg.Wait()
}
func (l *DetectionLayer) handle(fv types.FeatureVector) {
if l.scalingController != nil {
l.scalingController.ObserveCPU(fv.AvgCPUPercent)
}
start := time.Now()
result, err := l.detector.Score(fv)
ms := time.Since(start).Seconds() * 1e3
l.mu.Lock()
l.processed++
if l.avgLatency == 0 {
l.avgLatency = ms
} else {
l.avgLatency = l.avgLatency*0.8 + ms*0.2
}
l.mu.Unlock()
if err != nil {
log.Printf("detection: score error: %v", err)
return
}
select {
case l.outputChan <- result:
default:
l.mu.Lock()
l.dropped++
l.mu.Unlock()
log.Printf("detection: output channel full dropping result (score=%.4f)", result.Score)
}
}
// emitHealth sends a StageHealth snapshot to healthChan.
// Non-blocking: skips the report if healthChan is full.
func (l *DetectionLayer) emitHealth() {
l.mu.Lock()
p := l.processed
d := l.dropped
avg := l.avgLatency
l.mu.Unlock()
select {
case l.healthChan <- types.StageHealth{
StageName: "detection_layer",
EventsProcessed: p,
EventsDropped: d,
AvgLatencyMs: avg,
LastUpdate: time.Now(),
}:
default:
}
}