| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- // Package archiver is the M7 periodic data-tier job. It
- // moves old rows from TimescaleDB `deliveries` to ClickHouse
- // `deliveries_archive` in batches. The hot-path services
- // (ingestd, routerd, deliverd-*) are unaffected; they
- // INSERT into `deliveries` just as before.
- //
- // The archiver is fail-stop: a partial batch is rolled back
- // (no DELETE happens if the ClickHouse INSERT fails).
- // Container restart retries the next hour.
- //
- // Concurrency: only one archiverd should run at a time per
- // environment. We use a Postgres advisory lock so two
- // replicas can't double-archive. `SKIP LOCKED` in the
- // SELECT makes the lock acquisition non-blocking.
- package archiver
- import (
- "bytes"
- "context"
- "database/sql"
- "encoding/json"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "net/url"
- "time"
- _ "github.com/jackc/pgx/v5/stdlib"
- )
- // RunOnce executes one full pass: for each table in scope,
- // drain rows older than `olderThan` in batches of
- // `batchSize`. Returns the total rows archived, total
- // batches, and any error encountered (caller decides
- // whether to retry the next interval).
- //
- // The function is safe to call repeatedly; each call
- // acquires the advisory lock, processes whatever's old,
- // releases the lock, and returns.
- func RunOnce(ctx context.Context, opts RunOptions) (Report, error) {
- rep := Report{StartedAt: time.Now().UTC()}
- conn, err := sql.Open("pgx", opts.PostgresDSN)
- if err != nil {
- return rep, fmt.Errorf("postgres open: %w", err)
- }
- defer conn.Close()
- if err := conn.PingContext(ctx); err != nil {
- return rep, fmt.Errorf("postgres ping: %w", err)
- }
- if err := ensureCHSchema(ctx, opts.ClickHouseURL, opts.Logger); err != nil {
- return rep, fmt.Errorf("clickhouse ensure-schema: %w", err)
- }
- // Per-table drain loop. We process the same table
- // repeatedly until the SELECT returns fewer than
- // `batchSize` rows.
- for _, t := range []string{"deliveries"} {
- n, batches, err := drainTable(ctx, conn, opts, t)
- if err != nil {
- return rep, fmt.Errorf("%s: %w", t, err)
- }
- rep.Tables = append(rep.Tables, TableReport{Name: t, Rows: n, Batches: batches})
- }
- rep.FinishedAt = time.Now().UTC()
- rep.Duration = rep.FinishedAt.Sub(rep.StartedAt)
- return rep, nil
- }
- // RunOptions is the per-call configuration.
- type RunOptions struct {
- // PostgresDSN is the libpq-style DSN (e.g. from
- // config.Common.PostgresDSN).
- PostgresDSN string
- // ClickHouseURL is the HTTP base URL for the
- // ClickHouse server (e.g. http://clickhouse:8123).
- // Note: NO trailing slash.
- ClickHouseURL string
- // OlderThan is the cutoff; rows whose created_at
- // is strictly less than now() - OlderThan are
- // eligible for archive. Default: 7 days.
- OlderThan time.Duration
- // BatchSize is the cap per SELECT/INSERT. The
- // per-run cap is BatchSize * ~10 cycles (the
- // drain loop runs until a SELECT returns <BatchSize
- // rows; the safety cap is 100 cycles).
- BatchSize int
- // Logger is the slog handle.
- Logger *slog.Logger
- }
- // Report is the result of one RunOnce call.
- type Report struct {
- StartedAt time.Time
- FinishedAt time.Time
- Duration time.Duration
- Tables []TableReport
- }
- // TableReport is the per-table result.
- type TableReport struct {
- Name string
- Rows int
- Batches int
- }
- // drainTable moves all rows of `table` older than the
- // cutoff in batches of `BatchSize`. Returns the total
- // row count and the number of batches.
- //
- // The drain uses `FOR UPDATE SKIP LOCKED` to be safe
- // against concurrent archiverd instances (the advisory
- // lock is the primary guard; SKIP LOCKED is a
- // belt-and-suspenders).
- func drainTable(ctx context.Context, conn *sql.DB, opts RunOptions, table string) (int, int, error) {
- olderThan := opts.OlderThan
- if olderThan == 0 {
- olderThan = 7 * 24 * time.Hour
- }
- batchSize := opts.BatchSize
- if batchSize == 0 {
- batchSize = 10000
- }
- // Acquire the per-table advisory lock. If another
- // archiverd holds it, we return early (no error —
- // this is normal in a multi-replica deploy).
- lockKey := int64(0xBA21B0DA) // arbitrary stable key
- conn2, err := conn.Conn(ctx)
- if err != nil {
- return 0, 0, err
- }
- defer conn2.Close()
- var gotLock bool
- if err := conn2.QueryRowContext(ctx,
- `SELECT pg_try_advisory_lock($1)`, lockKey,
- ).Scan(&gotLock); err != nil {
- return 0, 0, fmt.Errorf("advisory lock: %w", err)
- }
- if !gotLock {
- opts.Logger.Info("another archiverd holds the lock; skipping this run")
- return 0, 0, nil
- }
- defer func() {
- _, _ = conn2.ExecContext(ctx, `SELECT pg_advisory_unlock($1)`, lockKey)
- }()
- totalRows := 0
- batches := 0
- safetyCycles := 100
- for cycle := 0; cycle < safetyCycles; cycle++ {
- rows, err := selectBatch(ctx, conn2, table, olderThan, batchSize)
- if err != nil {
- return totalRows, batches, err
- }
- if len(rows) == 0 {
- break
- }
- if err := insertCH(ctx, opts.ClickHouseURL, table, rows); err != nil {
- return totalRows, batches, fmt.Errorf("clickhouse insert: %w", err)
- }
- if err := deleteBatch(ctx, conn2, table, rows); err != nil {
- return totalRows, batches, fmt.Errorf("postgres delete: %w", err)
- }
- totalRows += len(rows)
- batches++
- opts.Logger.Info("archived batch",
- "table", table,
- "batch", batches,
- "rows", len(rows),
- "total_rows", totalRows,
- )
- if len(rows) < batchSize {
- break // drained
- }
- }
- return totalRows, batches, nil
- }
- // deliveryRow is the wire shape we read from Postgres
- // and INSERT into ClickHouse. JSON tags match the CH
- // column names (case-insensitive in CH).
- //
- // Time fields are stored as strings (CH wire format) —
- // not as time.Time. We do the conversion in selectBatch
- // after scanning. This sidesteps the default time.Time
- // JSON encoding (RFC3339Nano with 'T' + 'Z') that CH
- // can't parse for DateTime64 columns.
- type deliveryRow struct {
- ID int64 `json:"id"`
- AlertID string `json:"alert_id"`
- CompanyID string `json:"company_id"`
- IndividualID string `json:"individual_id"`
- Channel string `json:"channel"`
- Target string `json:"target"`
- Status string `json:"status"`
- Attempts int32 `json:"attempts"`
- LastError string `json:"last_error"`
- Payload json.RawMessage `json:"payload"`
- CreatedAt string `json:"created_at"`
- SentAt string `json:"sent_at,omitempty"`
- NextAttemptAt string `json:"next_attempt_at,omitempty"`
- }
- // chTime formats a time as ClickHouse's preferred
- // DateTime64(3, 'UTC') wire format: "2026-06-06 21:11:52.036".
- // Postgres returns RFC3339Nano with 'T' separator and a 'Z'
- // suffix; CH doesn't parse those directly. We use 3-decimal
- // precision to match the column type.
- func chTime(t time.Time) string {
- return t.UTC().Format("2006-01-02 15:04:05.000")
- }
- // chTimeOrEmpty renders a nullable time as "" for CH's
- // Nullable(DateTime64) column. CH accepts an empty string
- // for nullable datetime columns in JSONEachRow.
- func chTimeOrEmpty(t *time.Time) string {
- if t == nil {
- return ""
- }
- return chTime(*t)
- }
- // selectBatch reads up to `limit` rows from the given
- // table where the time column is older than `cutoff`.
- // The time column is always `created_at` in M7; if M7.5
- // adds another table, we may need to vary the column.
- func selectBatch(ctx context.Context, conn *sql.Conn, table string, olderThan time.Duration, limit int) ([]deliveryRow, error) {
- cutoff := time.Now().UTC().Add(-olderThan)
- q := `
- SELECT id, alert_id, company_id, individual_id, channel,
- target, status, attempts, last_error, payload,
- created_at, sent_at, next_attempt_at
- FROM deliveries
- WHERE created_at < $1
- ORDER BY created_at
- LIMIT $2
- FOR UPDATE SKIP LOCKED
- `
- _ = table // currently only one table in scope
- rows, err := conn.QueryContext(ctx, q, cutoff, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var out []deliveryRow
- for rows.Next() {
- var r deliveryRow
- var payload sql.NullString
- var sentAt, nextAttemptAt sql.NullTime
- var createdAt time.Time
- if err := rows.Scan(
- &r.ID, &r.AlertID, &r.CompanyID, &r.IndividualID, &r.Channel,
- &r.Target, &r.Status, &r.Attempts, &r.LastError, &payload,
- &createdAt, &sentAt, &nextAttemptAt,
- ); err != nil {
- return nil, err
- }
- if payload.Valid {
- r.Payload = json.RawMessage(payload.String)
- } else {
- r.Payload = json.RawMessage("null")
- }
- // Convert Postgres time.Time to CH wire format.
- // We marshal as a string in the row, not via
- // the default time.Time JSON (which is RFC3339Nano
- // with Z suffix; CH can't parse that).
- r.CreatedAt = chTime(createdAt)
- if sentAt.Valid {
- t := sentAt.Time
- r.SentAt = chTimeOrEmpty(&t)
- }
- if nextAttemptAt.Valid {
- t := nextAttemptAt.Time
- r.NextAttemptAt = chTimeOrEmpty(&t)
- }
- out = append(out, r)
- }
- return out, rows.Err()
- }
- // insertCH writes the rows to ClickHouse via the HTTP
- // interface. Each batch is one INSERT; CH is atomic per
- // INSERT so a partial failure rolls back the whole
- // batch.
- func insertCH(ctx context.Context, chURL, table string, rows []deliveryRow) error {
- // CH table for the deliveries archive.
- chTable := "ba_archive.deliveries_archive"
- _ = table // currently only one in scope
- // Build a JSONEachRow payload. CH accepts one JSON
- // object per line.
- var buf bytes.Buffer
- for _, r := range rows {
- // CH's DateTime64(3, 'UTC') expects RFC3339Nano
- // format. time.Time's default MarshalJSON gives
- // RFC3339Nano.
- j, err := json.Marshal(r)
- if err != nil {
- return fmt.Errorf("marshal row %d: %w", r.ID, err)
- }
- buf.Write(j)
- buf.WriteByte('\n')
- }
- u := chURL + "/?" + url.Values{
- "query": {"INSERT INTO " + chTable + " FORMAT JSONEachRow"},
- }.Encode()
- req, err := http.NewRequestWithContext(ctx, "POST", u, &buf)
- if err != nil {
- return err
- }
- req.Header.Set("Content-Type", "application/x-ndjson")
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return err
- }
- defer resp.Body.Close()
- if resp.StatusCode != 200 {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("clickhouse HTTP %d: %s", resp.StatusCode, string(body))
- }
- return nil
- }
- // deleteBatch removes the just-archived rows from
- // Postgres. The `created_at < cutoff` is the same
- // predicate we used in selectBatch, but we constrain on
- // `id IN (...)` to avoid accidentally re-deleting rows
- // that arrived between the SELECT and the DELETE.
- func deleteBatch(ctx context.Context, conn *sql.Conn, table string, rows []deliveryRow) error {
- _ = table
- ids := make([]int64, 0, len(rows))
- for _, r := range rows {
- ids = append(ids, r.ID)
- }
- // Cap chunk size at 1000 ids per query to stay
- // within pgx's parameter limit and not blow up
- // statement parsing on large batches.
- const chunk = 1000
- for i := 0; i < len(ids); i += chunk {
- end := i + chunk
- if end > len(ids) {
- end = len(ids)
- }
- q := `DELETE FROM deliveries WHERE id = ANY($1::bigint[])`
- _, err := conn.ExecContext(ctx, q, ids[i:end])
- if err != nil {
- return err
- }
- }
- return nil
- }
- // ensureCHSchema runs the 007_clickhouse.up.sql DDL on the
- // ClickHouse server. Idempotent — every statement is
- // CREATE ... IF NOT EXISTS. We do this at the start of
- // every RunOnce so a fresh deploy auto-creates the
- // schema without operator intervention.
- func ensureCHSchema(ctx context.Context, chURL string, logger *slog.Logger) error {
- stmts := []string{
- `CREATE DATABASE IF NOT EXISTS ba_archive`,
- `CREATE TABLE IF NOT EXISTS ba_archive.deliveries_archive (
- id BIGINT,
- alert_id String,
- company_id String,
- individual_id String,
- channel LowCardinality(String),
- target String,
- status LowCardinality(String),
- attempts UInt32,
- last_error String,
- payload String,
- created_at DateTime64(3, 'UTC'),
- sent_at Nullable(DateTime64(3, 'UTC')),
- next_attempt_at Nullable(DateTime64(3, 'UTC')),
- archived_at DateTime DEFAULT now()
- ) ENGINE = MergeTree
- PARTITION BY toYYYYMM(created_at)
- ORDER BY (company_id, created_at, id)
- TTL toDateTime(created_at) + INTERVAL 365 DAY`,
- `CREATE MATERIALIZED VIEW IF NOT EXISTS
- ba_archive.deliveries_per_company_daily_mv
- ENGINE = SummingMergeTree
- PARTITION BY toYYYYMM(day)
- ORDER BY (company_id, day, channel, status)
- AS
- SELECT
- company_id,
- toDate(created_at) AS day,
- channel,
- status,
- count() AS n
- FROM ba_archive.deliveries_archive
- GROUP BY company_id, day, channel, status`,
- }
- for _, s := range stmts {
- req, err := http.NewRequestWithContext(ctx, "POST", chURL+"/", bytes.NewBufferString(s))
- if err != nil {
- return err
- }
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- return fmt.Errorf("ch stmt: %w", err)
- }
- if resp.StatusCode != 200 {
- body, _ := io.ReadAll(resp.Body)
- resp.Body.Close()
- return fmt.Errorf("ch stmt HTTP %d: %s", resp.StatusCode, string(body))
- }
- resp.Body.Close()
- logger.Debug("clickhouse schema ok", "stmt_prefix", firstLine(s))
- }
- return nil
- }
- // firstLine returns up to 80 chars of the first line of
- // a SQL statement, for log readability.
- func firstLine(s string) string {
- for i, c := range s {
- if c == '\n' {
- if i > 80 {
- return s[:80] + "..."
- }
- return s[:i]
- }
- }
- if len(s) > 80 {
- return s[:80] + "..."
- }
- return s
- }
|