controller.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. package exporter
  2. import (
  3. "fmt"
  4. "reflect"
  5. "slices"
  6. "strings"
  7. "time"
  8. "github.com/opencost/opencost/core/pkg/log"
  9. "github.com/opencost/opencost/core/pkg/opencost"
  10. "github.com/opencost/opencost/core/pkg/source"
  11. "github.com/opencost/opencost/core/pkg/util/atomic"
  12. "github.com/opencost/opencost/core/pkg/util/timeutil"
  13. "github.com/opencost/opencost/core/pkg/util/typeutil"
  14. )
  15. // ExportController is a controller interface that is responsible for exporting data on a specific interval.
  16. type ExportController interface {
  17. // Name returns the name of the controller
  18. Name() string
  19. // Start starts a background compute processing loop, which will compute the data for the current resolution and export it
  20. // on the provided interval. This function will return `true` if the loop was started successfully, and `false` if it was
  21. // already running.
  22. Start(interval time.Duration) bool
  23. // Stops the compute processing loop
  24. Stop()
  25. }
  26. // EventExportController[T] is used to export timestamped events of type T on a specific interval.
  27. type EventExportController[T any] struct {
  28. runState atomic.AtomicRunState
  29. source ExportSource[T]
  30. exporter EventExporter[T]
  31. typeName string
  32. }
  33. // NewEventExportController creates a new `EventExportController[T]` instance which is used to export timestamped events of type T
  34. // on a specific interval.
  35. func NewEventExportController[T any](source ExportSource[T], exporter EventExporter[T]) *EventExportController[T] {
  36. return &EventExportController[T]{
  37. source: source,
  38. exporter: exporter,
  39. typeName: reflect.TypeOf((*T)(nil)).Elem().String(),
  40. }
  41. }
  42. // Name returns the name of the controller, which is the name of the T-type
  43. func (cd *EventExportController[T]) Name() string {
  44. return cd.typeName
  45. }
  46. // Start starts a background export loop, which will create a new event instance for the current minute-truncated time
  47. // and export it on the provided interval. This function will return `true` if the loop was started successfully, and
  48. // `false` if it was already running.
  49. func (cd *EventExportController[T]) Start(interval time.Duration) bool {
  50. cd.runState.WaitForReset()
  51. if !cd.runState.Start() {
  52. return false
  53. }
  54. go func() {
  55. for {
  56. select {
  57. case <-cd.runState.OnStop():
  58. cd.runState.Reset()
  59. return // exit go routine
  60. case <-time.After(interval):
  61. }
  62. // truncate the time to the second to ensure broad enough coverage for event exports
  63. t := time.Now().UTC().Truncate(time.Second)
  64. evt := cd.source.Make(t)
  65. if evt == nil {
  66. log.Debugf("[%s] No event data to export", cd.typeName)
  67. continue
  68. }
  69. err := cd.exporter.Export(t, evt)
  70. if err != nil {
  71. log.Warnf("[%s] Error during Write: %s", cd.typeName, err)
  72. }
  73. }
  74. }()
  75. return true
  76. }
  77. // Stops the export loop
  78. func (cd *EventExportController[T]) Stop() {
  79. cd.runState.Stop()
  80. }
  81. const (
  82. // defaultMaxPendingWindows is the number of closed sub-daily windows retained for retry
  83. defaultMaxPendingWindows = 48
  84. // defaultMaxPendingDailyWindows is the number of closed daily (or longer) windows retained for retry
  85. defaultMaxPendingDailyWindows = 7
  86. // defaultMaxExportsPerTick caps how many closed windows are exported in a single tick, so that
  87. // draining a backlog after an outage doesn't compute and write every window at once
  88. defaultMaxExportsPerTick = 4
  89. // maxRetryBackoffTicks caps the number of ticks between retries of a failed closed window
  90. maxRetryBackoffTicks = 12
  91. // persistentFailureAttempts is the number of failed exports after which a window is reported as
  92. // persistently failing (about 3 hours of retries at a 5 minute interval)
  93. persistentFailureAttempts = 6
  94. )
  95. // pendingWindow is a closed window awaiting a successful export
  96. type pendingWindow struct {
  97. start time.Time
  98. // attempts is the number of failed exports of this window since it closed
  99. attempts int
  100. // nextTick is the tick at which this window is next due to be retried
  101. nextTick uint64
  102. }
  103. // ComputeExportController[T] is a controller type which leverages a `ComputeSource[T]` and `Exporter[T]`
  104. // to regularly compute the data for the current resolution and export it on a specific interval.
  105. //
  106. // Each tick exports the current (in-progress) window. When a window closes, it is added to a pending
  107. // list and exported on the next tick. A window whose export fails stays pending and is retried with a
  108. // per-window backoff until an export succeeds. Newly closed windows are exported before retries, and
  109. // retries go fewest-attempts first, then oldest; after a failed retry no further retries are attempted
  110. // in that tick, so a storage outage costs at most one retry per tick. The pending list is bounded; when it overflows,
  111. // the oldest window is dropped and counted.
  112. type ComputeExportController[T any] struct {
  113. runState atomic.AtomicRunState
  114. source ComputeSource[T]
  115. exporter ComputeExporter[T]
  116. resolution time.Duration
  117. typeName string
  118. // tickCount is the number of ticks run
  119. tickCount uint64
  120. // lastTickWindow is the latest start of the current window seen by a tick
  121. lastTickWindow time.Time
  122. // pending holds closed windows awaiting a successful export, ascending by start
  123. pending []*pendingWindow
  124. // maxPendingWindows bounds pending; the oldest windows beyond this are dropped
  125. maxPendingWindows int
  126. // maxExportsPerTick bounds the closed-window exports (first attempts and retries) per tick
  127. maxExportsPerTick int
  128. // droppedWindows counts closed windows dropped from pending without a successful export
  129. droppedWindows uint64
  130. // now returns the current time; overridable for tests
  131. now func() time.Time
  132. }
  133. // NewComputeExportController creates a new `ComputeExportController[T]` instance.
  134. func NewComputeExportController[T any](
  135. source ComputeSource[T],
  136. exporter ComputeExporter[T],
  137. resolution time.Duration,
  138. ) *ComputeExportController[T] {
  139. maxPending := defaultMaxPendingWindows
  140. if resolution >= timeutil.Day {
  141. maxPending = defaultMaxPendingDailyWindows
  142. }
  143. return &ComputeExportController[T]{
  144. source: source,
  145. resolution: resolution,
  146. exporter: exporter,
  147. typeName: reflect.TypeFor[T]().String(),
  148. maxPendingWindows: maxPending,
  149. maxExportsPerTick: defaultMaxExportsPerTick,
  150. now: func() time.Time { return time.Now().UTC() },
  151. }
  152. }
  153. // Name returns the name of the controller, which is a combination of the type name and the resolution
  154. func (cd *ComputeExportController[T]) Name() string {
  155. return cd.typeName + "-" + timeutil.FormatStoreResolution(cd.resolution)
  156. }
  157. // Start starts a background compute processing loop, which will compute the data for the current resolution and export it
  158. // on the provided interval. This function will return `true` if the loop was started successfully, and `false` if it was
  159. // already running.
  160. func (cd *ComputeExportController[T]) Start(interval time.Duration) bool {
  161. // Before we attempt to start, we must ensure we are not in a stopping state
  162. cd.runState.WaitForReset()
  163. // This will atomically check the current state to ensure we can run, then advances the state.
  164. // If the state is already started, it will return false.
  165. if !cd.runState.Start() {
  166. return false
  167. }
  168. // our run state is advanced, let's execute our action on the interval
  169. // spawn a new goroutine which will loop and wait the interval each iteration
  170. go func() {
  171. for {
  172. // use a select statement to receive whichever channel receives data first
  173. select {
  174. // if our stop channel receives data, it means we have explicitly called
  175. // Stop(), and must reset our AtomicRunState to it's initial idle state
  176. case <-cd.runState.OnStop():
  177. if n := cd.pendingCount(); n > 0 {
  178. log.Warnf("[%s] stopping with %d closed window(s) not yet exported", cd.Name(), n)
  179. }
  180. cd.runState.Reset()
  181. return // exit go routine
  182. // After our interval elapses, fall through
  183. case <-time.After(interval):
  184. }
  185. cd.tick(cd.now())
  186. }
  187. }()
  188. return true
  189. }
  190. // tick runs a single export pass for the provided time: newly closed windows first, then due retries
  191. // (together at most maxExportsPerTick), followed by the current window. Retries go to the windows with
  192. // the fewest failed attempts first, then oldest, so windows that keep failing can't hold the retry
  193. // slot ahead of a window that failed once.
  194. func (cd *ComputeExportController[T]) tick(now time.Time) {
  195. cd.tickCount++
  196. start := now.Truncate(cd.resolution)
  197. cd.enqueueClosedWindows(start)
  198. // never move backwards, so a backwards clock step can't enqueue the same window twice
  199. if start.After(cd.lastTickWindow) {
  200. cd.lastTickWindow = start
  201. }
  202. first, retries := cd.dueWindows()
  203. budget := cd.maxExportsPerTick
  204. budget -= cd.exportPending(first, budget, true)
  205. cd.exportPending(retries, budget, false)
  206. cd.pending = slices.DeleteFunc(cd.pending, func(pw *pendingWindow) bool { return pw.start.IsZero() })
  207. if cd.runState.IsStopping() {
  208. return
  209. }
  210. cd.exportAndLog(opencost.NewClosedWindow(start, start.Add(cd.resolution)))
  211. }
  212. // dueWindows returns the pending windows to attempt this tick: never-attempted windows oldest first, and
  213. // due retries ordered by fewest attempts, then oldest (a stable sort keeps pending's ascending order
  214. // within ties).
  215. func (cd *ComputeExportController[T]) dueWindows() (first, retries []*pendingWindow) {
  216. for _, pw := range cd.pending {
  217. if pw.attempts == 0 {
  218. first = append(first, pw)
  219. } else if pw.nextTick <= cd.tickCount {
  220. retries = append(retries, pw)
  221. }
  222. }
  223. slices.SortStableFunc(retries, func(a, b *pendingWindow) int { return a.attempts - b.attempts })
  224. return first, retries
  225. }
  226. // exportPending attempts up to budget of the given windows in order and returns the number attempted.
  227. // Exported windows are marked for removal by zeroing their start; failed windows are scheduled for
  228. // retry. After a failed retry (firstAttempt false) it stops: the next retry would most likely fail too
  229. // (e.g. storage is down), so it doesn't recompute more windows only to discard them.
  230. func (cd *ComputeExportController[T]) exportPending(windows []*pendingWindow, budget int, firstAttempt bool) int {
  231. attempted := 0
  232. for _, pw := range windows {
  233. if attempted >= budget || cd.runState.IsStopping() {
  234. break
  235. }
  236. attempted++
  237. if cd.exportAndLog(opencost.NewClosedWindow(pw.start, pw.start.Add(cd.resolution))) {
  238. pw.start = time.Time{} // exported; removed by tick
  239. continue
  240. }
  241. pw.attempts++
  242. pw.nextTick = cd.tickCount + retryBackoffTicks(pw.attempts)
  243. if pw.attempts == persistentFailureAttempts {
  244. log.Warnf("[%s] closed window [%s, %s) has failed to export %d times; retrying until it is dropped from the pending list",
  245. cd.Name(), pw.start.Format(time.RFC3339), pw.start.Add(cd.resolution).Format(time.RFC3339), pw.attempts)
  246. }
  247. if !firstAttempt {
  248. break
  249. }
  250. }
  251. return attempted
  252. }
  253. // retryBackoffTicks returns the number of ticks to wait before retrying a window that has failed the
  254. // given number of times: 1, 2, 4, 8, then maxRetryBackoffTicks.
  255. func retryBackoffTicks(attempts int) uint64 {
  256. if attempts < 1 {
  257. return 1
  258. }
  259. if attempts > 4 {
  260. return maxRetryBackoffTicks
  261. }
  262. return min(uint64(1)<<(attempts-1), maxRetryBackoffTicks)
  263. }
  264. // enqueueClosedWindows adds every window that has closed since the previous tick to the pending list,
  265. // dropping the oldest pending windows if the list exceeds maxPendingWindows.
  266. func (cd *ComputeExportController[T]) enqueueClosedWindows(currentStart time.Time) {
  267. // on the first tick there is no previous window; on a backwards clock step nothing has closed
  268. if cd.lastTickWindow.IsZero() || !currentStart.After(cd.lastTickWindow) {
  269. return
  270. }
  271. first := cd.lastTickWindow
  272. closed := int(currentStart.Sub(first) / cd.resolution)
  273. // if more windows closed than can be retained (e.g. a long stall), skip straight to the ones we
  274. // can keep rather than enqueueing and evicting each one
  275. if closed > cd.maxPendingWindows {
  276. skipped := closed - cd.maxPendingWindows
  277. cd.drop(first, first.Add(time.Duration(skipped)*cd.resolution), skipped)
  278. first = first.Add(time.Duration(skipped) * cd.resolution)
  279. }
  280. for ws := first; ws.Before(currentStart); ws = ws.Add(cd.resolution) {
  281. cd.pending = append(cd.pending, &pendingWindow{start: ws})
  282. }
  283. if over := len(cd.pending) - cd.maxPendingWindows; over > 0 {
  284. cd.drop(cd.pending[0].start, cd.pending[over-1].start.Add(cd.resolution), over)
  285. cd.pending = append([]*pendingWindow(nil), cd.pending[over:]...)
  286. }
  287. }
  288. // drop records count closed windows between start and end as dropped without a successful export
  289. func (cd *ComputeExportController[T]) drop(start, end time.Time, count int) {
  290. cd.droppedWindows += uint64(count)
  291. log.Errorf("[%s] dropping %d closed window(s) between %s and %s that were never exported: pending limit of %d reached",
  292. cd.Name(), count, start.Format(time.RFC3339), end.Format(time.RFC3339), cd.maxPendingWindows)
  293. }
  294. // pendingCount returns the number of closed windows awaiting a successful export
  295. func (cd *ComputeExportController[T]) pendingCount() int {
  296. return len(cd.pending)
  297. }
  298. // exportAndLog exports the window, logging any error, and returns true on success
  299. func (cd *ComputeExportController[T]) exportAndLog(window opencost.Window) bool {
  300. err := cd.export(window)
  301. if err == nil {
  302. return true
  303. }
  304. // Check ErrorCollection to set Warnings and Errors
  305. if source.IsErrorCollection(err) {
  306. c := err.(source.QueryErrorCollection)
  307. errors, warnings := c.ToErrorAndWarningStrings()
  308. cd.logErrors(window, warnings, errors)
  309. return false
  310. }
  311. log.Errorf("[%s] %s", cd.typeName, err)
  312. return false
  313. }
  314. // export computes and exports the data for a given time window
  315. func (cd *ComputeExportController[T]) export(window opencost.Window) error {
  316. if window.IsOpen() {
  317. return fmt.Errorf("window is open: %s", window.String())
  318. }
  319. start, end := *window.Start(), *window.End()
  320. log.Debugf("[%s] Reporting for window: %s - %s", cd.typeName, start.UTC(), end.UTC())
  321. if !cd.source.CanCompute(start, end) {
  322. return fmt.Errorf("cannot compute window: [Start: %s, End: %s]", start, end)
  323. }
  324. set, err := cd.source.Compute(start, end)
  325. // all errors but NoDataError are considered a halt to the export
  326. if err != nil && !source.IsNoDataError(err) {
  327. return err
  328. }
  329. log.Debugf("[%s] Exporting data for window: %s - %s", cd.typeName, start.UTC(), end.UTC())
  330. err = cd.exporter.Export(window, set)
  331. if err != nil {
  332. return fmt.Errorf("write error: %w", err)
  333. }
  334. return nil
  335. }
  336. // Stops the compute processing loop
  337. func (cd *ComputeExportController[T]) Stop() {
  338. cd.runState.Stop()
  339. }
  340. // temporary
  341. func (cd *ComputeExportController[T]) logErrors(window opencost.Window, warnings []string, errors []string) {
  342. start, end := window.Start(), window.End()
  343. for _, w := range warnings {
  344. log.Warnf("[%s] (%s-%s) %s", cd.typeName, start.Format(time.RFC3339), end.Format(time.RFC3339), w)
  345. }
  346. for _, e := range errors {
  347. log.Errorf("[%s] (%s-%s) %s", cd.typeName, start.Format(time.RFC3339), end.Format(time.RFC3339), e)
  348. }
  349. }
  350. type ComputeExportControllerGroup[T any] struct {
  351. controllers []*ComputeExportController[T]
  352. }
  353. func NewComputeExportControllerGroup[T any](controllers ...*ComputeExportController[T]) *ComputeExportControllerGroup[T] {
  354. return &ComputeExportControllerGroup[T]{controllers: controllers}
  355. }
  356. func (g *ComputeExportControllerGroup[T]) Name() string {
  357. var sb strings.Builder
  358. sb.WriteRune('[')
  359. for i, c := range g.controllers {
  360. if i > 0 {
  361. sb.WriteRune('/')
  362. }
  363. sb.WriteString(c.Name())
  364. }
  365. sb.WriteRune(']')
  366. return sb.String()
  367. }
  368. func (g *ComputeExportControllerGroup[T]) Start(interval time.Duration) bool {
  369. if len(g.controllers) == 0 {
  370. log.Debugf("ComputeExportControllerGroup[%s] has no controllers to start", typeutil.TypeOf[T]())
  371. return false
  372. }
  373. for _, c := range g.controllers {
  374. if !c.Start(interval) {
  375. return false
  376. }
  377. }
  378. return true
  379. }
  380. func (g *ComputeExportControllerGroup[T]) Stop() {
  381. for _, c := range g.controllers {
  382. c.Stop()
  383. }
  384. }
  385. func (g *ComputeExportControllerGroup[T]) Resolutions() []time.Duration {
  386. resolutions := make([]time.Duration, 0, len(g.controllers))
  387. for _, c := range g.controllers {
  388. resolutions = append(resolutions, c.resolution)
  389. }
  390. return resolutions
  391. }