148 lines
3.7 KiB
Go
148 lines
3.7 KiB
Go
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:
|
||
}
|
||
}
|