|
| 1 | +// Unless explicitly stated otherwise all files in this repository are licensed |
| 2 | +// under the Apache License Version 2.0. |
| 3 | +// This product includes software developed at Datadog (https://www.datadoghq.com/). |
| 4 | +// Copyright 2016-present Datadog, Inc. |
| 5 | + |
| 6 | +//go:build test |
| 7 | + |
| 8 | +package errortracking |
| 9 | + |
| 10 | +import ( |
| 11 | + "sync" |
| 12 | + "testing" |
| 13 | + "time" |
| 14 | + |
| 15 | + "github.com/stretchr/testify/assert" |
| 16 | +) |
| 17 | + |
| 18 | +func TestBouncer_FirstSightingNotSuppressed(t *testing.T) { |
| 19 | + b := NewBouncer(15*time.Minute, 0) |
| 20 | + now := time.Now() |
| 21 | + suppressed, count, firstSeen := b.Observe(0xCAFE, now) |
| 22 | + assert.False(t, suppressed, "first sighting must NOT be suppressed") |
| 23 | + assert.Equal(t, uint32(1), count, "first sighting count") |
| 24 | + assert.True(t, firstSeen.Equal(now), "first sighting firstSeen = %v, want %v", firstSeen, now) |
| 25 | +} |
| 26 | + |
| 27 | +func TestBouncer_SecondSightingSuppressedInsideWindow(t *testing.T) { |
| 28 | + b := NewBouncer(15*time.Minute, 0) |
| 29 | + t0 := time.Now() |
| 30 | + b.Observe(0xCAFE, t0) |
| 31 | + |
| 32 | + t1 := t0.Add(time.Minute) |
| 33 | + suppressed, count, firstSeen := b.Observe(0xCAFE, t1) |
| 34 | + assert.True(t, suppressed, "second sighting inside window MUST be suppressed") |
| 35 | + assert.Equal(t, uint32(2), count, "second sighting count") |
| 36 | + assert.True(t, firstSeen.Equal(t0), "firstSeen drifted: got %v, want original %v", firstSeen, t0) |
| 37 | +} |
| 38 | + |
| 39 | +func TestBouncer_ResetsAfterWindow(t *testing.T) { |
| 40 | + b := NewBouncer(15*time.Minute, 0) |
| 41 | + t0 := time.Now() |
| 42 | + b.Observe(0xCAFE, t0) // count=1 |
| 43 | + b.Observe(0xCAFE, t0.Add(time.Minute)) // count=2 (suppressed) |
| 44 | + |
| 45 | + // Step past the window — next sighting MUST NOT be suppressed and |
| 46 | + // MUST carry the suppressed count of the prior window (1 = total-1) |
| 47 | + // rather than the full total, to avoid double-counting the first |
| 48 | + // sighting already delivered. The entry is then reset to a fresh |
| 49 | + // window internally. |
| 50 | + t2 := t0.Add(16 * time.Minute) |
| 51 | + suppressed, count, firstSeen := b.Observe(0xCAFE, t2) |
| 52 | + assert.False(t, suppressed, "sighting after window MUST NOT be suppressed") |
| 53 | + assert.Equal(t, uint32(1), count, "post-window count (want suppressed-only: total-1)") |
| 54 | + assert.True(t, firstSeen.Equal(t2), "post-window firstSeen = %v, want %v", firstSeen, t2) |
| 55 | +} |
| 56 | + |
| 57 | +// TestBouncer_WindowElapseCarriesPriorCount exercises the |
| 58 | +// suppressed-count carry-forward contract end-to-end: a hot bug path |
| 59 | +// with N sightings per window must deliver Count=1 at first sighting |
| 60 | +// and Count=N-1 on rollover (the suppressed sightings), so consumers |
| 61 | +// summing both get N without double-counting the first sighting. |
| 62 | +func TestBouncer_WindowElapseCarriesPriorCount(t *testing.T) { |
| 63 | + b := NewBouncer(15*time.Minute, 0) |
| 64 | + var key uint64 = 0xABCDEF |
| 65 | + |
| 66 | + t0 := time.Now() |
| 67 | + |
| 68 | + // First sighting — delivered, count=1. |
| 69 | + suppressed, count, _ := b.Observe(key, t0) |
| 70 | + assert.False(t, suppressed, "first sighting must NOT be suppressed") |
| 71 | + assert.Equal(t, uint32(1), count, "first sighting count") |
| 72 | + |
| 73 | + // Two more sightings within the window — both suppressed. |
| 74 | + suppressed, count, _ = b.Observe(key, t0.Add(5*time.Minute)) |
| 75 | + assert.True(t, suppressed, "sighting inside window MUST be suppressed") |
| 76 | + assert.Equal(t, uint32(2), count, "second sighting count") |
| 77 | + |
| 78 | + suppressed, count, _ = b.Observe(key, t0.Add(10*time.Minute)) |
| 79 | + assert.True(t, suppressed, "sighting inside window MUST be suppressed") |
| 80 | + assert.Equal(t, uint32(3), count, "third sighting count") |
| 81 | + |
| 82 | + // Window elapses; next sighting delivers the suppressed count of the |
| 83 | + // prior window (2 = total(3) - first-already-delivered(1)). |
| 84 | + suppressed, count, _ = b.Observe(key, t0.Add(20*time.Minute)) |
| 85 | + assert.False(t, suppressed, "window elapsed; sighting must be delivered") |
| 86 | + assert.Equal(t, uint32(2), count, "rollover count (want suppressed-only: total-1)") |
| 87 | + |
| 88 | + // Subsequent sighting in the new window — suppressed, count starts |
| 89 | + // from the post-reset 1 and increments to 2. |
| 90 | + suppressed, count, _ = b.Observe(key, t0.Add(21*time.Minute)) |
| 91 | + assert.True(t, suppressed, "sighting inside fresh window MUST be suppressed") |
| 92 | + assert.Equal(t, uint32(2), count, "post-reset sighting count") |
| 93 | +} |
| 94 | + |
| 95 | +func TestBouncer_PerPCIndependent(t *testing.T) { |
| 96 | + b := NewBouncer(15*time.Minute, 0) |
| 97 | + now := time.Now() |
| 98 | + b.Observe(0xCAFE, now) |
| 99 | + // A different PC must NOT see the suppression state of 0xCAFE. |
| 100 | + suppressed, count, _ := b.Observe(0xBEEF, now) |
| 101 | + assert.False(t, suppressed, "different PC must NOT be suppressed by another PC's sighting") |
| 102 | + assert.Equal(t, uint32(1), count, "different PC count") |
| 103 | +} |
| 104 | + |
| 105 | +func TestBouncer_DisabledWindowPassesThrough(t *testing.T) { |
| 106 | + b := NewBouncer(0, 0) |
| 107 | + now := time.Now() |
| 108 | + for i := 0; i < 5; i++ { |
| 109 | + suppressed, count, _ := b.Observe(0xCAFE, now.Add(time.Duration(i)*time.Second)) |
| 110 | + assert.False(t, suppressed, "disabled window must never suppress (i=%d)", i) |
| 111 | + assert.Equal(t, uint32(1), count, "disabled window count (i=%d)", i) |
| 112 | + } |
| 113 | +} |
| 114 | + |
| 115 | +func TestBouncer_RaceFree_ConcurrentObserve(t *testing.T) { |
| 116 | + b := NewBouncer(15*time.Minute, 1024) |
| 117 | + const goroutines = 32 |
| 118 | + const perGoroutine = 1000 |
| 119 | + |
| 120 | + var wg sync.WaitGroup |
| 121 | + wg.Add(goroutines) |
| 122 | + for g := 0; g < goroutines; g++ { |
| 123 | + g := g |
| 124 | + go func() { |
| 125 | + defer wg.Done() |
| 126 | + now := time.Now() |
| 127 | + for i := 0; i < perGoroutine; i++ { |
| 128 | + pc := uint64(g*1000 + i%50) // some overlap to exercise the suppress path |
| 129 | + b.Observe(pc, now) |
| 130 | + } |
| 131 | + }() |
| 132 | + } |
| 133 | + wg.Wait() |
| 134 | + // Survived without -race detecting anything; this is the |
| 135 | + // observable for the test (the race detector flags concurrent |
| 136 | + // map access or counter races at the failure site, not here). |
| 137 | + t.Log("concurrent observe completed without race violation") |
| 138 | +} |
| 139 | + |
| 140 | +func TestBouncer_PrunesNearCap(t *testing.T) { |
| 141 | + b := NewBouncer(time.Minute, 4) |
| 142 | + t0 := time.Now() |
| 143 | + // Fill the cap with entries that are about to expire. |
| 144 | + for pc := uint64(1); pc <= 4; pc++ { |
| 145 | + b.Observe(pc, t0) |
| 146 | + } |
| 147 | + // All entries are well past their window when we insert one more. |
| 148 | + t1 := t0.Add(2 * time.Minute) |
| 149 | + b.Observe(uint64(5), t1) |
| 150 | + |
| 151 | + b.mu.Lock() |
| 152 | + defer b.mu.Unlock() |
| 153 | + assert.LessOrEqual(t, len(b.entries), b.maxEntries, "entries exceeded cap after prune") |
| 154 | +} |
| 155 | + |
| 156 | +// TestBouncer_CapEnforced_WhenAllWithinWindow verifies that when the cap |
| 157 | +// is reached and pruneLocked removes nothing (all entries still within the |
| 158 | +// window), new keys are dropped rather than growing the map past maxEntries. |
| 159 | +// The dropped observation is returned as a pass-through (suppressed=false, |
| 160 | +// count=1) so the record still reaches the consumer without dedup tracking. |
| 161 | +func TestBouncer_CapEnforced_WhenAllWithinWindow(t *testing.T) { |
| 162 | + b := NewBouncer(time.Hour, 4) // large window: no entries expire |
| 163 | + t0 := time.Now() |
| 164 | + for pc := uint64(1); pc <= 4; pc++ { |
| 165 | + b.Observe(pc, t0) |
| 166 | + } |
| 167 | + |
| 168 | + // New unique key inserted while all existing entries are still within window. |
| 169 | + t1 := t0.Add(time.Minute) |
| 170 | + suppressed, count, _ := b.Observe(uint64(5), t1) |
| 171 | + assert.False(t, suppressed, "dropped key must not be reported as suppressed") |
| 172 | + assert.Equal(t, uint32(1), count, "dropped key must return count=1 (pass-through)") |
| 173 | + |
| 174 | + b.mu.Lock() |
| 175 | + defer b.mu.Unlock() |
| 176 | + assert.LessOrEqual(t, len(b.entries), b.maxEntries, "map must not exceed maxEntries") |
| 177 | +} |
0 commit comments