main.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  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. // M13a W5: trigger channel for the admin route. Buffered
  77. // to size 1 — concurrent trigger requests coalesce.
  78. triggerCh := make(chan struct{}, 1)
  79. // Periodic loop. RunOnce is called in a goroutine so
  80. // the /health endpoint stays responsive between runs.
  81. go runLoop(ctx, logger, archiver.RunOptions{
  82. PostgresDSN: cfg.PostgresDSN,
  83. ClickHouseURL: cfg.ClickHouseURL,
  84. OlderThan: time.Duration(cfg.OlderThanHours) * time.Hour,
  85. BatchSize: cfg.BatchSize,
  86. Logger: logger,
  87. }, rowsArchived, &lastRunUnix, runDuration, triggerCh)
  88. srv := httpserver.New(httpserver.Config{
  89. Addr: cfg.HTTPAddr,
  90. ServiceName: "archiverd",
  91. ShutdownGrace: cfg.ShutdownGrace,
  92. }, logger, metricsHandler(reg, &lastRunUnix, cfg.RunEverySeconds*2))
  93. // M13a W5: admin routes (JWT-gated). When BA_AUTHD_JWT_SECRET
  94. // is unset, wireAdminRoutes is a no-op so the LAN deploy
  95. // path keeps working unchanged.
  96. wireAdminRoutes(srv.Mux(), triggerCh, logger)
  97. errCh := make(chan error, 1)
  98. go func() { errCh <- srv.Start() }()
  99. select {
  100. case <-ctx.Done():
  101. logger.Info("shutdown signal received")
  102. case err := <-errCh:
  103. if err != nil {
  104. logger.Error("http server", "err", err)
  105. os.Exit(1)
  106. }
  107. }
  108. if err := srv.Shutdown(ctx); err != nil {
  109. logger.Warn("graceful shutdown", "err", err)
  110. }
  111. logger.Info("bye")
  112. }
  113. // runLoop drives the periodic execution. The first run
  114. // fires immediately on startup (so a fresh deploy catches
  115. // up on backlog), then every `RunEvery` seconds. The
  116. // M13a W5 admin route can also fire a run via triggerCh.
  117. func runLoop(
  118. ctx context.Context,
  119. logger *slog.Logger,
  120. opts archiver.RunOptions,
  121. rowsArchived *prometheus.CounterVec,
  122. lastRunUnix *atomic.Int64,
  123. runDuration prometheus.Gauge,
  124. triggerCh <-chan struct{},
  125. ) {
  126. tick := time.NewTicker(time.Duration(optsRunEverySeconds()) * time.Second)
  127. defer tick.Stop()
  128. for {
  129. // Fire immediately on first iteration.
  130. oneRun(ctx, logger, opts, rowsArchived, lastRunUnix, runDuration)
  131. select {
  132. case <-ctx.Done():
  133. return
  134. case <-tick.C:
  135. // scheduled tick
  136. case <-triggerCh:
  137. // M13a W5: admin trigger. Falls through to
  138. // the loop body, which calls oneRun again.
  139. logger.Info("archiver run triggered by admin route")
  140. }
  141. }
  142. }
  143. func oneRun(
  144. ctx context.Context,
  145. logger *slog.Logger,
  146. opts archiver.RunOptions,
  147. rowsArchived *prometheus.CounterVec,
  148. lastRunUnix *atomic.Int64,
  149. runDuration prometheus.Gauge,
  150. ) {
  151. runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
  152. defer cancel()
  153. rep, err := archiver.RunOnce(runCtx, opts)
  154. if err != nil {
  155. logger.Error("archiver run failed", "err", err)
  156. return
  157. }
  158. lastRunUnix.Store(rep.FinishedAt.Unix())
  159. for _, t := range rep.Tables {
  160. rowsArchived.WithLabelValues(t.Name).Add(float64(t.Rows))
  161. }
  162. runDuration.Set(rep.Duration.Seconds())
  163. logger.Info("archiver run ok",
  164. "duration_seconds", rep.Duration.Seconds(),
  165. "tables", rep.Tables,
  166. )
  167. }
  168. // metricsHandler is a small wrapper that exposes /health
  169. // (returns 200 if the last run was within 2x the cadence)
  170. // alongside the /metrics endpoint.
  171. func metricsHandler(reg *prometheus.Registry, lastRun *atomic.Int64, healthyAfterSeconds int) *http.ServeMux {
  172. mux := http.NewServeMux()
  173. mux.Handle("GET /metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
  174. mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
  175. last := lastRun.Load()
  176. if last == 0 {
  177. // No run yet — still healthy if we're
  178. // within the first 2x cadence of
  179. // startup.
  180. w.WriteHeader(http.StatusOK)
  181. _ = json.NewEncoder(w).Encode(map[string]any{
  182. "status": "starting",
  183. "note": "no run has completed yet",
  184. })
  185. return
  186. }
  187. age := time.Now().Unix() - last
  188. if age > int64(healthyAfterSeconds) {
  189. w.WriteHeader(http.StatusServiceUnavailable)
  190. _ = json.NewEncoder(w).Encode(map[string]any{
  191. "status": "stale",
  192. "age_secs": age,
  193. })
  194. return
  195. }
  196. w.WriteHeader(http.StatusOK)
  197. _ = json.NewEncoder(w).Encode(map[string]any{
  198. "status": "ok",
  199. "age_secs": age,
  200. })
  201. })
  202. return mux
  203. }
  204. // optsRunEverySeconds is read from the env at startup.
  205. // We keep it in a func rather than the config struct
  206. // because the loop closure captures it.
  207. func optsRunEverySeconds() int {
  208. v := os.Getenv("BA_ARCHIVERD_RUN_EVERY_SECONDS")
  209. if v == "" {
  210. return 3600
  211. }
  212. n, err := strconv.Atoi(v)
  213. if err != nil || n <= 0 {
  214. return 3600
  215. }
  216. return n
  217. }
  218. // keep fmt import
  219. var _ = fmt.Sprintf