archiver.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  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. //
  55. // M8: drain both `deliveries` (live) and
  56. // `deliveries_dlq` (forensic). The DLQ has a longer
  57. // CH TTL (2y) so an operator can still find a row
  58. // when triaging a regression that happened weeks ago.
  59. // Both tables share the 7d Postgres hot window — the
  60. // archiver just ships the older rows to CH.
  61. tables := opts.Tables
  62. if len(tables) == 0 {
  63. tables = defaultTables(opts)
  64. }
  65. for _, t := range tables {
  66. n, batches, err := drainTable(ctx, conn, opts, t)
  67. if err != nil {
  68. return rep, fmt.Errorf("%s: %w", t.PGName, err)
  69. }
  70. rep.Tables = append(rep.Tables, TableReport{Name: t.PGName, Rows: n, Batches: batches})
  71. }
  72. rep.FinishedAt = time.Now().UTC()
  73. rep.Duration = rep.FinishedAt.Sub(rep.StartedAt)
  74. return rep, nil
  75. }
  76. // RunOptions is the per-call configuration.
  77. type RunOptions struct {
  78. // PostgresDSN is the libpq-style DSN (e.g. from
  79. // config.Common.PostgresDSN).
  80. PostgresDSN string
  81. // ClickHouseURL is the HTTP base URL for the
  82. // ClickHouse server (e.g. http://clickhouse:8123).
  83. // Note: NO trailing slash.
  84. ClickHouseURL string
  85. // OlderThan is the DEFAULT cutoff; rows whose
  86. // created_at is strictly less than now() - OlderThan
  87. // are eligible for archive. Per-table Tables[i].OlderThan
  88. // overrides this. Default: 7 days.
  89. OlderThan time.Duration
  90. // BatchSize is the cap per SELECT/INSERT. The
  91. // per-run cap is BatchSize * ~10 cycles (the
  92. // drain loop runs until a SELECT returns <BatchSize
  93. // rows; the safety cap is 100 cycles).
  94. BatchSize int
  95. // Logger is the slog handle.
  96. Logger *slog.Logger
  97. // Tables is the per-table drain plan. If empty,
  98. // defaultTables(OlderThan) is used. M8 ships two
  99. // tables: the live `deliveries` and the forensic
  100. // `deliveries_dlq` (PROMPT.md M8 "Loose ends").
  101. Tables []TableSpec
  102. }
  103. // TableSpec is one row of the archiver drain plan.
  104. type TableSpec struct {
  105. // PGName is the Postgres source table (must be
  106. // a Timescale hypertable on created_at).
  107. PGName string
  108. // CHName is the ClickHouse target table in
  109. // ba_archive.*. EnsureCHSchema creates the table
  110. // on first run.
  111. CHName string
  112. // OlderThan overrides RunOptions.OlderThan for
  113. // this specific table. The two stock tables
  114. // (deliveries, deliveries_dlq) both use 7d; the
  115. // CH TTL is what makes the DLQ long-lived.
  116. OlderThan time.Duration
  117. }
  118. // defaultTables returns the M7+M8 stock drain plan.
  119. func defaultTables(opts RunOptions) []TableSpec {
  120. older := opts.OlderThan
  121. if older == 0 {
  122. older = 7 * 24 * time.Hour
  123. }
  124. return []TableSpec{
  125. {PGName: "deliveries", CHName: "ba_archive.deliveries_archive", OlderThan: older},
  126. {PGName: "deliveries_dlq", CHName: "ba_archive.deliveries_dlq_archive", OlderThan: older},
  127. }
  128. }
  129. // Report is the result of one RunOnce call.
  130. type Report struct {
  131. StartedAt time.Time
  132. FinishedAt time.Time
  133. Duration time.Duration
  134. Tables []TableReport
  135. }
  136. // TableReport is the per-table result.
  137. type TableReport struct {
  138. Name string
  139. Rows int
  140. Batches int
  141. }
  142. // drainTable moves all rows of `t.PGName` older than
  143. // `t.OlderThan` in batches of `BatchSize`. Returns the
  144. // total row count and the number of batches.
  145. //
  146. // The drain uses `FOR UPDATE SKIP LOCKED` to be safe
  147. // against concurrent archiverd instances (the advisory
  148. // lock is the primary guard; SKIP LOCKED is a
  149. // belt-and-suspenders).
  150. func drainTable(ctx context.Context, conn *sql.DB, opts RunOptions, t TableSpec) (int, int, error) {
  151. olderThan := t.OlderThan
  152. if olderThan == 0 {
  153. olderThan = opts.OlderThan
  154. }
  155. if olderThan == 0 {
  156. olderThan = 7 * 24 * time.Hour
  157. }
  158. batchSize := opts.BatchSize
  159. if batchSize == 0 {
  160. batchSize = 10000
  161. }
  162. // Acquire the per-table advisory lock. If another
  163. // archiverd holds it, we return early (no error —
  164. // this is normal in a multi-replica deploy).
  165. lockKey := int64(0xBA21B0DA) // arbitrary stable key
  166. conn2, err := conn.Conn(ctx)
  167. if err != nil {
  168. return 0, 0, err
  169. }
  170. defer conn2.Close()
  171. var gotLock bool
  172. if err := conn2.QueryRowContext(ctx,
  173. `SELECT pg_try_advisory_lock($1)`, lockKey,
  174. ).Scan(&gotLock); err != nil {
  175. return 0, 0, fmt.Errorf("advisory lock: %w", err)
  176. }
  177. if !gotLock {
  178. opts.Logger.Info("another archiverd holds the lock; skipping this run")
  179. return 0, 0, nil
  180. }
  181. defer func() {
  182. _, _ = conn2.ExecContext(ctx, `SELECT pg_advisory_unlock($1)`, lockKey)
  183. }()
  184. totalRows := 0
  185. batches := 0
  186. safetyCycles := 100
  187. for cycle := 0; cycle < safetyCycles; cycle++ {
  188. rows, err := selectBatch(ctx, conn2, t, olderThan, batchSize)
  189. if err != nil {
  190. return totalRows, batches, err
  191. }
  192. if len(rows) == 0 {
  193. break
  194. }
  195. if err := insertCH(ctx, opts.ClickHouseURL, t, rows); err != nil {
  196. return totalRows, batches, fmt.Errorf("clickhouse insert: %w", err)
  197. }
  198. if err := deleteBatch(ctx, conn2, t.PGName, rows); err != nil {
  199. return totalRows, batches, fmt.Errorf("postgres delete: %w", err)
  200. }
  201. totalRows += len(rows)
  202. batches++
  203. opts.Logger.Info("archived batch",
  204. "table", t.PGName,
  205. "batch", batches,
  206. "rows", len(rows),
  207. "total_rows", totalRows,
  208. )
  209. if len(rows) < batchSize {
  210. break // drained
  211. }
  212. }
  213. return totalRows, batches, nil
  214. }
  215. // deliveryRow is the wire shape we read from Postgres
  216. // and INSERT into ClickHouse. JSON tags match the CH
  217. // column names (case-insensitive in CH).
  218. //
  219. // Time fields are stored as strings (CH wire format) —
  220. // not as time.Time. We do the conversion in selectBatch
  221. // after scanning. This sidesteps the default time.Time
  222. // JSON encoding (RFC3339Nano with 'T' + 'Z') that CH
  223. // can't parse for DateTime64 columns.
  224. type deliveryRow struct {
  225. ID int64 `json:"id"`
  226. AlertID string `json:"alert_id"`
  227. CompanyID string `json:"company_id"`
  228. IndividualID string `json:"individual_id"`
  229. Channel string `json:"channel"`
  230. Target string `json:"target"`
  231. Status string `json:"status"`
  232. Attempts int32 `json:"attempts"`
  233. LastError string `json:"last_error"`
  234. Payload json.RawMessage `json:"payload"`
  235. CreatedAt string `json:"created_at"`
  236. SentAt string `json:"sent_at,omitempty"`
  237. NextAttemptAt string `json:"next_attempt_at,omitempty"`
  238. }
  239. // chTime formats a time as ClickHouse's preferred
  240. // DateTime64(3, 'UTC') wire format: "2026-06-06 21:11:52.036".
  241. // Postgres returns RFC3339Nano with 'T' separator and a 'Z'
  242. // suffix; CH doesn't parse those directly. We use 3-decimal
  243. // precision to match the column type.
  244. func chTime(t time.Time) string {
  245. return t.UTC().Format("2006-01-02 15:04:05.000")
  246. }
  247. // chTimeOrEmpty renders a nullable time as "" for CH's
  248. // Nullable(DateTime64) column. CH accepts an empty string
  249. // for nullable datetime columns in JSONEachRow.
  250. func chTimeOrEmpty(t *time.Time) string {
  251. if t == nil {
  252. return ""
  253. }
  254. return chTime(*t)
  255. }
  256. // selectBatch reads up to `limit` rows from the given
  257. // table where the time column is older than `cutoff`.
  258. // The time column is always `created_at` (M7/M8 both
  259. // hypertables on created_at).
  260. //
  261. // M8: the SELECT statement is chosen based on the
  262. // table name. The live `deliveries` table and the DLQ
  263. // `deliveries_dlq` table have different columns; the
  264. // DLQ has the extra original_subject, discarded,
  265. // discarded_at, discarded_by fields the live table
  266. // doesn't.
  267. func selectBatch(ctx context.Context, conn *sql.Conn, t TableSpec, olderThan time.Duration, limit int) ([]deliveryRow, error) {
  268. cutoff := time.Now().UTC().Add(-olderThan)
  269. var (
  270. q string
  271. scan func(*sql.Rows) (deliveryRow, error)
  272. )
  273. switch t.PGName {
  274. case "deliveries":
  275. q = `
  276. SELECT id, alert_id, company_id, individual_id, channel,
  277. target, status, attempts, last_error, payload,
  278. created_at, sent_at, next_attempt_at
  279. FROM deliveries
  280. WHERE created_at < $1
  281. ORDER BY created_at
  282. LIMIT $2
  283. FOR UPDATE SKIP LOCKED
  284. `
  285. scan = scanDeliveryRow
  286. case "deliveries_dlq":
  287. q = `
  288. SELECT id, alert_id, company_id, individual_id, channel,
  289. target, status, attempts, last_error, payload,
  290. created_at, NULL::timestamptz AS sent_at,
  291. NULL::timestamptz AS next_attempt_at
  292. FROM deliveries_dlq
  293. WHERE created_at < $1
  294. ORDER BY created_at
  295. LIMIT $2
  296. FOR UPDATE SKIP LOCKED
  297. `
  298. scan = scanDeliveryRow
  299. default:
  300. return nil, fmt.Errorf("selectBatch: unknown table %q", t.PGName)
  301. }
  302. rows, err := conn.QueryContext(ctx, q, cutoff, limit)
  303. if err != nil {
  304. return nil, err
  305. }
  306. defer rows.Close()
  307. var out []deliveryRow
  308. for rows.Next() {
  309. r, err := scan(rows)
  310. if err != nil {
  311. return nil, err
  312. }
  313. out = append(out, r)
  314. }
  315. return out, rows.Err()
  316. }
  317. // scanDeliveryRow scans one row from the `deliveries`
  318. // (or deliveries_dlq, with the NULL sent_at/
  319. // next_attempt_at columns) SELECT. Returns the row in
  320. // the wire format used by the ClickHouse INSERT.
  321. func scanDeliveryRow(rows *sql.Rows) (deliveryRow, error) {
  322. var r deliveryRow
  323. var payload sql.NullString
  324. var sentAt, nextAttemptAt sql.NullTime
  325. var createdAt time.Time
  326. if err := rows.Scan(
  327. &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel,
  328. &r.Target, &r.Status, &r.Attempts, &r.LastError, &payload,
  329. &createdAt, &sentAt, &nextAttemptAt,
  330. ); err != nil {
  331. return r, err
  332. }
  333. if payload.Valid {
  334. r.Payload = json.RawMessage(payload.String)
  335. } else {
  336. r.Payload = json.RawMessage("null")
  337. }
  338. // Convert Postgres time.Time to CH wire format.
  339. // We marshal as a string in the row, not via
  340. // the default time.Time JSON (which is RFC3339Nano
  341. // with Z suffix; CH can't parse that).
  342. r.CreatedAt = chTime(createdAt)
  343. if sentAt.Valid {
  344. t := sentAt.Time
  345. r.SentAt = chTimeOrEmpty(&t)
  346. }
  347. if nextAttemptAt.Valid {
  348. t := nextAttemptAt.Time
  349. r.NextAttemptAt = chTimeOrEmpty(&t)
  350. }
  351. return r, nil
  352. }
  353. // insertCH writes the rows to ClickHouse via the HTTP
  354. // interface. Each batch is one INSERT; CH is atomic per
  355. // INSERT so a partial failure rolls back the whole
  356. // batch.
  357. //
  358. // M8: the CH target table comes from t.CHName. The
  359. // stock `deliveries` table maps to
  360. // `ba_archive.deliveries_archive` (1y TTL); the DLQ
  361. // maps to `ba_archive.deliveries_dlq_archive` (2y TTL).
  362. func insertCH(ctx context.Context, chURL string, t TableSpec, rows []deliveryRow) error {
  363. chTable := t.CHName
  364. // Build a JSONEachRow payload. CH accepts one JSON
  365. // object per line.
  366. var buf bytes.Buffer
  367. for _, r := range rows {
  368. // CH's DateTime64(3, 'UTC') expects RFC3339Nano
  369. // format. time.Time's default MarshalJSON gives
  370. // RFC3339Nano.
  371. j, err := json.Marshal(r)
  372. if err != nil {
  373. return fmt.Errorf("marshal row %d: %w", r.ID, err)
  374. }
  375. buf.Write(j)
  376. buf.WriteByte('\n')
  377. }
  378. u := chURL + "/?" + url.Values{
  379. "query": {"INSERT INTO " + chTable + " FORMAT JSONEachRow"},
  380. }.Encode()
  381. req, err := http.NewRequestWithContext(ctx, "POST", u, &buf)
  382. if err != nil {
  383. return err
  384. }
  385. req.Header.Set("Content-Type", "application/x-ndjson")
  386. resp, err := http.DefaultClient.Do(req)
  387. if err != nil {
  388. return err
  389. }
  390. defer resp.Body.Close()
  391. if resp.StatusCode != 200 {
  392. body, _ := io.ReadAll(resp.Body)
  393. return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body))
  394. }
  395. return nil
  396. }
  397. // deleteBatch removes the just-archived rows from
  398. // Postgres. The `created_at < cutoff` is the same
  399. // predicate we used in selectBatch, but we constrain on
  400. // `id IN (...)` to avoid accidentally re-deleting rows
  401. // that arrived between the SELECT and the DELETE.
  402. func deleteBatch(ctx context.Context, conn *sql.Conn, pgName string, rows []deliveryRow) error {
  403. _ = pgName
  404. ids := make([]int64, 0, len(rows))
  405. for _, r := range rows {
  406. ids = append(ids, r.ID)
  407. }
  408. // Cap chunk size at 1000 ids per query to stay
  409. // within pgx's parameter limit and not blow up
  410. // statement parsing on large batches.
  411. const chunk = 1000
  412. for i := 0; i < len(ids); i += chunk {
  413. end := i + chunk
  414. if end > len(ids) {
  415. end = len(ids)
  416. }
  417. q := `DELETE FROM deliveries WHERE id = ANY($1::bigint[])`
  418. _, err := conn.ExecContext(ctx, q, ids[i:end])
  419. if err != nil {
  420. return err
  421. }
  422. }
  423. return nil
  424. }
  425. // ensureCHSchema runs the 007_clickhouse.up.sql + M8 DLQ
  426. // DDL on the ClickHouse server. Idempotent — every
  427. // statement is CREATE ... IF NOT EXISTS. We do this at
  428. // the start of every RunOnce so a fresh deploy auto-
  429. // creates the schema without operator intervention.
  430. func ensureCHSchema(ctx context.Context, chURL string, logger *slog.Logger) error {
  431. stmts := []string{
  432. `CREATE DATABASE IF NOT EXISTS ba_archive`,
  433. // M7: live deliveries archive. 1y TTL.
  434. `CREATE TABLE IF NOT EXISTS ba_archive.deliveries_archive (
  435. id BIGINT,
  436. alert_id String,
  437. company_id String,
  438. individual_id String,
  439. channel LowCardinality(String),
  440. target String,
  441. status LowCardinality(String),
  442. attempts UInt32,
  443. last_error String,
  444. payload String,
  445. created_at DateTime64(3, 'UTC'),
  446. sent_at Nullable(DateTime64(3, 'UTC')),
  447. next_attempt_at Nullable(DateTime64(3, 'UTC')),
  448. archived_at DateTime DEFAULT now()
  449. ) ENGINE = MergeTree
  450. PARTITION BY toYYYYMM(created_at)
  451. ORDER BY (company_id, created_at, id)
  452. TTL toDateTime(created_at) + INTERVAL 365 DAY`,
  453. `CREATE MATERIALIZED VIEW IF NOT EXISTS
  454. ba_archive.deliveries_per_company_daily_mv
  455. ENGINE = SummingMergeTree
  456. PARTITION BY toYYYYMM(day)
  457. ORDER BY (company_id, day, channel, status)
  458. AS
  459. SELECT
  460. company_id,
  461. toDate(created_at) AS day,
  462. channel,
  463. status,
  464. count() AS n
  465. FROM ba_archive.deliveries_archive
  466. GROUP BY company_id, day, channel, status`,
  467. // M8: DLQ archive. 2y TTL (vs 1y for live)
  468. // because DLQ entries are forensic data you
  469. // want around longer when triaging a
  470. // regression. Same column shape minus the
  471. // sent_at / next_attempt_at (DLQ has no
  472. // retry-scheduling state).
  473. `CREATE TABLE IF NOT EXISTS ba_archive.deliveries_dlq_archive (
  474. id BIGINT,
  475. alert_id String,
  476. company_id String,
  477. individual_id String,
  478. channel LowCardinality(String),
  479. target String,
  480. status LowCardinality(String),
  481. attempts UInt32,
  482. last_error String,
  483. payload String,
  484. created_at DateTime64(3, 'UTC'),
  485. archived_at DateTime DEFAULT now()
  486. ) ENGINE = MergeTree
  487. PARTITION BY toYYYYMM(created_at)
  488. ORDER BY (company_id, created_at, id)
  489. TTL toDateTime(created_at) + INTERVAL 730 DAY`,
  490. `CREATE MATERIALIZED VIEW IF NOT EXISTS
  491. ba_archive.deliveries_dlq_per_company_daily_mv
  492. ENGINE = SummingMergeTree
  493. PARTITION BY toYYYYMM(day)
  494. ORDER BY (company_id, day, channel)
  495. AS
  496. SELECT
  497. company_id,
  498. toDate(created_at) AS day,
  499. channel,
  500. count() AS n
  501. FROM ba_archive.deliveries_dlq_archive
  502. GROUP BY company_id, day, channel`,
  503. }
  504. for _, s := range stmts {
  505. req, err := http.NewRequestWithContext(ctx, "POST", chURL+"/", bytes.NewBufferString(s))
  506. if err != nil {
  507. return err
  508. }
  509. resp, err := http.DefaultClient.Do(req)
  510. if err != nil {
  511. return fmt.Errorf("ch stmt: %w", err)
  512. }
  513. if resp.StatusCode != 200 {
  514. body, _ := io.ReadAll(resp.Body)
  515. resp.Body.Close()
  516. return fmt.Errorf("ch stmt HTTP %d: %s", resp.StatusCode, string(body))
  517. }
  518. resp.Body.Close()
  519. logger.Debug("clickhouse schema ok", "stmt_prefix", firstLine(s))
  520. }
  521. return nil
  522. }
  523. // firstLine returns up to 80 chars of the first line of
  524. // a SQL statement, for log readability.
  525. func firstLine(s string) string {
  526. for i, c := range s {
  527. if c == '\n' {
  528. if i > 80 {
  529. return s[:80] + "..."
  530. }
  531. return s[:i]
  532. }
  533. }
  534. if len(s) > 80 {
  535. return s[:80] + "..."
  536. }
  537. return s
  538. }