main.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. // Command archiverd is the M7 periodic data-tier job. It
  2. // moves deliveries older than 7 days from TimescaleDB to
  3. // ClickHouse, then drops the source rows. The hot-path
  4. // services are unaffected — archiverd only reads and
  5. // deletes, never publishes or queues.
  6. //
  7. // Cadence: BA_ARCHIVERD_RUN_EVERY_SECONDS (default 3600).
  8. // Health: /health returns 200 if the last successful run
  9. // was less than 2x the cadence ago. /metrics exposes
  10. // ba_archiverd_rows_archived_total and
  11. // ba_archiverd_last_run_timestamp_seconds.
  12. package main
  13. import (
  14. "context"
  15. "encoding/json"
  16. "fmt"
  17. "log/slog"
  18. "net/http"
  19. "os"
  20. "os/signal"
  21. "strconv"
  22. "sync/atomic"
  23. "syscall"
  24. "time"
  25. "git3.techno-world.net/lrosales/broad-announce/internal/archiver"
  26. "git3.techno-world.net/lrosales/broad-announce/internal/config"
  27. "git3.techno-world.net/lrosales/broad-announce/internal/httpserver"
  28. "git3.techno-world.net/lrosales/broad-announce/internal/observability"
  29. "github.com/prometheus/client_golang/prometheus"
  30. "github.com/prometheus/client_golang/prometheus/promhttp"
  31. )
  32. func main() {
  33. cfg, err := config.LoadArchiverd()
  34. if err != nil {
  35. os.Stderr.WriteString("config: " + err.Error() + "\n")
  36. os.Exit(1)
  37. }
  38. logger := observability.Init(cfg.Env, cfg.LogLevel, "archiverd")
  39. logger.Info("starting",
  40. "env", cfg.Env,
  41. "addr", cfg.HTTPAddr,
  42. "run_every_seconds", cfg.RunEverySeconds,
  43. "older_than_hours", cfg.OlderThanHours,
  44. "batch_size", cfg.BatchSize,
  45. )
  46. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  47. defer stop()
  48. // Metrics. We use a tiny custom registry here rather
  49. // than the shared IngestdMetrics struct because the
  50. // archiver has different counters (rows_archived by
  51. // table) and doesn't need the full ingestd suite.
  52. reg := prometheus.NewRegistry()
  53. reg.MustRegister(prometheus.NewGoCollector(), prometheus.NewProcessCollector(prometheus.ProcessCollectorOpts{}))
  54. rowsArchived := prometheus.NewCounterVec(prometheus.CounterOpts{
  55. Namespace: "ba",
  56. Subsystem: "archiverd",
  57. Name: "rows_archived_total",
  58. Help: "M7: number of rows moved from Timescale to ClickHouse by the archiver.",
  59. }, []string{"table"})
  60. lastRunTs := prometheus.NewGauge(prometheus.GaugeOpts{
  61. Namespace: "ba",
  62. Subsystem: "archiverd",
  63. Name: "last_run_timestamp_seconds",
  64. Help: "M7: unix timestamp of the last successful archiverd run.",
  65. })
  66. runDuration := prometheus.NewGauge(prometheus.GaugeOpts{
  67. Namespace: "ba",
  68. Subsystem: "archiverd",
  69. Name: "last_run_duration_seconds",
  70. Help: "M7: duration of the last archiverd run, in seconds.",
  71. })
  72. reg.MustRegister(rowsArchived, lastRunTs, runDuration)
  73. // Shared atomic for the /health check.
  74. var lastRunUnix atomic.Int64
  75. lastRunUnix.Store(0)
  76. // Periodic loop. RunOnce is called in a goroutine so
  77. // the /health endpoint stays responsive between runs.
  78. go runLoop(ctx, logger, archiver.RunOptions{
  79. PostgresDSN: cfg.PostgresDSN,
  80. ClickHouseURL: cfg.ClickHouseURL,
  81. OlderThan: time.Duration(cfg.OlderThanHours) * time.Hour,
  82. BatchSize: cfg.BatchSize,
  83. Logger: logger,
  84. }, rowsArchived, &lastRunUnix, runDuration)
  85. srv := httpserver.New(httpserver.Config{
  86. Addr: cfg.HTTPAddr,
  87. ServiceName: "archiverd",
  88. ShutdownGrace: cfg.ShutdownGrace,
  89. }, logger, metricsHandler(reg, &lastRunUnix, cfg.RunEverySeconds*2))
  90. errCh := make(chan error, 1)
  91. go func() { errCh <- srv.Start() }()
  92. select {
  93. case <-ctx.Done():
  94. logger.Info("shutdown signal received")
  95. case err := <-errCh:
  96. if err != nil {
  97. logger.Error("http server", "err", err)
  98. os.Exit(1)
  99. }
  100. }
  101. if err := srv.Shutdown(ctx); err != nil {
  102. logger.Warn("graceful shutdown", "err", err)
  103. }
  104. logger.Info("bye")
  105. }
  106. // runLoop drives the periodic execution. The first run
  107. // fires immediately on startup (so a fresh deploy catches
  108. // up on backlog), then every `RunEvery` seconds.
  109. func runLoop(
  110. ctx context.Context,
  111. logger *slog.Logger,
  112. opts archiver.RunOptions,
  113. rowsArchived *prometheus.CounterVec,
  114. lastRunUnix *atomic.Int64,
  115. runDuration prometheus.Gauge,
  116. ) {
  117. tick := time.NewTicker(time.Duration(optsRunEverySeconds()) * time.Second)
  118. defer tick.Stop()
  119. for {
  120. // Fire immediately on first iteration.
  121. oneRun(ctx, logger, opts, rowsArchived, lastRunUnix, runDuration)
  122. select {
  123. case <-ctx.Done():
  124. return
  125. case <-tick.C:
  126. }
  127. }
  128. }
  129. func oneRun(
  130. ctx context.Context,
  131. logger *slog.Logger,
  132. opts archiver.RunOptions,
  133. rowsArchived *prometheus.CounterVec,
  134. lastRunUnix *atomic.Int64,
  135. runDuration prometheus.Gauge,
  136. ) {
  137. runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
  138. defer cancel()
  139. rep, err := archiver.RunOnce(runCtx, opts)
  140. if err != nil {
  141. logger.Error("archiver run failed", "err", err)
  142. return
  143. }
  144. lastRunUnix.Store(rep.FinishedAt.Unix())
  145. for _, t := range rep.Tables {
  146. rowsArchived.WithLabelValues(t.Name).Add(float64(t.Rows))
  147. }
  148. runDuration.Set(rep.Duration.Seconds())
  149. logger.Info("archiver run ok",
  150. "duration_seconds", rep.Duration.Seconds(),
  151. "tables", rep.Tables,
  152. )
  153. }
  154. // metricsHandler is a small wrapper that exposes /health
  155. // (returns 200 if the last run was within 2x the cadence)
  156. // alongside the /metrics endpoint.
  157. func metricsHandler(reg *prometheus.Registry, lastRun *atomic.Int64, healthyAfterSeconds int) *http.ServeMux {
  158. mux := http.NewServeMux()
  159. mux.Handle("GET /metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
  160. mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
  161. last := lastRun.Load()
  162. if last == 0 {
  163. // No run yet — still healthy if we're
  164. // within the first 2x cadence of
  165. // startup.
  166. w.WriteHeader(http.StatusOK)
  167. _ = json.NewEncoder(w).Encode(map[string]any{
  168. "status": "starting",
  169. "note": "no run has completed yet",
  170. })
  171. return
  172. }
  173. age := time.Now().Unix() - last
  174. if age > int64(healthyAfterSeconds) {
  175. w.WriteHeader(http.StatusServiceUnavailable)
  176. _ = json.NewEncoder(w).Encode(map[string]any{
  177. "status": "stale",
  178. "age_secs": age,
  179. })
  180. return
  181. }
  182. w.WriteHeader(http.StatusOK)
  183. _ = json.NewEncoder(w).Encode(map[string]any{
  184. "status": "ok",
  185. "age_secs": age,
  186. })
  187. })
  188. return mux
  189. }
  190. // optsRunEverySeconds is read from the env at startup.
  191. // We keep it in a func rather than the config struct
  192. // because the loop closure captures it.
  193. func optsRunEverySeconds() int {
  194. v := os.Getenv("BA_ARCHIVERD_RUN_EVERY_SECONDS")
  195. if v == "" {
  196. return 3600
  197. }
  198. n, err := strconv.Atoi(v)
  199. if err != nil || n <= 0 {
  200. return 3600
  201. }
  202. return n
  203. }
  204. // keep fmt import
  205. var _ = fmt.Sprintf