// 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 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 }