collapser_test.go 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. package dedupe
  2. import (
  3. "context"
  4. "sync"
  5. "sync/atomic"
  6. "testing"
  7. "time"
  8. "git3.techno-world.net/lrosales/broad-announce/internal/alert"
  9. )
  10. // flushMs used by most tests; small enough to be fast.
  11. const testFlushMs = 50
  12. // mkAlert is a small constructor so the tests stay readable.
  13. func mkAlert(severity, sourceID, key string, count uint32) alert.Alert {
  14. return alert.Alert{
  15. ID: "test-" + sourceID + "-" + key,
  16. CompanyID: "co",
  17. SourceID: sourceID,
  18. Severity: alert.Severity(severity),
  19. Category: "test",
  20. Title: "t",
  21. Body: "b",
  22. DedupeKey: key,
  23. DedupeCount: count,
  24. ReceivedAt: time.Now(),
  25. }
  26. }
  27. // TestObserve_Passthrough_EmptyKey: an empty dedupe_key is
  28. // delivered as Passthrough, the collapser holds nothing.
  29. func TestObserve_Passthrough_EmptyKey(t *testing.T) {
  30. var pass int32
  31. c := NewCollapser(testFlushMs*time.Millisecond, func(_, _ string, _ alert.Alert) {
  32. t.Fatal("onFlush should not be called for empty key")
  33. })
  34. c.OnPassthrough = func() { atomic.AddInt32(&pass, 1) }
  35. for i := 0; i < 5; i++ {
  36. a := mkAlert("warning", "prom-prod", "", 1)
  37. if d := c.Observe("prom-prod", "", a); d != Passthrough {
  38. t.Fatalf("expected Passthrough, got %v", d)
  39. }
  40. }
  41. if got := atomic.LoadInt32(&pass); got != 5 {
  42. t.Errorf("OnPassthrough fired %d times, want 5", got)
  43. }
  44. if c.Pending() != 0 {
  45. t.Errorf("Pending() = %d, want 0", c.Pending())
  46. }
  47. }
  48. // TestObserve_Collapse_FirstThenFlush: 100 observes with the
  49. // same (source, key) collapse into 1 flush, dedupe_count=100.
  50. func TestObserve_Collapse_FirstThenFlush(t *testing.T) {
  51. var (
  52. mu sync.Mutex
  53. flushed []alert.Alert
  54. flushSrc []string
  55. flushKey []string
  56. )
  57. c := NewCollapser(testFlushMs*time.Millisecond, func(src, key string, a alert.Alert) {
  58. mu.Lock()
  59. flushed = append(flushed, a)
  60. flushSrc = append(flushSrc, src)
  61. flushKey = append(flushKey, key)
  62. mu.Unlock()
  63. })
  64. for i := uint32(1); i <= 100; i++ {
  65. a := mkAlert("warning", "prom-prod", "burst-1", i)
  66. var d Decision
  67. if i == 1 {
  68. d = c.Observe("prom-prod", "burst-1", a)
  69. if d != CollapseNew {
  70. t.Fatalf("first Observe: got %v, want CollapseNew", d)
  71. }
  72. } else {
  73. d = c.Observe("prom-prod", "burst-1", a)
  74. if d != CollapseDupe {
  75. t.Fatalf("Observe #%d: got %v, want CollapseDupe", i, d)
  76. }
  77. }
  78. }
  79. if c.Pending() != 1 {
  80. t.Errorf("Pending() during burst = %d, want 1", c.Pending())
  81. }
  82. runCtx, cancel := context.WithCancel(context.Background())
  83. done := make(chan struct{})
  84. go func() { c.Run(runCtx); close(done) }()
  85. // Wait up to 1s for the flush. With testFlushMs=50, it
  86. // should fire on the next tick (≤25ms).
  87. deadline := time.Now().Add(1 * time.Second)
  88. for time.Now().Before(deadline) {
  89. mu.Lock()
  90. n := len(flushed)
  91. mu.Unlock()
  92. if n > 0 {
  93. break
  94. }
  95. time.Sleep(2 * time.Millisecond)
  96. }
  97. cancel()
  98. <-done
  99. c.FlushAll()
  100. mu.Lock()
  101. defer mu.Unlock()
  102. if len(flushed) != 1 {
  103. t.Fatalf("flushed %d alerts, want 1", len(flushed))
  104. }
  105. if flushed[0].DedupeCount != 100 {
  106. t.Errorf("flushed dedupe_count = %d, want 100", flushed[0].DedupeCount)
  107. }
  108. if flushSrc[0] != "prom-prod" || flushKey[0] != "burst-1" {
  109. t.Errorf("flushed (%q, %q), want (prom-prod, burst-1)", flushSrc[0], flushKey[0])
  110. }
  111. }
  112. // TestObserve_PerSourceIsolation: same key from two sources
  113. // produces two flushes.
  114. func TestObserve_PerSourceIsolation(t *testing.T) {
  115. var mu sync.Mutex
  116. flushed := make(map[string]alert.Alert) // key = "source|dedupeKey"
  117. c := NewCollapser(testFlushMs*time.Millisecond, func(src, key string, a alert.Alert) {
  118. mu.Lock()
  119. flushed[src+"|"+key] = a
  120. mu.Unlock()
  121. })
  122. for i := uint32(1); i <= 5; i++ {
  123. c.Observe("prom-prod", "shared", mkAlert("warning", "prom-prod", "shared", i))
  124. c.Observe("grafana", "shared", mkAlert("warning", "grafana", "shared", i))
  125. }
  126. if c.Pending() != 2 {
  127. t.Errorf("Pending() = %d, want 2", c.Pending())
  128. }
  129. runCtx, cancel := context.WithCancel(context.Background())
  130. done := make(chan struct{})
  131. go func() { c.Run(runCtx); close(done) }()
  132. // Wait for 2 flushes.
  133. deadline := time.Now().Add(1 * time.Second)
  134. for time.Now().Before(deadline) {
  135. mu.Lock()
  136. n := len(flushed)
  137. mu.Unlock()
  138. if n == 2 {
  139. break
  140. }
  141. time.Sleep(2 * time.Millisecond)
  142. }
  143. cancel()
  144. <-done
  145. c.FlushAll()
  146. mu.Lock()
  147. defer mu.Unlock()
  148. if len(flushed) != 2 {
  149. t.Fatalf("flushed %d, want 2", len(flushed))
  150. }
  151. if flushed["prom-prod|shared"].DedupeCount != 5 {
  152. t.Errorf("prom-prod count = %d, want 5", flushed["prom-prod|shared"].DedupeCount)
  153. }
  154. if flushed["grafana|shared"].DedupeCount != 5 {
  155. t.Errorf("grafana count = %d, want 5", flushed["grafana|shared"].DedupeCount)
  156. }
  157. }
  158. // TestRun_MaxWaitReFlush: a continuous stream re-flushes every
  159. // max-wait, each flush carrying the running count.
  160. func TestRun_MaxWaitReFlush(t *testing.T) {
  161. flushMs := 30
  162. var (
  163. mu sync.Mutex
  164. flushed []uint32
  165. )
  166. c := NewCollapser(time.Duration(flushMs)*time.Millisecond, func(_, _ string, a alert.Alert) {
  167. mu.Lock()
  168. flushed = append(flushed, a.DedupeCount)
  169. mu.Unlock()
  170. })
  171. runCtx, cancel := context.WithCancel(context.Background())
  172. done := make(chan struct{})
  173. go func() { c.Run(runCtx); close(done) }()
  174. // Send 90 alerts over ~100ms (1ms each). With flushMs=30ms
  175. // the first flush should fire around 30ms in. After the
  176. // flush, the (source, key) is removed from pending, so the
  177. // next Observe starts a new collapse window. Over 100ms we
  178. // should see 3+ flushes.
  179. for i := uint32(1); i <= 90; i++ {
  180. c.Observe("prom-prod", "continuous", mkAlert("warning", "prom-prod", "continuous", i))
  181. time.Sleep(1 * time.Millisecond)
  182. }
  183. // Wait a tick for any final flush.
  184. time.Sleep(2 * time.Duration(flushMs) * time.Millisecond)
  185. cancel()
  186. <-done
  187. c.FlushAll()
  188. mu.Lock()
  189. defer mu.Unlock()
  190. if len(flushed) < 2 {
  191. t.Errorf("got %d flushes, want ≥2 (continuous burst should re-flush)", len(flushed))
  192. }
  193. // Each flush count must be ≥1 and ≤90.
  194. for i, n := range flushed {
  195. if n < 1 || n > 90 {
  196. t.Errorf("flushed[%d] = %d, want 1..90", i, n)
  197. }
  198. }
  199. // Counts should be monotonically non-decreasing per window
  200. // (each new collapse window starts fresh; counts are local
  201. // to that window, so this is a weak assertion — just check
  202. // the last is > 0).
  203. if flushed[len(flushed)-1] == 0 {
  204. t.Errorf("final flush had count 0")
  205. }
  206. }
  207. // TestObserve_EmptyKeyDoesNotBlockRealKey: an empty-key alert
  208. // during a non-empty-key burst doesn't reset the burst's timer.
  209. func TestObserve_EmptyKeyDoesNotBlockRealKey(t *testing.T) {
  210. var mu sync.Mutex
  211. flushed := 0
  212. c := NewCollapser(testFlushMs*time.Millisecond, func(_, _ string, _ alert.Alert) {
  213. mu.Lock()
  214. flushed++
  215. mu.Unlock()
  216. })
  217. // Real key burst.
  218. c.Observe("prom-prod", "real", mkAlert("warning", "prom-prod", "real", 1))
  219. // Empty keys interspersed.
  220. for i := 0; i < 5; i++ {
  221. c.Observe("prom-prod", "", mkAlert("warning", "prom-prod", "", 1))
  222. }
  223. if c.Pending() != 1 {
  224. t.Errorf("Pending() = %d, want 1", c.Pending())
  225. }
  226. runCtx, cancel := context.WithCancel(context.Background())
  227. done := make(chan struct{})
  228. go func() { c.Run(runCtx); close(done) }()
  229. time.Sleep(2 * testFlushMs * time.Millisecond)
  230. cancel()
  231. <-done
  232. c.FlushAll()
  233. mu.Lock()
  234. defer mu.Unlock()
  235. if flushed != 1 {
  236. t.Errorf("flushed = %d, want 1 (empty keys must not interfere)", flushed)
  237. }
  238. }
  239. // TestObserve_ConcurrentSameKey: 8 goroutines all hitting the
  240. // same (source, key) — final dedupe_count is exactly the max
  241. // observed.
  242. func TestObserve_ConcurrentSameKey(t *testing.T) {
  243. var mu sync.Mutex
  244. flushed := alert.Alert{}
  245. c := NewCollapser(testFlushMs*time.Millisecond, func(_, _ string, a alert.Alert) {
  246. mu.Lock()
  247. flushed = a
  248. mu.Unlock()
  249. })
  250. const goroutines = 8
  251. const perG = 500
  252. var wg sync.WaitGroup
  253. wg.Add(goroutines)
  254. counter := uint32(0)
  255. for g := 0; g < goroutines; g++ {
  256. go func() {
  257. defer wg.Done()
  258. for i := 0; i < perG; i++ {
  259. n := atomic.AddUint32(&counter, 1)
  260. c.Observe("prom-prod", "concurrent", mkAlert("warning", "prom-prod", "concurrent", n))
  261. }
  262. }()
  263. }
  264. wg.Wait()
  265. runCtx, cancel := context.WithCancel(context.Background())
  266. done := make(chan struct{})
  267. go func() { c.Run(runCtx); close(done) }()
  268. deadline := time.Now().Add(2 * time.Second)
  269. for time.Now().Before(deadline) {
  270. mu.Lock()
  271. ok := flushed.DedupeCount > 0
  272. mu.Unlock()
  273. if ok {
  274. break
  275. }
  276. time.Sleep(2 * time.Millisecond)
  277. }
  278. cancel()
  279. <-done
  280. c.FlushAll()
  281. mu.Lock()
  282. defer mu.Unlock()
  283. want := uint32(goroutines * perG)
  284. if flushed.DedupeCount != want {
  285. t.Errorf("flushed dedupe_count = %d, want %d", flushed.DedupeCount, want)
  286. }
  287. }
  288. // TestFlushAll_DrainsPending: FlushAll publishes every held
  289. // entry immediately, regardless of timer state.
  290. func TestFlushAll_DrainsPending(t *testing.T) {
  291. var mu sync.Mutex
  292. flushed := []string{}
  293. c := NewCollapser(10*time.Second, func(src, key string, _ alert.Alert) {
  294. mu.Lock()
  295. flushed = append(flushed, src+"|"+key)
  296. mu.Unlock()
  297. })
  298. c.Observe("s1", "k1", mkAlert("warning", "s1", "k1", 1))
  299. c.Observe("s2", "k2", mkAlert("warning", "s2", "k2", 1))
  300. if c.Pending() != 2 {
  301. t.Fatalf("Pending() = %d, want 2", c.Pending())
  302. }
  303. c.FlushAll()
  304. if c.Pending() != 0 {
  305. t.Errorf("Pending() after FlushAll = %d, want 0", c.Pending())
  306. }
  307. mu.Lock()
  308. defer mu.Unlock()
  309. if len(flushed) != 2 {
  310. t.Errorf("flushed %d, want 2", len(flushed))
  311. }
  312. }