controller.go 15 KB

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