fix: durcir la validation et fiabiliser flush/arrêt idempotents

Co-authored-by: aider (openrouter/openai/gpt-5.3-codex) <aider@aider.chat>
This commit is contained in:
Jacquin Antoine
2026-02-28 20:10:28 +01:00
parent 81849b16d8
commit 7e9535122e
5 changed files with 239 additions and 123 deletions

View File

@ -9,6 +9,8 @@ import (
const (
// DefaultMaxBufferSize is the default maximum number of events per buffer
DefaultMaxBufferSize = 10000
// DefaultTimeWindow is used when no valid time window is provided
DefaultTimeWindow = time.Second
)
// CorrelationConfig holds the correlation configuration.
@ -25,8 +27,8 @@ type CorrelationService struct {
mu sync.Mutex
bufferA *eventBuffer
bufferB *eventBuffer
pendingA map[string]*list.Element // key -> list element containing NormalizedEvent
pendingB map[string]*list.Element
pendingA map[string][]*list.Element // key -> ordered elements containing *NormalizedEvent
pendingB map[string][]*list.Element
timeProvider TimeProvider
}
@ -60,12 +62,16 @@ func NewCorrelationService(config CorrelationConfig, timeProvider TimeProvider)
if config.MaxBufferSize <= 0 {
config.MaxBufferSize = DefaultMaxBufferSize
}
if config.TimeWindow <= 0 {
config.TimeWindow = DefaultTimeWindow
}
return &CorrelationService{
config: config,
bufferA: newEventBuffer(),
bufferB: newEventBuffer(),
pendingA: make(map[string]*list.Element),
pendingB: make(map[string]*list.Element),
pendingA: make(map[string][]*list.Element),
pendingB: make(map[string][]*list.Element),
timeProvider: timeProvider,
}
}
@ -84,20 +90,29 @@ func (s *CorrelationService) ProcessEvent(event *NormalizedEvent) []CorrelatedLo
if event.Source == SourceA && s.config.ApacheAlwaysEmit {
return []CorrelatedLog{NewCorrelatedLogFromEvent(event, "A")}
}
if event.Source == SourceB && s.config.NetworkEmit {
return []CorrelatedLog{NewCorrelatedLogFromEvent(event, "B")}
}
return nil
}
var results []CorrelatedLog
var (
results []CorrelatedLog
shouldBuffer bool
)
switch event.Source {
case SourceA:
results = s.processSourceA(event)
results, shouldBuffer = s.processSourceA(event)
case SourceB:
results = s.processSourceB(event)
results, shouldBuffer = s.processSourceB(event)
default:
return nil
}
// Add the new event to the appropriate buffer
s.addEvent(event)
if shouldBuffer {
s.addEvent(event)
}
return results
}
@ -112,54 +127,46 @@ func (s *CorrelationService) isBufferFull(source EventSource) bool {
return false
}
func (s *CorrelationService) processSourceA(event *NormalizedEvent) []CorrelatedLog {
func (s *CorrelationService) processSourceA(event *NormalizedEvent) ([]CorrelatedLog, bool) {
key := event.CorrelationKeyFull()
// Look for a matching B event
if elem, ok := s.pendingB[key]; ok {
bEvent := elem.Value.(*NormalizedEvent)
if s.eventsMatch(event, bEvent) {
// Found a match!
correlated := NewCorrelatedLog(event, bEvent)
s.bufferB.events.Remove(elem)
delete(s.pendingB, key)
return []CorrelatedLog{correlated}
}
// Look for the first matching B event (one-to-one first match)
if bEvent := s.findAndPopFirstMatch(s.bufferB, s.pendingB, key, func(other *NormalizedEvent) bool {
return s.eventsMatch(event, other)
}); bEvent != nil {
correlated := NewCorrelatedLog(event, bEvent)
return []CorrelatedLog{correlated}, false
}
// No match found
if s.config.ApacheAlwaysEmit {
orphan := NewCorrelatedLogFromEvent(event, "A")
return []CorrelatedLog{orphan}
return []CorrelatedLog{orphan}, false
}
// Keep in buffer for potential future match
return nil
return nil, true
}
func (s *CorrelationService) processSourceB(event *NormalizedEvent) []CorrelatedLog {
func (s *CorrelationService) processSourceB(event *NormalizedEvent) ([]CorrelatedLog, bool) {
key := event.CorrelationKeyFull()
// Look for a matching A event
if elem, ok := s.pendingA[key]; ok {
aEvent := elem.Value.(*NormalizedEvent)
if s.eventsMatch(aEvent, event) {
// Found a match!
correlated := NewCorrelatedLog(aEvent, event)
s.bufferA.events.Remove(elem)
delete(s.pendingA, key)
return []CorrelatedLog{correlated}
}
// Look for the first matching A event (one-to-one first match)
if aEvent := s.findAndPopFirstMatch(s.bufferA, s.pendingA, key, func(other *NormalizedEvent) bool {
return s.eventsMatch(other, event)
}); aEvent != nil {
correlated := NewCorrelatedLog(aEvent, event)
return []CorrelatedLog{correlated}, false
}
// No match found - B is never emitted alone per spec
// No match found
if s.config.NetworkEmit {
orphan := NewCorrelatedLogFromEvent(event, "B")
return []CorrelatedLog{orphan}
return []CorrelatedLog{orphan}, false
}
// Keep in buffer for potential future match (but won't be emitted alone)
return nil
// Keep in buffer for potential future match
return nil, true
}
func (s *CorrelationService) eventsMatch(a, b *NormalizedEvent) bool {
@ -176,10 +183,10 @@ func (s *CorrelationService) addEvent(event *NormalizedEvent) {
switch event.Source {
case SourceA:
elem := s.bufferA.events.PushBack(event)
s.pendingA[key] = elem
s.pendingA[key] = append(s.pendingA[key], elem)
case SourceB:
elem := s.bufferB.events.PushBack(event)
s.pendingB[key] = elem
s.pendingB[key] = append(s.pendingB[key], elem)
}
}
@ -192,22 +199,67 @@ func (s *CorrelationService) cleanExpired() {
s.cleanBuffer(s.bufferB, s.pendingB, cutoff)
}
// cleanBuffer removes expired events from a buffer (shared logic for A and B).
func (s *CorrelationService) cleanBuffer(buffer *eventBuffer, pending map[string]*list.Element, cutoff time.Time) {
// cleanBuffer removes expired events from a buffer.
func (s *CorrelationService) cleanBuffer(buffer *eventBuffer, pending map[string][]*list.Element, cutoff time.Time) {
for elem := buffer.events.Front(); elem != nil; {
event := elem.Value.(*NormalizedEvent)
if event.Timestamp.Before(cutoff) {
next := elem.Next()
key := event.CorrelationKeyFull()
buffer.events.Remove(elem)
if pending[key] == elem {
pending[key] = removeElementFromSlice(pending[key], elem)
if len(pending[key]) == 0 {
delete(pending, key)
}
elem = next
continue
}
// Events are inserted in arrival order; once we hit a non-expired event, stop.
break
}
}
func (s *CorrelationService) findAndPopFirstMatch(
buffer *eventBuffer,
pending map[string][]*list.Element,
key string,
matcher func(*NormalizedEvent) bool,
) *NormalizedEvent {
elements, ok := pending[key]
if !ok || len(elements) == 0 {
return nil
}
for idx, elem := range elements {
other := elem.Value.(*NormalizedEvent)
if !matcher(other) {
continue
}
buffer.events.Remove(elem)
updated := append(elements[:idx], elements[idx+1:]...)
if len(updated) == 0 {
delete(pending, key)
} else {
break // Events are ordered, so we can stop early
pending[key] = updated
}
return other
}
return nil
}
func removeElementFromSlice(elements []*list.Element, target *list.Element) []*list.Element {
if len(elements) == 0 {
return elements
}
for i, elem := range elements {
if elem == target {
return append(elements[:i], elements[i+1:]...)
}
}
return elements
}
// Flush forces emission of remaining buffered events (for shutdown).
@ -226,11 +278,20 @@ func (s *CorrelationService) Flush() []CorrelatedLog {
}
}
// Emit remaining B events as orphans only if explicitly enabled
if s.config.NetworkEmit {
for elem := s.bufferB.events.Front(); elem != nil; elem = elem.Next() {
event := elem.Value.(*NormalizedEvent)
orphan := NewCorrelatedLogFromEvent(event, "B")
results = append(results, orphan)
}
}
// Clear buffers
s.bufferA.events.Init()
s.bufferB.events.Init()
s.pendingA = make(map[string]*list.Element)
s.pendingB = make(map[string]*list.Element)
s.pendingA = make(map[string][]*list.Element)
s.pendingB = make(map[string][]*list.Element)
return results
}

View File

@ -144,10 +144,14 @@ func TestCorrelationService_Flush(t *testing.T) {
SrcPort: 8080,
}
svc.ProcessEvent(apacheEvent)
// A est émis immédiatement quand ApacheAlwaysEmit=true
results := svc.ProcessEvent(apacheEvent)
if len(results) != 1 {
t.Fatalf("expected 1 immediate orphan event, got %d", len(results))
}
flushed := svc.Flush()
if len(flushed) != 1 {
t.Errorf("expected 1 flushed event, got %d", len(flushed))
if len(flushed) != 0 {
t.Errorf("expected 0 flushed events, got %d", len(flushed))
}
}