controller_pending_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. package exporter
  2. // These tests exercise the bounded pending set introduced by OC-01. They rely
  3. // on unexported controller members added by the fix:
  4. //
  5. // maxPendingWindows (integer field) cap on closed windows awaiting export
  6. // maxExportsPerTick (integer field) cap on closed-window exports per tick
  7. // droppedWindows (uint64 field) count of windows evicted from pending
  8. // pendingCount() int closed windows currently pending
  9. // (excludes the current, open window)
  10. import (
  11. "fmt"
  12. "testing"
  13. "time"
  14. "github.com/opencost/opencost/core/pkg/opencost"
  15. "github.com/opencost/opencost/core/pkg/source"
  16. )
  17. func TestComputeExportController_EvictsBeyondMaxPending(t *testing.T) {
  18. src := &fakeComputeSource[controllerTestSet]{}
  19. // 6 closed windows fall in the outage: [08:00..13:00] (all close in [09:00,14:30)).
  20. outageStart, outageEnd := at(9, 0, 0), at(14, 30, 0)
  21. exp := &fakeComputeExporter[controllerTestSet]{
  22. failIf: func(_ opencost.Window, now time.Time) bool {
  23. return !now.Before(outageStart) && now.Before(outageEnd)
  24. },
  25. }
  26. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  27. c.maxPendingWindows = 3
  28. ticks := ticksEvery(at(8, 0, 0), at(16, 30, 0), 5*time.Minute)
  29. runTicks(c, exp, ticks, func(_ int, now time.Time) {
  30. if n := c.pendingCount(); n > 3 {
  31. t.Errorf("after tick %s pendingCount() = %d, want <= 3", now.Format("15:04:05"), n)
  32. }
  33. })
  34. recs := exp.Records()
  35. evicted := []time.Time{at(8, 0, 0), at(9, 0, 0), at(10, 0, 0)}
  36. kept := []time.Time{at(11, 0, 0), at(12, 0, 0), at(13, 0, 0)}
  37. for _, w := range kept {
  38. if firstPostCloseSuccess(recs, w) < 0 {
  39. t.Errorf("window %s is within maxPendingWindows but never got a post-close export", hourWindow(w))
  40. }
  41. }
  42. for _, w := range evicted {
  43. if i := firstPostCloseSuccess(recs, w); i >= 0 {
  44. t.Errorf("window %s should have been evicted but was exported post-close at %s",
  45. hourWindow(w), recs[i].Now.Format("15:04:05"))
  46. }
  47. }
  48. if got := uint64(c.droppedWindows); got != uint64(len(evicted)) {
  49. t.Errorf("droppedWindows = %d, want %d", got, len(evicted))
  50. }
  51. if t.Failed() {
  52. dumpRecords(t, recs)
  53. }
  54. }
  55. func TestComputeExportController_CapsExportsPerTick(t *testing.T) {
  56. src := &fakeComputeSource[controllerTestSet]{}
  57. // outage covers closes of [08:00..12:00] → 5 closed windows pending at 13:30.
  58. outageStart, recovery := at(9, 0, 0), at(13, 30, 0)
  59. exp := &fakeComputeExporter[controllerTestSet]{
  60. failIf: func(_ opencost.Window, now time.Time) bool {
  61. return !now.Before(outageStart) && now.Before(recovery)
  62. },
  63. }
  64. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  65. c.maxExportsPerTick = 2
  66. ticks := ticksEvery(at(8, 0, 0), at(15, 0, 0), 5*time.Minute)
  67. runTicks(c, exp, ticks, nil)
  68. recs := exp.Records()
  69. // per tick: at most 2 closed-window attempts, plus the current window.
  70. for i, now := range ticks {
  71. cur := now.Truncate(time.Hour)
  72. closed, current := 0, 0
  73. for _, r := range recs {
  74. if r.Tick != i {
  75. continue
  76. }
  77. if r.Start.Equal(cur) {
  78. current++
  79. } else {
  80. closed++
  81. }
  82. }
  83. if closed > 2 {
  84. t.Errorf("tick %s attempted %d closed windows, want <= 2", now.Format("15:04:05"), closed)
  85. }
  86. if current != 1 {
  87. t.Errorf("tick %s attempted the current window %d times, want 1", now.Format("15:04:05"), current)
  88. }
  89. }
  90. // every pending window drains within maxRetryBackoffTicks ticks of recovery
  91. drainedBy := recovery.Add(maxRetryBackoffTicks * 5 * time.Minute)
  92. for w := at(8, 0, 0); w.Before(at(13, 0, 0)); w = w.Add(time.Hour) {
  93. i := firstPostCloseSuccess(recs, w)
  94. if i < 0 {
  95. t.Errorf("window %s never got a post-close export", hourWindow(w))
  96. continue
  97. }
  98. if recs[i].Now.After(drainedBy) {
  99. t.Errorf("window %s finalized at %s, after the drain bound %s",
  100. hourWindow(w), recs[i].Now.Format("15:04:05"), drainedBy.Format("15:04:05"))
  101. }
  102. }
  103. if t.Failed() {
  104. dumpRecords(t, recs)
  105. }
  106. }
  107. // Without failures, every closed window gets exactly one post-close export, on the first tick after it
  108. // closes: the same cadence as before pending retries existed.
  109. func TestComputeExportController_NoFailuresExportsEachClosedWindowOnce(t *testing.T) {
  110. src := &fakeComputeSource[controllerTestSet]{}
  111. exp := &fakeComputeExporter[controllerTestSet]{}
  112. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  113. ticks := ticksEvery(at(8, 2, 0), at(12, 2, 0), 5*time.Minute)
  114. runTicks(c, exp, ticks, nil)
  115. for w := at(8, 0, 0); w.Before(at(12, 0, 0)); w = w.Add(time.Hour) {
  116. var postClose []exportRecord[controllerTestSet]
  117. for _, r := range exp.Records() {
  118. if r.Start.Equal(w) && r.postClose() {
  119. postClose = append(postClose, r)
  120. }
  121. }
  122. if len(postClose) != 1 {
  123. t.Fatalf("window %s got %d post-close exports, want 1", hourWindow(w), len(postClose))
  124. }
  125. if want := w.Add(time.Hour).Add(2 * time.Minute); !postClose[0].Now.Equal(want) {
  126. t.Errorf("window %s exported post-close at %s, want first tick after close %s", hourWindow(w), postClose[0].Now.Format("15:04:05"), want.Format("15:04:05"))
  127. }
  128. }
  129. if c.pendingCount() != 0 || c.droppedWindows != 0 {
  130. t.Errorf("expected nothing pending or dropped, got pending=%d dropped=%d", c.pendingCount(), c.droppedWindows)
  131. }
  132. }
  133. // A tick that arrives after many windows have closed (a long stall or a clock jump) enqueues only the
  134. // most recent maxPendingWindows and counts the rest as dropped, without iterating each one.
  135. func TestComputeExportController_StallBeyondMaxPending(t *testing.T) {
  136. src := &fakeComputeSource[controllerTestSet]{}
  137. exp := &fakeComputeExporter[controllerTestSet]{}
  138. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  139. c.maxPendingWindows = 3
  140. c.maxExportsPerTick = 10
  141. runTicks(c, exp, []time.Time{at(0, 30, 0), at(10, 30, 0)}, nil)
  142. // windows 00:00..09:00 closed between ticks: 07, 08 and 09 are retained and exported, 7 are dropped
  143. if c.droppedWindows != 7 {
  144. t.Errorf("droppedWindows = %d, want 7", c.droppedWindows)
  145. }
  146. for w := at(0, 0, 0); w.Before(at(10, 0, 0)); w = w.Add(time.Hour) {
  147. exported := firstPostCloseSuccess(exp.Records(), w) >= 0
  148. if want := !w.Before(at(7, 0, 0)); exported != want {
  149. t.Errorf("window %s post-close exported = %v, want %v", hourWindow(w), exported, want)
  150. }
  151. }
  152. }
  153. // A clock that steps backwards enqueues nothing and does not panic.
  154. func TestComputeExportController_ClockStepsBackwards(t *testing.T) {
  155. src := &fakeComputeSource[controllerTestSet]{}
  156. exp := &fakeComputeExporter[controllerTestSet]{}
  157. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  158. runTicks(c, exp, []time.Time{at(10, 30, 0), at(9, 30, 0), at(10, 5, 0)}, nil)
  159. if c.pendingCount() != 0 || c.droppedWindows != 0 {
  160. t.Errorf("expected nothing pending or dropped, got pending=%d dropped=%d", c.pendingCount(), c.droppedWindows)
  161. }
  162. }
  163. // Stop() during a backlog stops draining closed windows within the current tick.
  164. func TestComputeExportController_StopDuringBacklog(t *testing.T) {
  165. src := &fakeComputeSource[controllerTestSet]{}
  166. outageEnd := at(14, 0, 0)
  167. var c *ComputeExportController[controllerTestSet]
  168. exp := &fakeComputeExporter[controllerTestSet]{}
  169. exp.failIf = func(w opencost.Window, now time.Time) bool {
  170. if now.Equal(outageEnd) && !w.Start().Equal(outageEnd) {
  171. // first closed-window export of the recovery tick: stop the controller
  172. c.runState.Stop()
  173. }
  174. return now.Before(outageEnd)
  175. }
  176. c = NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  177. c.maxExportsPerTick = 10
  178. if !c.runState.Start() {
  179. t.Fatalf("failed to start run state")
  180. }
  181. runTicks(c, exp, ticksEvery(at(8, 30, 0), outageEnd, 30*time.Minute), nil)
  182. closedAttempts := 0
  183. for _, r := range exp.Records() {
  184. if r.Now.Equal(outageEnd) && !r.Start.Equal(outageEnd) {
  185. closedAttempts++
  186. }
  187. }
  188. if closedAttempts != 1 {
  189. t.Errorf("expected draining to stop after the first closed window once stopped, got %d attempts", closedAttempts)
  190. }
  191. if c.pendingCount() != 5 {
  192. t.Errorf("expected the 5 un-attempted windows to remain pending, got %d", c.pendingCount())
  193. }
  194. }
  195. // Windows that always fail must not block newer closed windows from being exported (no head-of-line
  196. // blocking), and are retried with backoff rather than on every tick.
  197. func TestComputeExportController_PoisonedWindowsDoNotBlock(t *testing.T) {
  198. poisoned := map[time.Time]bool{at(8, 0, 0): true, at(9, 0, 0): true, at(10, 0, 0): true, at(11, 0, 0): true, at(12, 0, 0): true}
  199. src := &fakeComputeSource[controllerTestSet]{}
  200. exp := &fakeComputeExporter[controllerTestSet]{
  201. failIf: func(w opencost.Window, _ time.Time) bool { return poisoned[*w.Start()] },
  202. }
  203. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  204. ticks := ticksEvery(at(8, 2, 0), at(20, 2, 0), 5*time.Minute)
  205. runTicks(c, exp, ticks, nil)
  206. recs := exp.Records()
  207. // every healthy window gets its final export on the first tick after it closes
  208. for w := at(13, 0, 0); w.Before(at(20, 0, 0)); w = w.Add(time.Hour) {
  209. i := firstPostCloseSuccess(recs, w)
  210. if i < 0 {
  211. t.Errorf("healthy window %s was never exported after closing", hourWindow(w))
  212. continue
  213. }
  214. if want := w.Add(time.Hour).Add(2 * time.Minute); !recs[i].Now.Equal(want) {
  215. t.Errorf("healthy window %s finalized at %s, want %s", hourWindow(w), recs[i].Now.Format("15:04:05"), want.Format("15:04:05"))
  216. }
  217. }
  218. // the first poisoned window is retried with backoff: far fewer attempts than ticks since it closed
  219. attempts := 0
  220. for _, r := range recs {
  221. if r.Start.Equal(at(8, 0, 0)) && r.postClose() {
  222. attempts++
  223. }
  224. }
  225. ticksSinceClose := len(ticksEvery(at(9, 2, 0), at(20, 2, 0), 5*time.Minute))
  226. if attempts == 0 || attempts > ticksSinceClose/maxRetryBackoffTicks+5 {
  227. t.Errorf("poisoned window attempted %d times over %d ticks, want backoff", attempts, ticksSinceClose)
  228. }
  229. if t.Failed() {
  230. dumpRecords(t, recs)
  231. }
  232. }
  233. // A backwards clock step followed by a forward one must not enqueue the same window twice.
  234. func TestComputeExportController_ClockStepDoesNotDuplicatePending(t *testing.T) {
  235. src := &fakeComputeSource[controllerTestSet]{}
  236. exp := &fakeComputeExporter[controllerTestSet]{
  237. failIf: func(w opencost.Window, _ time.Time) bool { return w.Start().Equal(at(9, 0, 0)) },
  238. }
  239. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  240. runTicks(c, exp, []time.Time{at(9, 30, 0), at(10, 30, 0), at(9, 45, 0), at(10, 35, 0)}, nil)
  241. if n := c.pendingCount(); n != 1 {
  242. t.Errorf("pendingCount() = %d, want 1", n)
  243. }
  244. }
  245. // A compute that fails with a QueryErrorCollection keeps the window pending until it succeeds.
  246. func TestComputeExportController_ErrorCollectionKeepsWindowPending(t *testing.T) {
  247. failing := true
  248. src := &fakeComputeSource[controllerTestSet]{
  249. computeFn: func(start, _ time.Time, _ int) (*controllerTestSet, error) {
  250. if failing && start.Equal(at(9, 0, 0)) {
  251. errs := &source.QueryErrorCollector{}
  252. errs.AppendError(&source.QueryError{Query: "q", Error: fmt.Errorf("boom")})
  253. return nil, errs
  254. }
  255. return &controllerTestSet{}, nil
  256. },
  257. }
  258. exp := &fakeComputeExporter[controllerTestSet]{}
  259. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  260. runTicks(c, exp, []time.Time{at(9, 30, 0), at(10, 5, 0)}, nil)
  261. if n := c.pendingCount(); n != 1 {
  262. t.Fatalf("pendingCount() = %d after error collection, want 1", n)
  263. }
  264. failing = false
  265. runTicks(c, exp, []time.Time{at(10, 10, 0)}, nil)
  266. if n := c.pendingCount(); n != 0 {
  267. t.Errorf("pendingCount() = %d after recovery, want 0", n)
  268. }
  269. }