controller.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  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. // oldest first (together at most maxExportsPerTick), followed by the current window.
  189. func (cd *ComputeExportController[T]) tick(now time.Time) {
  190. cd.tickCount++
  191. start := now.Truncate(cd.resolution)
  192. cd.enqueueClosedWindows(start)
  193. // never move backwards, so a backwards clock step can't enqueue the same window twice
  194. if start.After(cd.lastTickWindow) {
  195. cd.lastTickWindow = start
  196. }
  197. attempts := 0
  198. for _, firstAttempt := range []bool{true, false} {
  199. for _, pw := range cd.pending {
  200. if attempts >= cd.maxExportsPerTick || cd.runState.IsStopping() {
  201. break
  202. }
  203. if (pw.attempts == 0) != firstAttempt || pw.nextTick > cd.tickCount {
  204. continue
  205. }
  206. attempts++
  207. if cd.exportAndLog(opencost.NewClosedWindow(pw.start, pw.start.Add(cd.resolution))) {
  208. pw.start = time.Time{} // exported; removed below
  209. continue
  210. }
  211. pw.attempts++
  212. pw.nextTick = cd.tickCount + retryBackoffTicks(pw.attempts)
  213. // a failed retry most likely means the next one will fail too (e.g. storage is down); stop
  214. // retrying until the next tick rather than recomputing more windows only to discard them
  215. if !firstAttempt {
  216. break
  217. }
  218. }
  219. }
  220. cd.pending = slices.DeleteFunc(cd.pending, func(pw *pendingWindow) bool { return pw.start.IsZero() })
  221. if cd.runState.IsStopping() {
  222. return
  223. }
  224. cd.exportAndLog(opencost.NewClosedWindow(start, start.Add(cd.resolution)))
  225. }
  226. // retryBackoffTicks returns the number of ticks to wait before retrying a window that has failed the
  227. // given number of times: 1, 2, 4, 8, then maxRetryBackoffTicks.
  228. func retryBackoffTicks(attempts int) uint64 {
  229. if attempts > 4 {
  230. return maxRetryBackoffTicks
  231. }
  232. return min(uint64(1)<<(attempts-1), maxRetryBackoffTicks)
  233. }
  234. // enqueueClosedWindows adds every window that has closed since the previous tick to the pending list,
  235. // dropping the oldest pending windows if the list exceeds maxPendingWindows.
  236. func (cd *ComputeExportController[T]) enqueueClosedWindows(currentStart time.Time) {
  237. // on the first tick there is no previous window; on a backwards clock step nothing has closed
  238. if cd.lastTickWindow.IsZero() || !currentStart.After(cd.lastTickWindow) {
  239. return
  240. }
  241. first := cd.lastTickWindow
  242. closed := int(currentStart.Sub(first) / cd.resolution)
  243. // if more windows closed than can be retained (e.g. a long stall), skip straight to the ones we
  244. // can keep rather than enqueueing and evicting each one
  245. if closed > cd.maxPendingWindows {
  246. skipped := closed - cd.maxPendingWindows
  247. cd.drop(first, first.Add(time.Duration(skipped)*cd.resolution), skipped)
  248. first = first.Add(time.Duration(skipped) * cd.resolution)
  249. }
  250. for ws := first; ws.Before(currentStart); ws = ws.Add(cd.resolution) {
  251. cd.pending = append(cd.pending, &pendingWindow{start: ws})
  252. }
  253. if over := len(cd.pending) - cd.maxPendingWindows; over > 0 {
  254. cd.drop(cd.pending[0].start, cd.pending[over-1].start.Add(cd.resolution), over)
  255. cd.pending = append([]*pendingWindow(nil), cd.pending[over:]...)
  256. }
  257. }
  258. // drop records count closed windows between start and end as dropped without a successful export
  259. func (cd *ComputeExportController[T]) drop(start, end time.Time, count int) {
  260. cd.droppedWindows += uint64(count)
  261. log.Errorf("[%s] dropping %d closed window(s) between %s and %s that were never exported: pending limit of %d reached",
  262. cd.Name(), count, start.Format(time.RFC3339), end.Format(time.RFC3339), cd.maxPendingWindows)
  263. }
  264. // pendingCount returns the number of closed windows awaiting a successful export
  265. func (cd *ComputeExportController[T]) pendingCount() int {
  266. return len(cd.pending)
  267. }
  268. // exportAndLog exports the window, logging any error, and returns true on success
  269. func (cd *ComputeExportController[T]) exportAndLog(window opencost.Window) bool {
  270. err := cd.export(window)
  271. if err == nil {
  272. return true
  273. }
  274. // Check ErrorCollection to set Warnings and Errors
  275. if source.IsErrorCollection(err) {
  276. c := err.(source.QueryErrorCollection)
  277. errors, warnings := c.ToErrorAndWarningStrings()
  278. cd.logErrors(window, warnings, errors)
  279. return false
  280. }
  281. log.Errorf("[%s] %s", cd.typeName, err)
  282. return false
  283. }
  284. // export computes and exports the data for a given time window
  285. func (cd *ComputeExportController[T]) export(window opencost.Window) error {
  286. if window.IsOpen() {
  287. return fmt.Errorf("window is open: %s", window.String())
  288. }
  289. start, end := *window.Start(), *window.End()
  290. log.Debugf("[%s] Reporting for window: %s - %s", cd.typeName, start.UTC(), end.UTC())
  291. if !cd.source.CanCompute(start, end) {
  292. return fmt.Errorf("cannot compute window: [Start: %s, End: %s]", start, end)
  293. }
  294. set, err := cd.source.Compute(start, end)
  295. // all errors but NoDataError are considered a halt to the export
  296. if err != nil && !source.IsNoDataError(err) {
  297. return err
  298. }
  299. log.Debugf("[%s] Exporting data for window: %s - %s", cd.typeName, start.UTC(), end.UTC())
  300. err = cd.exporter.Export(window, set)
  301. if err != nil {
  302. return fmt.Errorf("write error: %w", err)
  303. }
  304. return nil
  305. }
  306. // Stops the compute processing loop
  307. func (cd *ComputeExportController[T]) Stop() {
  308. cd.runState.Stop()
  309. }
  310. // temporary
  311. func (cd *ComputeExportController[T]) logErrors(window opencost.Window, warnings []string, errors []string) {
  312. start, end := window.Start(), window.End()
  313. for _, w := range warnings {
  314. log.Warnf("[%s] (%s-%s) %s", cd.typeName, start.Format(time.RFC3339), end.Format(time.RFC3339), w)
  315. }
  316. for _, e := range errors {
  317. log.Errorf("[%s] (%s-%s) %s", cd.typeName, start.Format(time.RFC3339), end.Format(time.RFC3339), e)
  318. }
  319. }
  320. type ComputeExportControllerGroup[T any] struct {
  321. controllers []*ComputeExportController[T]
  322. }
  323. func NewComputeExportControllerGroup[T any](controllers ...*ComputeExportController[T]) *ComputeExportControllerGroup[T] {
  324. return &ComputeExportControllerGroup[T]{controllers: controllers}
  325. }
  326. func (g *ComputeExportControllerGroup[T]) Name() string {
  327. var sb strings.Builder
  328. sb.WriteRune('[')
  329. for i, c := range g.controllers {
  330. if i > 0 {
  331. sb.WriteRune('/')
  332. }
  333. sb.WriteString(c.Name())
  334. }
  335. sb.WriteRune(']')
  336. return sb.String()
  337. }
  338. func (g *ComputeExportControllerGroup[T]) Start(interval time.Duration) bool {
  339. if len(g.controllers) == 0 {
  340. log.Debugf("ComputeExportControllerGroup[%s] has no controllers to start", typeutil.TypeOf[T]())
  341. return false
  342. }
  343. for _, c := range g.controllers {
  344. if !c.Start(interval) {
  345. return false
  346. }
  347. }
  348. return true
  349. }
  350. func (g *ComputeExportControllerGroup[T]) Stop() {
  351. for _, c := range g.controllers {
  352. c.Stop()
  353. }
  354. }
  355. func (g *ComputeExportControllerGroup[T]) Resolutions() []time.Duration {
  356. resolutions := make([]time.Duration, 0, len(g.controllers))
  357. for _, c := range g.controllers {
  358. resolutions = append(resolutions, c.resolution)
  359. }
  360. return resolutions
  361. }