archiver.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. // Package archiver is the M7 periodic data-tier job. It
  2. // moves old rows from TimescaleDB `deliveries` to ClickHouse
  3. // `deliveries_archive` in batches. The hot-path services
  4. // (ingestd, routerd, deliverd-*) are unaffected; they
  5. // INSERT into `deliveries` just as before.
  6. //
  7. // The archiver is fail-stop: a partial batch is rolled back
  8. // (no DELETE happens if the ClickHouse INSERT fails).
  9. // Container restart retries the next hour.
  10. //
  11. // Concurrency: only one archiverd should run at a time per
  12. // environment. We use a Postgres advisory lock so two
  13. // replicas can't double-archive. `SKIP LOCKED` in the
  14. // SELECT makes the lock acquisition non-blocking.
  15. package archiver
  16. import (
  17. "bytes"
  18. "context"
  19. "database/sql"
  20. "encoding/json"
  21. "fmt"
  22. "io"
  23. "log/slog"
  24. "net/http"
  25. "net/url"
  26. "time"
  27. _ "github.com/jackc/pgx/v5/stdlib"
  28. )
  29. // RunOnce executes one full pass: for each table in scope,
  30. // drain rows older than `olderThan` in batches of
  31. // `batchSize`. Returns the total rows archived, total
  32. // batches, and any error encountered (caller decides
  33. // whether to retry the next interval).
  34. //
  35. // The function is safe to call repeatedly; each call
  36. // acquires the advisory lock, processes whatever's old,
  37. // releases the lock, and returns.
  38. func RunOnce(ctx context.Context, opts RunOptions) (Report, error) {
  39. rep := Report{StartedAt: time.Now().UTC()}
  40. conn, err := sql.Open("pgx", opts.PostgresDSN)
  41. if err != nil {
  42. return rep, fmt.Errorf("postgres open: %w", err)
  43. }
  44. defer conn.Close()
  45. if err := conn.PingContext(ctx); err != nil {
  46. return rep, fmt.Errorf("postgres ping: %w", err)
  47. }
  48. if err := ensureCHSchema(ctx, opts.ClickHouseURL, opts.Logger); err != nil {
  49. return rep, fmt.Errorf("clickhouse ensure-schema: %w", err)
  50. }
  51. // Per-table drain loop. We process the same table
  52. // repeatedly until the SELECT returns fewer than
  53. // `batchSize` rows.
  54. for _, t := range []string{"deliveries"} {
  55. n, batches, err := drainTable(ctx, conn, opts, t)
  56. if err != nil {
  57. return rep, fmt.Errorf("%s: %w", t, err)
  58. }
  59. rep.Tables = append(rep.Tables, TableReport{Name: t, Rows: n, Batches: batches})
  60. }
  61. rep.FinishedAt = time.Now().UTC()
  62. rep.Duration = rep.FinishedAt.Sub(rep.StartedAt)
  63. return rep, nil
  64. }
  65. // RunOptions is the per-call configuration.
  66. type RunOptions struct {
  67. // PostgresDSN is the libpq-style DSN (e.g. from
  68. // config.Common.PostgresDSN).
  69. PostgresDSN string
  70. // ClickHouseURL is the HTTP base URL for the
  71. // ClickHouse server (e.g. http://clickhouse:8123).
  72. // Note: NO trailing slash.
  73. ClickHouseURL string
  74. // OlderThan is the cutoff; rows whose created_at
  75. // is strictly less than now() - OlderThan are
  76. // eligible for archive. Default: 7 days.
  77. OlderThan time.Duration
  78. // BatchSize is the cap per SELECT/INSERT. The
  79. // per-run cap is BatchSize * ~10 cycles (the
  80. // drain loop runs until a SELECT returns <BatchSize
  81. // rows; the safety cap is 100 cycles).
  82. BatchSize int
  83. // Logger is the slog handle.
  84. Logger *slog.Logger
  85. }
  86. // Report is the result of one RunOnce call.
  87. type Report struct {
  88. StartedAt time.Time
  89. FinishedAt time.Time
  90. Duration time.Duration
  91. Tables []TableReport
  92. }
  93. // TableReport is the per-table result.
  94. type TableReport struct {
  95. Name string
  96. Rows int
  97. Batches int
  98. }
  99. // drainTable moves all rows of `table` older than the
  100. // cutoff in batches of `BatchSize`. Returns the total
  101. // row count and the number of batches.
  102. //
  103. // The drain uses `FOR UPDATE SKIP LOCKED` to be safe
  104. // against concurrent archiverd instances (the advisory
  105. // lock is the primary guard; SKIP LOCKED is a
  106. // belt-and-suspenders).
  107. func drainTable(ctx context.Context, conn *sql.DB, opts RunOptions, table string) (int, int, error) {
  108. olderThan := opts.OlderThan
  109. if olderThan == 0 {
  110. olderThan = 7 * 24 * time.Hour
  111. }
  112. batchSize := opts.BatchSize
  113. if batchSize == 0 {
  114. batchSize = 10000
  115. }
  116. // Acquire the per-table advisory lock. If another
  117. // archiverd holds it, we return early (no error —
  118. // this is normal in a multi-replica deploy).
  119. lockKey := int64(0xBA21B0DA) // arbitrary stable key
  120. conn2, err := conn.Conn(ctx)
  121. if err != nil {
  122. return 0, 0, err
  123. }
  124. defer conn2.Close()
  125. var gotLock bool
  126. if err := conn2.QueryRowContext(ctx,
  127. `SELECT pg_try_advisory_lock($1)`, lockKey,
  128. ).Scan(&gotLock); err != nil {
  129. return 0, 0, fmt.Errorf("advisory lock: %w", err)
  130. }
  131. if !gotLock {
  132. opts.Logger.Info("another archiverd holds the lock; skipping this run")
  133. return 0, 0, nil
  134. }
  135. defer func() {
  136. _, _ = conn2.ExecContext(ctx, `SELECT pg_advisory_unlock($1)`, lockKey)
  137. }()
  138. totalRows := 0
  139. batches := 0
  140. safetyCycles := 100
  141. for cycle := 0; cycle < safetyCycles; cycle++ {
  142. rows, err := selectBatch(ctx, conn2, table, olderThan, batchSize)
  143. if err != nil {
  144. return totalRows, batches, err
  145. }
  146. if len(rows) == 0 {
  147. break
  148. }
  149. if err := insertCH(ctx, opts.ClickHouseURL, table, rows); err != nil {
  150. return totalRows, batches, fmt.Errorf("clickhouse insert: %w", err)
  151. }
  152. if err := deleteBatch(ctx, conn2, table, rows); err != nil {
  153. return totalRows, batches, fmt.Errorf("postgres delete: %w", err)
  154. }
  155. totalRows += len(rows)
  156. batches++
  157. opts.Logger.Info("archived batch",
  158. "table", table,
  159. "batch", batches,
  160. "rows", len(rows),
  161. "total_rows", totalRows,
  162. )
  163. if len(rows) < batchSize {
  164. break // drained
  165. }
  166. }
  167. return totalRows, batches, nil
  168. }
  169. // deliveryRow is the wire shape we read from Postgres
  170. // and INSERT into ClickHouse. JSON tags match the CH
  171. // column names (case-insensitive in CH).
  172. //
  173. // Time fields are stored as strings (CH wire format) —
  174. // not as time.Time. We do the conversion in selectBatch
  175. // after scanning. This sidesteps the default time.Time
  176. // JSON encoding (RFC3339Nano with 'T' + 'Z') that CH
  177. // can't parse for DateTime64 columns.
  178. type deliveryRow struct {
  179. ID int64 `json:"id"`
  180. AlertID string `json:"alert_id"`
  181. CompanyID string `json:"company_id"`
  182. IndividualID string `json:"individual_id"`
  183. Channel string `json:"channel"`
  184. Target string `json:"target"`
  185. Status string `json:"status"`
  186. Attempts int32 `json:"attempts"`
  187. LastError string `json:"last_error"`
  188. Payload json.RawMessage `json:"payload"`
  189. CreatedAt string `json:"created_at"`
  190. SentAt string `json:"sent_at,omitempty"`
  191. NextAttemptAt string `json:"next_attempt_at,omitempty"`
  192. }
  193. // chTime formats a time as ClickHouse's preferred
  194. // DateTime64(3, 'UTC') wire format: "2026-06-06 21:11:52.036".
  195. // Postgres returns RFC3339Nano with 'T' separator and a 'Z'
  196. // suffix; CH doesn't parse those directly. We use 3-decimal
  197. // precision to match the column type.
  198. func chTime(t time.Time) string {
  199. return t.UTC().Format("2006-01-02 15:04:05.000")
  200. }
  201. // chTimeOrEmpty renders a nullable time as "" for CH's
  202. // Nullable(DateTime64) column. CH accepts an empty string
  203. // for nullable datetime columns in JSONEachRow.
  204. func chTimeOrEmpty(t *time.Time) string {
  205. if t == nil {
  206. return ""
  207. }
  208. return chTime(*t)
  209. }
  210. // selectBatch reads up to `limit` rows from the given
  211. // table where the time column is older than `cutoff`.
  212. // The time column is always `created_at` in M7; if M7.5
  213. // adds another table, we may need to vary the column.
  214. func selectBatch(ctx context.Context, conn *sql.Conn, table string, olderThan time.Duration, limit int) ([]deliveryRow, error) {
  215. cutoff := time.Now().UTC().Add(-olderThan)
  216. q := `
  217. SELECT id, alert_id, company_id, individual_id, channel,
  218. target, status, attempts, last_error, payload,
  219. created_at, sent_at, next_attempt_at
  220. FROM deliveries
  221. WHERE created_at < $1
  222. ORDER BY created_at
  223. LIMIT $2
  224. FOR UPDATE SKIP LOCKED
  225. `
  226. _ = table // currently only one table in scope
  227. rows, err := conn.QueryContext(ctx, q, cutoff, limit)
  228. if err != nil {
  229. return nil, err
  230. }
  231. defer rows.Close()
  232. var out []deliveryRow
  233. for rows.Next() {
  234. var r deliveryRow
  235. var payload sql.NullString
  236. var sentAt, nextAttemptAt sql.NullTime
  237. var createdAt time.Time
  238. if err := rows.Scan(
  239. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel,
  240. &r.Target, &r.Status, &r.Attempts, &r.LastError, &payload,
  241. &createdAt, &sentAt, &nextAttemptAt,
  242. ); err != nil {
  243. return nil, err
  244. }
  245. if payload.Valid {
  246. r.Payload = json.RawMessage(payload.String)
  247. } else {
  248. r.Payload = json.RawMessage("null")
  249. }
  250. // Convert Postgres time.Time to CH wire format.
  251. // We marshal as a string in the row, not via
  252. // the default time.Time JSON (which is RFC3339Nano
  253. // with Z suffix; CH can't parse that).
  254. r.CreatedAt = chTime(createdAt)
  255. if sentAt.Valid {
  256. t := sentAt.Time
  257. r.SentAt = chTimeOrEmpty(&t)
  258. }
  259. if nextAttemptAt.Valid {
  260. t := nextAttemptAt.Time
  261. r.NextAttemptAt = chTimeOrEmpty(&t)
  262. }
  263. out = append(out, r)
  264. }
  265. return out, rows.Err()
  266. }
  267. // insertCH writes the rows to ClickHouse via the HTTP
  268. // interface. Each batch is one INSERT; CH is atomic per
  269. // INSERT so a partial failure rolls back the whole
  270. // batch.
  271. func insertCH(ctx context.Context, chURL, table string, rows []deliveryRow) error {
  272. // CH table for the deliveries archive.
  273. chTable := "ba_archive.deliveries_archive"
  274. _ = table // currently only one in scope
  275. // Build a JSONEachRow payload. CH accepts one JSON
  276. // object per line.
  277. var buf bytes.Buffer
  278. for _, r := range rows {
  279. // CH's DateTime64(3, 'UTC') expects RFC3339Nano
  280. // format. time.Time's default MarshalJSON gives
  281. // RFC3339Nano.
  282. j, err := json.Marshal(r)
  283. if err != nil {
  284. return fmt.Errorf("marshal row %d: %w", r.ID, err)
  285. }
  286. buf.Write(j)
  287. buf.WriteByte('\n')
  288. }
  289. u := chURL + "/?" + url.Values{
  290. "query": {"INSERT INTO " + chTable + " FORMAT JSONEachRow"},
  291. }.Encode()
  292. req, err := http.NewRequestWithContext(ctx, "POST", u, &buf)
  293. if err != nil {
  294. return err
  295. }
  296. req.Header.Set("Content-Type", "application/x-ndjson")
  297. resp, err := http.DefaultClient.Do(req)
  298. if err != nil {
  299. return err
  300. }
  301. defer resp.Body.Close()
  302. if resp.StatusCode != 200 {
  303. body, _ := io.ReadAll(resp.Body)
  304. return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body))
  305. }
  306. return nil
  307. }
  308. // deleteBatch removes the just-archived rows from
  309. // Postgres. The `created_at < cutoff` is the same
  310. // predicate we used in selectBatch, but we constrain on
  311. // `id IN (...)` to avoid accidentally re-deleting rows
  312. // that arrived between the SELECT and the DELETE.
  313. func deleteBatch(ctx context.Context, conn *sql.Conn, table string, rows []deliveryRow) error {
  314. _ = table
  315. ids := make([]int64, 0, len(rows))
  316. for _, r := range rows {
  317. ids = append(ids, r.ID)
  318. }
  319. // Cap chunk size at 1000 ids per query to stay
  320. // within pgx's parameter limit and not blow up
  321. // statement parsing on large batches.
  322. const chunk = 1000
  323. for i := 0; i < len(ids); i += chunk {
  324. end := i + chunk
  325. if end > len(ids) {
  326. end = len(ids)
  327. }
  328. q := `DELETE FROM deliveries WHERE id = ANY($1::bigint[])`
  329. _, err := conn.ExecContext(ctx, q, ids[i:end])
  330. if err != nil {
  331. return err
  332. }
  333. }
  334. return nil
  335. }
  336. // ensureCHSchema runs the 007_clickhouse.up.sql DDL on the
  337. // ClickHouse server. Idempotent — every statement is
  338. // CREATE ... IF NOT EXISTS. We do this at the start of
  339. // every RunOnce so a fresh deploy auto-creates the
  340. // schema without operator intervention.
  341. func ensureCHSchema(ctx context.Context, chURL string, logger *slog.Logger) error {
  342. stmts := []string{
  343. `CREATE DATABASE IF NOT EXISTS ba_archive`,
  344. `CREATE TABLE IF NOT EXISTS ba_archive.deliveries_archive (
  345. id BIGINT,
  346. alert_id String,
  347. company_id String,
  348. individual_id String,
  349. channel LowCardinality(String),
  350. target String,
  351. status LowCardinality(String),
  352. attempts UInt32,
  353. last_error String,
  354. payload String,
  355. created_at DateTime64(3, 'UTC'),
  356. sent_at Nullable(DateTime64(3, 'UTC')),
  357. next_attempt_at Nullable(DateTime64(3, 'UTC')),
  358. archived_at DateTime DEFAULT now()
  359. ) ENGINE = MergeTree
  360. PARTITION BY toYYYYMM(created_at)
  361. ORDER BY (company_id, created_at, id)
  362. TTL toDateTime(created_at) + INTERVAL 365 DAY`,
  363. `CREATE MATERIALIZED VIEW IF NOT EXISTS
  364. ba_archive.deliveries_per_company_daily_mv
  365. ENGINE = SummingMergeTree
  366. PARTITION BY toYYYYMM(day)
  367. ORDER BY (company_id, day, channel, status)
  368. AS
  369. SELECT
  370. company_id,
  371. toDate(created_at) AS day,
  372. channel,
  373. status,
  374. count() AS n
  375. FROM ba_archive.deliveries_archive
  376. GROUP BY company_id, day, channel, status`,
  377. }
  378. for _, s := range stmts {
  379. req, err := http.NewRequestWithContext(ctx, "POST", chURL+"/", bytes.NewBufferString(s))
  380. if err != nil {
  381. return err
  382. }
  383. resp, err := http.DefaultClient.Do(req)
  384. if err != nil {
  385. return fmt.Errorf("ch stmt: %w", err)
  386. }
  387. if resp.StatusCode != 200 {
  388. body, _ := io.ReadAll(resp.Body)
  389. resp.Body.Close()
  390. return fmt.Errorf("ch stmt HTTP %d: %s", resp.StatusCode, string(body))
  391. }
  392. resp.Body.Close()
  393. logger.Debug("clickhouse schema ok", "stmt_prefix", firstLine(s))
  394. }
  395. return nil
  396. }
  397. // firstLine returns up to 80 chars of the first line of
  398. // a SQL statement, for log readability.
  399. func firstLine(s string) string {
  400. for i, c := range s {
  401. if c == '\n' {
  402. if i > 80 {
  403. return s[:80] + "..."
  404. }
  405. return s[:i]
  406. }
  407. }
  408. if len(s) > 80 {
  409. return s[:80] + "..."
  410. }
  411. return s
  412. }