controller_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. package exporter
  2. import (
  3. "bytes"
  4. "errors"
  5. "fmt"
  6. "sync"
  7. "testing"
  8. "time"
  9. "github.com/opencost/opencost/core/pkg/exporter/pathing"
  10. "github.com/opencost/opencost/core/pkg/exporter/validator"
  11. "github.com/opencost/opencost/core/pkg/opencost"
  12. "github.com/opencost/opencost/core/pkg/pipelines"
  13. "github.com/opencost/opencost/core/pkg/storage"
  14. )
  15. // ---------------------------------------------------------------------------
  16. // Fakes
  17. // ---------------------------------------------------------------------------
  18. // controllerTestSet is a trivial payload type for driving the controller.
  19. type controllerTestSet struct {
  20. Start time.Time
  21. End time.Time
  22. Seq int
  23. }
  24. type computeCall struct {
  25. Start time.Time
  26. End time.Time
  27. }
  28. // fakeComputeSource is a scripted ComputeSource[T]. By default Compute returns
  29. // a non-nil *T built by makeFn; computeFn (if set) overrides that per call.
  30. type fakeComputeSource[T any] struct {
  31. mu sync.Mutex
  32. calls []computeCall
  33. canCompute func(start, end time.Time) bool
  34. computeFn func(start, end time.Time, callsForWindow int) (*T, error)
  35. }
  36. func (s *fakeComputeSource[T]) CanCompute(start, end time.Time) bool {
  37. s.mu.Lock()
  38. defer s.mu.Unlock()
  39. if s.canCompute == nil {
  40. return true
  41. }
  42. return s.canCompute(start, end)
  43. }
  44. func (s *fakeComputeSource[T]) Compute(start, end time.Time) (*T, error) {
  45. s.mu.Lock()
  46. n := 0
  47. for _, c := range s.calls {
  48. if c.Start.Equal(start) && c.End.Equal(end) {
  49. n++
  50. }
  51. }
  52. s.calls = append(s.calls, computeCall{Start: start, End: end})
  53. fn := s.computeFn
  54. s.mu.Unlock()
  55. if fn == nil {
  56. return new(T), nil
  57. }
  58. return fn(start, end, n)
  59. }
  60. func (s *fakeComputeSource[T]) Name() string { return "fake-compute-source" }
  61. func (s *fakeComputeSource[T]) Calls() []computeCall {
  62. s.mu.Lock()
  63. defer s.mu.Unlock()
  64. return append([]computeCall(nil), s.calls...)
  65. }
  66. // exportRecord captures one Export attempt.
  67. type exportRecord[T any] struct {
  68. Tick int // index of the tick in which the attempt happened
  69. Now time.Time // tick time the attempt happened at
  70. Start time.Time
  71. End time.Time
  72. Set *T
  73. Success bool
  74. }
  75. func (r exportRecord[T]) postClose() bool { return !r.Now.Before(r.End) }
  76. var errInjectedExport = errors.New("injected export failure")
  77. // fakeComputeExporter records every Export attempt (in order) and can be
  78. // scripted to fail via failIf. The test drives the "current tick" through
  79. // beginTick so the exporter can attribute attempts to tick times.
  80. type fakeComputeExporter[T any] struct {
  81. mu sync.Mutex
  82. tick int
  83. now time.Time
  84. failIf func(window opencost.Window, now time.Time) bool
  85. delegate ComputeExporter[T]
  86. records []exportRecord[T]
  87. }
  88. func (e *fakeComputeExporter[T]) beginTick(i int, now time.Time) {
  89. e.mu.Lock()
  90. defer e.mu.Unlock()
  91. e.tick = i
  92. e.now = now
  93. }
  94. func (e *fakeComputeExporter[T]) Export(window opencost.Window, set *T) error {
  95. e.mu.Lock()
  96. rec := exportRecord[T]{
  97. Tick: e.tick,
  98. Now: e.now,
  99. Start: *window.Start(),
  100. End: *window.End(),
  101. Set: set,
  102. }
  103. fail := e.failIf != nil && e.failIf(window, e.now)
  104. delegate := e.delegate
  105. e.mu.Unlock()
  106. var err error
  107. if fail {
  108. err = errInjectedExport
  109. } else if delegate != nil {
  110. err = delegate.Export(window, set)
  111. }
  112. rec.Success = err == nil
  113. e.mu.Lock()
  114. e.records = append(e.records, rec)
  115. e.mu.Unlock()
  116. return err
  117. }
  118. func (e *fakeComputeExporter[T]) Records() []exportRecord[T] {
  119. e.mu.Lock()
  120. defer e.mu.Unlock()
  121. return append([]exportRecord[T](nil), e.records...)
  122. }
  123. // ---------------------------------------------------------------------------
  124. // Helpers
  125. // ---------------------------------------------------------------------------
  126. var ctlBase = time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC)
  127. func at(hh, mm, ss int) time.Time {
  128. return ctlBase.Add(time.Duration(hh)*time.Hour + time.Duration(mm)*time.Minute + time.Duration(ss)*time.Second)
  129. }
  130. // ticksEvery returns tick times in [from, to] (inclusive) every step.
  131. func ticksEvery(from, to time.Time, step time.Duration) []time.Time {
  132. var out []time.Time
  133. for t := from; !t.After(to); t = t.Add(step) {
  134. out = append(out, t)
  135. }
  136. return out
  137. }
  138. func runTicks[T any](c *ComputeExportController[T], exp *fakeComputeExporter[T], ticks []time.Time, afterTick func(i int, now time.Time)) {
  139. for i, now := range ticks {
  140. exp.beginTick(i, now)
  141. c.now = func() time.Time { return now }
  142. c.tick(now)
  143. if afterTick != nil {
  144. afterTick(i, now)
  145. }
  146. }
  147. }
  148. func hourWindow(start time.Time) string {
  149. return fmt.Sprintf("[%s,%s)", start.Format("15:04"), start.Add(time.Hour).Format("15:04"))
  150. }
  151. func dumpRecords[T any](t *testing.T, recs []exportRecord[T]) {
  152. t.Helper()
  153. for _, r := range recs {
  154. if r.Success && !r.postClose() {
  155. continue // routine in-progress refreshes are noise
  156. }
  157. t.Logf("tick=%02d now=%s window=%s success=%v postClose=%v",
  158. r.Tick, r.Now.Format("15:04:05"), hourWindow(r.Start), r.Success, r.postClose())
  159. }
  160. }
  161. // firstPostCloseSuccess returns the index into recs of the first successful
  162. // export of the window starting at start whose tick time is >= window end.
  163. func firstPostCloseSuccess[T any](recs []exportRecord[T], start time.Time) int {
  164. for i, r := range recs {
  165. if r.Start.Equal(start) && r.Success && r.postClose() {
  166. return i
  167. }
  168. }
  169. return -1
  170. }
  171. // ---------------------------------------------------------------------------
  172. // F-29: a failed closed-window export must be retried
  173. // ---------------------------------------------------------------------------
  174. func TestComputeExportController_RetriesFailedClosedWindow(t *testing.T) {
  175. src := &fakeComputeSource[controllerTestSet]{}
  176. nineAM := at(9, 0, 0)
  177. failAt := at(10, 0, 30)
  178. exp := &fakeComputeExporter[controllerTestSet]{
  179. // bucket 503 for the closed [09:00,10:00) window on the first
  180. // post-rollover tick only; the current window succeeds.
  181. failIf: func(w opencost.Window, now time.Time) bool {
  182. return w.Start().Equal(nineAM) && now.Equal(failAt)
  183. },
  184. }
  185. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  186. ticks := []time.Time{at(9, 59, 0), failAt}
  187. ticks = append(ticks, ticksEvery(at(10, 5, 0), at(12, 0, 0), 5*time.Minute)...)
  188. runTicks(c, exp, ticks, nil)
  189. recs := exp.Records()
  190. if firstPostCloseSuccess(recs, nineAM) < 0 {
  191. dumpRecords(t, recs)
  192. t.Fatalf("F-29: closed window %s was never successfully exported after it closed (at or after 10:00); "+
  193. "the failed export at 10:00:30 was never retried", hourWindow(nineAM))
  194. }
  195. }
  196. // ---------------------------------------------------------------------------
  197. // Outage: every closed window drains, in order, exactly once
  198. // ---------------------------------------------------------------------------
  199. func TestComputeExportController_OutageDrainsInOrder(t *testing.T) {
  200. src := &fakeComputeSource[controllerTestSet]{}
  201. outageStart, outageEnd := at(9, 0, 0), at(14, 0, 0)
  202. exp := &fakeComputeExporter[controllerTestSet]{
  203. failIf: func(_ opencost.Window, now time.Time) bool {
  204. return !now.Before(outageStart) && now.Before(outageEnd)
  205. },
  206. }
  207. c := NewComputeExportController[controllerTestSet](src, exp, time.Hour)
  208. ticks := ticksEvery(at(8, 0, 0), at(16, 0, 0), 5*time.Minute)
  209. runTicks(c, exp, ticks, nil)
  210. recs := exp.Records()
  211. failed := false
  212. fail := func(format string, args ...any) {
  213. t.Helper()
  214. failed = true
  215. t.Errorf(format, args...)
  216. }
  217. // 1. every hourly window 08:00..14:00 got a final (post-close) export.
  218. var windows []time.Time
  219. for h := 8; h <= 14; h++ {
  220. windows = append(windows, at(h, 0, 0))
  221. }
  222. for _, w := range windows {
  223. if firstPostCloseSuccess(recs, w) < 0 {
  224. fail("window %s never got a successful export at or after its end (final export missing)", hourWindow(w))
  225. }
  226. }
  227. // 2. final exports occur in ascending window order, and 3. a closed window
  228. // is never exported again after its first post-close success.
  229. var order []time.Time
  230. done := map[time.Time]bool{}
  231. for _, r := range recs {
  232. if !r.postClose() {
  233. continue
  234. }
  235. if done[r.Start] {
  236. fail("window %s re-exported at %s after its final export already succeeded",
  237. hourWindow(r.Start), r.Now.Format("15:04:05"))
  238. continue
  239. }
  240. if r.Success {
  241. done[r.Start] = true
  242. order = append(order, r.Start)
  243. }
  244. }
  245. for i := 1; i < len(order); i++ {
  246. if !order[i].After(order[i-1]) {
  247. fail("final exports out of order: %s finalized after %s", hourWindow(order[i]), hourWindow(order[i-1]))
  248. }
  249. }
  250. // 4. the current window is attempted on every tick, including during the outage.
  251. for i, now := range ticks {
  252. cur := now.Truncate(time.Hour)
  253. found := false
  254. for _, r := range recs {
  255. if r.Tick == i && r.Start.Equal(cur) {
  256. found = true
  257. break
  258. }
  259. }
  260. if !found {
  261. fail("tick %s did not attempt the current window %s", now.Format("15:04:05"), hourWindow(cur))
  262. }
  263. }
  264. if failed {
  265. dumpRecords(t, recs)
  266. }
  267. }
  268. // ---------------------------------------------------------------------------
  269. // Skeptic: a retried closed window that now computes empty must not overwrite
  270. // ---------------------------------------------------------------------------
  271. func TestComputeExportController_EmptyRetryDoesNotOverwrite(t *testing.T) {
  272. res := time.Hour
  273. store := storage.NewMemoryStorage()
  274. paths, err := pathing.NewDefaultStoragePathFormatter(TestAppName, TestClusterID, TestClusterName, pipelines.AllocationPipelineName, &res)
  275. if err != nil {
  276. t.Fatalf("failed to create path formatter: %v", err)
  277. }
  278. storeExp := NewComputeStorageExporter(
  279. paths,
  280. NewBingenEncoder[opencost.AllocationSet](),
  281. store,
  282. validator.NewSetValidator[opencost.AllocationSet](res),
  283. false,
  284. )
  285. nineAM, tenAM := at(9, 0, 0), at(10, 0, 0)
  286. failAt := at(10, 0, 30)
  287. // The first computation of [09:00,10:00) has data; every later computation
  288. // (e.g. data source restarted / retention expired) returns an empty set.
  289. src := &fakeComputeSource[opencost.AllocationSet]{
  290. computeFn: func(start, end time.Time, n int) (*opencost.AllocationSet, error) {
  291. if start.Equal(nineAM) && n == 0 {
  292. return opencost.GenerateMockAllocationSet(start), nil
  293. }
  294. return opencost.NewAllocationSet(start, end), nil
  295. },
  296. }
  297. exp := &fakeComputeExporter[opencost.AllocationSet]{
  298. delegate: storeExp,
  299. failIf: func(w opencost.Window, now time.Time) bool {
  300. return w.Start().Equal(nineAM) && now.Equal(failAt)
  301. },
  302. }
  303. c := NewComputeExportController[opencost.AllocationSet](src, exp, res)
  304. window := opencost.NewClosedWindow(nineAM, tenAM)
  305. path := paths.ToFullPath("", window, NewBingenEncoder[opencost.AllocationSet]().FileExt())
  306. // first write, with data
  307. runTicks(c, exp, []time.Time{at(9, 30, 0)}, nil)
  308. original, err := store.Read(path)
  309. if err != nil || len(original) == 0 {
  310. t.Fatalf("expected populated object at %s after first export: err=%v len=%d", path, err, len(original))
  311. }
  312. // fail the post-close export once, then keep ticking; any retry of
  313. // [09:00,10:00) will carry an empty set.
  314. ticks := []time.Time{failAt}
  315. ticks = append(ticks, ticksEvery(at(10, 5, 0), at(12, 0, 0), 5*time.Minute)...)
  316. runTicks(c, exp, ticks, nil)
  317. // and a direct re-export of the closed window with an empty set.
  318. if err := exp.Export(window, opencost.NewAllocationSet(nineAM, tenAM)); err != nil {
  319. t.Fatalf("direct empty re-export returned error: %v", err)
  320. }
  321. after, err := store.Read(path)
  322. if err != nil {
  323. t.Fatalf("failed to read %s: %v", path, err)
  324. }
  325. if !bytes.Equal(original, after) {
  326. t.Fatalf("stored object for %s was overwritten by an empty set (before %d bytes, after %d bytes)",
  327. hourWindow(nineAM), len(original), len(after))
  328. }
  329. }