Skip to main content

rivet/load/
bigquery.rs

1//! The `TargetLoader` seam and the first live loader — **BigQuery**.
2//!
3//! OSS decides *what* a column becomes in the warehouse (`TargetColumnSpec`,
4//! via `ExportTarget::resolve_table`). This module executes that plan against
5//! a live warehouse.
6//!
7//! ## BigQuery load model — one free path
8//!
9//! Batch-loading Parquet from GCS is **free** in BigQuery (load jobs use the
10//! ingestion slot pool, not query slots). The loader declares each column's
11//! native `target_type` **inline in the `LOAD DATA` statement**, e.g.
12//!
13//! ```sql
14//! LOAD DATA OVERWRITE `p.d.t` (id INT64, json_col JSON, dt_col DATETIME)
15//! PARTITION BY d FROM FILES (format = 'PARQUET', uris = [...]);
16//! ```
17//!
18//! With the schema declared, BigQuery **coerces the Parquet to native types on
19//! load** — JSON, DATETIME (wall-clock), TIME, NUMERIC, … all land natively,
20//! **for free** (a load job, not a query). No autoload-then-CTAS recovery is
21//! needed. Verified live against a full MySQL type matrix: every column loaded
22//! natively with `total_bytes_billed = 0`.
23//!
24//! (This corrects an earlier premise — the OSS resolver's `cast_sql` recovery
25//! assumes a *bare* autoload rejects native types; declaring the schema in the
26//! `LOAD DATA` statement itself coerces them for free. The one exception is a
27//! *value* transform like UUID `bytes → TO_HEX(hex)`, which a type declaration
28//! cannot perform; such a column lands as its declared type and may need a
29//! downstream transform.)
30//!
31//! Idempotent under Rivet's at-least-once file delivery: `LOAD DATA OVERWRITE`
32//! reproduces the same table on a retry.
33//!
34//! ## Two BigQuery limits this respects
35//!
36//! - `PARTITION BY` / `CLUSTER BY` apply **only when the table is created**;
37//!   you cannot convert an existing table by overwriting it, and clustering is
38//!   capped at 4 columns. The loader manages its own target table.
39//! - A single load *or* query job may modify at most **4,000 partitions**. A
40//!   partitioned load spanning more is split into several `LOAD DATA` jobs, each
41//!   under the cap (see `plan_load_batches`); a non-splittable overflow surfaces
42//!   an actionable error telling you to split the URIs by partition range.
43//!
44//! ## Cost attribution via job labels
45//!
46//! Every BigQuery job the loader creates is labeled so its cost is
47//! automatically attributable: `managed_by:rivet`, `rivet_op:<load|count>`,
48//! `rivet_table:<table>`, `rivet_run:<id>` (the load-run correlation id, when
49//! set).
50//! The batch ops are free load/metadata jobs (`total_bytes_billed = 0`); the
51//! CDC path adds billed `merge` / `compact` ops on the same `run_sql(sql, op,
52//! table)` seam, so a billed dedup step shows on its own cost line (see
53//! `docs/cdc-bigquery-load.md`). The labels flow into
54//! `INFORMATION_SCHEMA.JOBS` and the billing export, so cost per
55//! operation/table is one query:
56//!
57//! ```sql
58//! SELECT
59//!   (SELECT value FROM UNNEST(labels) WHERE key = 'rivet_run')   AS run,
60//!   (SELECT value FROM UNNEST(labels) WHERE key = 'rivet_op')    AS op,
61//!   (SELECT value FROM UNNEST(labels) WHERE key = 'rivet_table') AS tbl,
62//!   COUNT(*)                              AS jobs,
63//!   SUM(total_bytes_billed)               AS bytes_billed,
64//!   SUM(total_bytes_billed) / POW(1024, 4) * 6.25 AS est_usd  -- ~$6.25/TiB on-demand
65//! FROM `region-us`.INFORMATION_SCHEMA.JOBS
66//! WHERE EXISTS (SELECT 1 FROM UNNEST(labels) WHERE key = 'managed_by' AND value = 'rivet')
67//! GROUP BY run, op, tbl ORDER BY run, bytes_billed DESC;
68//! ```
69//!
70//! Transport is the `bq` CLI (Google Cloud SDK) — the same tool the OSS
71//! BigQuery live tests use, so auth is whatever `bq` is configured with (ADC /
72//! service account) and no credentials touch this crate.
73
74use super::TargetLoader;
75use crate::types::target::TargetColumnSpec;
76use anyhow::{Context, Result, bail};
77use std::collections::HashMap;
78use std::process::{Command, Output};
79// ── BigQuery ─────────────────────────────────────────────────────────────────
80
81/// Maximum clustering columns BigQuery allows.
82const MAX_CLUSTER_COLUMNS: usize = 4;
83
84/// BigQuery's hard cap on partitions modified by a single job.
85const DEFAULT_MAX_PARTITIONS_PER_JOB: usize = 4000;
86
87/// Loads Rivet Parquet into a BigQuery dataset via the `bq` CLI.
88#[derive(Debug, Clone)]
89pub struct BigQueryLoader {
90    pub project: String,
91    pub dataset: String,
92    /// Partition expression for table creation, e.g. `DATE(created_at)` or a
93    /// `DATE`/`TIMESTAMP` column. Applied only when the table is created.
94    pub partition_by: Option<String>,
95    /// Up to 4 clustering columns. Applied only when the table is created.
96    pub cluster_by: Vec<String>,
97    /// Load-run correlation id, emitted as the automatic `rivet_run:<id>` job
98    /// label so every job of one `rivet load` invocation shares a run key —
99    /// cost slices per run (across tables) as well as per table. `None` omits
100    /// the label entirely.
101    pub run_id: Option<String>,
102    /// Max distinct partitions a single load job may create — BigQuery's hard
103    /// limit is 4,000. When a daily-partitioned, Hive-prefixed input
104    /// (`<col>=YYYY-MM-DD/…`, as rivet's `partition_by` writes) spans more than
105    /// this, the free load is split into several `LOAD DATA` jobs, each under
106    /// the cap.
107    pub max_partitions_per_job: usize,
108}
109
110impl BigQueryLoader {
111    pub fn new(project: impl Into<String>, dataset: impl Into<String>) -> Self {
112        Self {
113            project: project.into(),
114            dataset: dataset.into(),
115            partition_by: None,
116            cluster_by: Vec::new(),
117            run_id: None,
118            max_partitions_per_job: DEFAULT_MAX_PARTITIONS_PER_JOB,
119        }
120    }
121
122    pub fn partition_by(mut self, expr: impl Into<String>) -> Self {
123        self.partition_by = Some(expr.into());
124        self
125    }
126
127    /// Set the load-run correlation id, emitted as the `rivet_run` job label.
128    pub fn run_id(mut self, id: impl Into<String>) -> Self {
129        self.run_id = Some(id.into());
130        self
131    }
132
133    pub fn cluster_by(mut self, columns: Vec<String>) -> Self {
134        self.cluster_by = columns;
135        self
136    }
137
138    /// Run `bq --project_id=<p> <args…>`. On failure, `bq` prints the actual
139    /// reason (e.g. "Too many partitions … allowed 4000") to **stdout** while
140    /// stderr carries only the "Waiting…/DONE" spinner — so the error detail
141    /// combines both streams (spinner lines stripped).
142    fn run_bq(&self, args: &[String]) -> Result<Output> {
143        let out = Command::new("bq")
144            .arg(format!("--project_id={}", self.project))
145            .args(args)
146            .output()
147            .context("failed to run `bq` — is the Google Cloud SDK installed and on PATH?")?;
148        if !out.status.success() {
149            let detail = [clean_bq_output(&out.stdout), clean_bq_output(&out.stderr)]
150                .into_iter()
151                .filter(|s| !s.is_empty())
152                .collect::<Vec<_>>()
153                .join(" | ");
154            bail!(
155                "bq {} failed: {detail}",
156                args.first()
157                    .map(String::as_str)
158                    .unwrap_or("<no-subcommand>"),
159            );
160        }
161        Ok(out)
162    }
163
164    /// The automatic + user labels for a job, as repeated `--label k:v` args.
165    fn label_flags(&self, op: &str, table: &str) -> Vec<String> {
166        build_label_flags(op, table, self.run_id.as_deref())
167    }
168
169    /// Run a SQL statement (free `LOAD DATA` load job or a billed CTAS/query),
170    /// tagged with `rivet_op:<op>` + `rivet_table:<table>` for cost attribution.
171    fn run_sql(&self, sql: &str, op: &str, table: &str) -> Result<Output> {
172        self.run_bq(&query_args(sql, &self.label_flags(op, table)))
173            .map_err(augment_partition_limit)
174    }
175
176    fn count_rows(&self, fqtn: &str, table: &str) -> Result<u64> {
177        // COUNT(*) reads table metadata — 0 bytes billed.
178        let out = self.run_bq(&count_args(fqtn, &self.label_flags("count", table)))?;
179        parse_count_csv(&String::from_utf8_lossy(&out.stdout))
180    }
181
182    /// Split `uris` into free-load batches that each stay under the per-job
183    /// partition cap. Splits only when partitioning on a bare column whose
184    /// Hive `<col>=value/` prefix is present on the URIs and the distinct
185    /// partition count exceeds the cap; otherwise the whole set is one batch
186    /// (non-Hive inputs load in one job, as before).
187    fn plan_load_batches(&self, uris: &[String]) -> Vec<Vec<String>> {
188        match self.partition_by.as_deref() {
189            Some(col) if is_bare_column(col) => {
190                plan_hive_batches(uris, col, self.max_partitions_per_job)
191                    .unwrap_or_else(|_| vec![uris.to_vec()])
192            }
193            _ => vec![uris.to_vec()],
194        }
195    }
196}
197
198impl TargetLoader for BigQueryLoader {
199    fn fqtn(&self, table: &str) -> String {
200        format!("{}.{}.{}", self.project, self.dataset, table)
201    }
202
203    fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64> {
204        if self.cluster_by.len() > MAX_CLUSTER_COLUMNS {
205            bail!(
206                "BigQuery allows at most {MAX_CLUSTER_COLUMNS} clustering columns, got {}",
207                self.cluster_by.len()
208            );
209        }
210        // Gate each clustering column: it splices raw into `CLUSTER BY <cols>`
211        // (an identifier list, no quoting) — the same is_safe_load_ident gate the
212        // table / column / pk names get. Config-derived, so operator self-harm,
213        // but gated for consistency with the round-5/6 injection surface.
214        // (`partition_by` is intentionally NOT gated here — it is a BigQuery
215        // partition EXPRESSION, e.g. `DATE(created_at)`, not a bare identifier.)
216        for c in &self.cluster_by {
217            if !super::is_safe_load_ident(c) {
218                bail!(
219                    "BigQuery load: clustering column `{}` is not a plain SQL identifier \
220                     ([A-Za-z_][A-Za-z0-9_]*) — it splices into CLUSTER BY. Rename it.",
221                    c.escape_default()
222                );
223            }
224        }
225        let target = self.fqtn(table);
226        let schema = build_schema(specs);
227
228        // ONE free path: declaring each column's native `target_type` inline in
229        // LOAD DATA makes BigQuery coerce the Parquet on load — JSON, DATETIME,
230        // NUMERIC, … land natively for FREE (a load job, not a query). A
231        // daily-partitioned, Hive-prefixed input over the per-job partition cap
232        // is split into several free LOAD DATA jobs: batch 0 OVERWRITEs the
233        // table, later batches append so they add to — not clobber — it.
234        for (i, batch) in self.plan_load_batches(uris).iter().enumerate() {
235            let sql = build_load_data_sql(
236                &target,
237                i == 0, // overwrite the first batch, append the rest
238                &schema,
239                &self.partition_by,
240                &self.cluster_by,
241                batch,
242            );
243            self.run_sql(&sql, "load", table)?;
244        }
245        // ponytail: rows via COUNT(*) (a 0-byte-billed metadata read); can become
246        // the load job's `outputRows` (also metadata) behind this seam, no driver
247        // change.
248        self.count_rows(&target, table)
249    }
250
251    fn append_changelog(
252        &self,
253        table: &str,
254        specs: &[TargetColumnSpec],
255        uris: &[String],
256        pk: &[String],
257    ) -> Result<u64> {
258        use crate::load::cdc::Warehouse;
259        // Full change-log schema: rivet's `__op`/`__pos`/`__seq` meta columns
260        // (not reported by `rivet check`) ahead of the resolved data columns.
261        let mut full = crate::load::cdc::meta_column_specs(Warehouse::BigQuery);
262        full.extend(
263            specs
264                .iter()
265                .filter(|s| !is_meta_column(&s.column_name))
266                .cloned(),
267        );
268        let schema = build_schema(&full);
269
270        let changes = format!("{table}__changes");
271        let changes_fqtn = self.fqtn(&changes);
272
273        // Ensure the append-only log exists, clustered on the PK so the dedup
274        // view prunes efficiently. Idempotent: created once, appended forever.
275        let create = build_create_changes_sql(&changes_fqtn, &schema, pk);
276        self.run_sql(&create, "create", &changes)?;
277
278        // …and, for a log that ALREADY existed, add whatever the declared
279        // schema has and it does not. The CREATE above is a no-op on such a
280        // table, so without this a log rivet did not create — or one that predates a new
281        // meta column — fails the LOAD below on a schema mismatch. ALTER ADD,
282        // never a replace: the table may hold the customer's history.
283        if let Some(alter) = build_alter_add_columns_sql(&changes_fqtn, &full) {
284            self.run_sql(&alter, "alter", &changes)?;
285        }
286
287        // Count before / append (free LOAD DATA INTO) / count after — the delta
288        // is what THIS load added; the driver gates it against the manifest total.
289        let before = self.count_rows(&changes_fqtn, &changes)?;
290        let load = build_load_data_sql(&changes_fqtn, false, &schema, &None, &[], uris);
291        self.run_sql(&load, "load", &changes)?;
292        let after = self.count_rows(&changes_fqtn, &changes)?;
293        Ok(after.saturating_sub(before))
294    }
295
296    fn warehouse(&self) -> crate::load::cdc::Warehouse {
297        crate::load::cdc::Warehouse::BigQuery
298    }
299
300    fn create_view(&self, table: &str, view_sql: &str) -> Result<()> {
301        self.run_sql(view_sql, "view", table)?;
302        Ok(())
303    }
304}
305
306/// Whether a column name is one of rivet's CDC meta columns — filtered out of
307/// the data specs before the meta columns are prepended, so a schema can never
308/// declare `__op`/`__pos`/`__seq` twice.
309fn is_meta_column(name: &str) -> bool {
310    crate::load::cdc::is_meta_column(name)
311}
312
313/// `CREATE TABLE IF NOT EXISTS` for the change log, clustered on the PK (capped
314/// at BigQuery's 4 clustering columns). Idempotent — the log is created once and
315/// appended to on every CDC load.
316fn build_create_changes_sql(fqtn: &str, schema: &str, pk: &[String]) -> String {
317    let cluster_cols = pk
318        .iter()
319        .take(MAX_CLUSTER_COLUMNS)
320        .cloned()
321        .collect::<Vec<_>>()
322        .join(", ");
323    format!("CREATE TABLE IF NOT EXISTS `{fqtn}` (\n{schema}\n)\nCLUSTER BY {cluster_cols};")
324}
325
326/// Bring an EXISTING table's schema up to the declared one by ADDING what is
327/// missing — never by replacing the table.
328///
329/// `CREATE TABLE IF NOT EXISTS` is a no-op on a table that already exists, so a
330/// table rivet did not create — one an operator pointed rivet at — keeps
331/// whatever shape its previous owner gave it. The next `LOAD DATA` then declares
332/// columns the table does not have and fails; and a load written to overwrite
333/// instead would impose our schema and destroy the customer's data. Neither is
334/// acceptable on a table we were handed rather than created.
335///
336/// `ADD COLUMN IF NOT EXISTS` is the only verb that is safe here: additive,
337/// idempotent, and metadata-only on BigQuery — no rewrite, no scan, and existing
338/// rows read NULL for the new column, which is exactly the state §5i's per-key
339/// fallback is built to handle.
340///
341/// `None` when there is nothing to add, so the caller skips the round trip
342/// rather than sending a statement with an empty body.
343fn build_alter_add_columns_sql(fqtn: &str, specs: &[TargetColumnSpec]) -> Option<String> {
344    if specs.is_empty() {
345        return None;
346    }
347    let adds = specs
348        .iter()
349        .map(|s| {
350            format!(
351                "ADD COLUMN IF NOT EXISTS `{}` {}",
352                s.column_name, s.target_type
353            )
354        })
355        .collect::<Vec<_>>()
356        .join(",\n  ");
357    Some(format!("ALTER TABLE `{fqtn}`\n  {adds};"))
358}
359
360/// Whether `c` is a bare column identifier (so it matches a Hive path key),
361/// not an expression like `DATE(x)` or `DATE_TRUNC(d, MONTH)`.
362fn is_bare_column(c: &str) -> bool {
363    !c.is_empty() && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
364}
365
366/// The Hive partition value for `column` in a URI path, e.g.
367/// `gs://b/t/d=2023-01-01/part-0.parquet` + `d` → `2023-01-01`.
368fn hive_partition_value(uri: &str, column: &str) -> Option<String> {
369    let needle = format!("{column}=");
370    uri.split('/')
371        .find_map(|seg| seg.strip_prefix(&needle).map(str::to_string))
372}
373
374/// Group `uris` so each batch holds at most `max` distinct Hive partition
375/// values of `column`. URIs sharing a value stay together. Errors if any URI
376/// lacks the `<column>=` segment (caller falls back to a single batch).
377fn plan_hive_batches(uris: &[String], column: &str, max: usize) -> Result<Vec<Vec<String>>> {
378    let pairs: Vec<(&String, String)> = uris
379        .iter()
380        .map(|u| {
381            hive_partition_value(u, column)
382                .map(|v| (u, v))
383                .ok_or_else(|| anyhow::anyhow!("uri has no `{column}=` Hive segment: {u}"))
384        })
385        .collect::<Result<_>>()?;
386
387    let mut values: Vec<&str> = pairs.iter().map(|(_, v)| v.as_str()).collect();
388    values.sort_unstable();
389    values.dedup();
390    if values.len() <= max {
391        return Ok(vec![uris.to_vec()]);
392    }
393
394    // Contiguous windows of `max` distinct (sorted) values → one batch each.
395    let batch_of: HashMap<&str, usize> = values
396        .iter()
397        .enumerate()
398        .map(|(i, v)| (*v, i / max))
399        .collect();
400    let mut batches: Vec<Vec<String>> = vec![Vec::new(); values.len().div_ceil(max)];
401    for (u, v) in &pairs {
402        batches[batch_of[v.as_str()]].push((*u).clone());
403    }
404    Ok(batches)
405}
406
407/// `PARTITION BY … / CLUSTER BY …` clauses (empty when unset). Both apply only
408/// at table creation, per BigQuery.
409fn table_shape_clauses(partition_by: &Option<String>, cluster_by: &[String]) -> String {
410    let mut s = String::new();
411    if let Some(expr) = partition_by {
412        s.push_str(&format!("\nPARTITION BY {expr}"));
413    }
414    if !cluster_by.is_empty() {
415        s.push_str(&format!("\nCLUSTER BY {}", cluster_by.join(", ")));
416    }
417    s
418}
419
420/// A `FROM FILES(...)` Parquet source list.
421///
422/// `enable_list_inference = true` collapses rivet's 3-level Parquet LIST
423/// (`col.list.item`) one level, so an array column loads as the declared
424/// `ARRAY<STRUCT<item T>>` (== REPEATED RECORD{item}) instead of empty. It is a
425/// no-op for non-list columns, so it is always safe to set.
426fn from_files(uris: &[String]) -> String {
427    let list = uris
428        .iter()
429        .map(|u| format!("    '{u}'"))
430        .collect::<Vec<_>>()
431        .join(",\n");
432    format!(
433        "FROM FILES (\n  format = 'PARQUET',\n  enable_list_inference = true,\n  uris = [\n{list}\n  ]\n)"
434    )
435}
436
437/// The BigQuery column schema declared inline in LOAD DATA, from each spec's
438/// native `target_type`. Declaring native types makes BigQuery coerce the
439/// Parquet on load — for FREE (a load job, not a query) — so JSON / DATETIME /
440/// TIME / NUMERIC / … land natively without a post-load CTAS. Verified live.
441fn build_schema(specs: &[TargetColumnSpec]) -> String {
442    specs
443        .iter()
444        .map(|s| format!("  {} {}", s.column_name, s.target_type))
445        .collect::<Vec<_>>()
446        .join(",\n")
447}
448
449/// A free `LOAD DATA` batch-load statement declaring the native `schema`, so
450/// BigQuery coerces the Parquet to native types on load.
451fn build_load_data_sql(
452    fqtn: &str,
453    overwrite: bool,
454    schema: &str,
455    partition_by: &Option<String>,
456    cluster_by: &[String],
457    uris: &[String],
458) -> String {
459    let kw = if overwrite { "OVERWRITE" } else { "INTO" };
460    let clauses = table_shape_clauses(partition_by, cluster_by);
461    format!(
462        "LOAD DATA {kw} `{fqtn}` (\n{schema}\n){clauses}\n{};",
463        from_files(uris)
464    )
465}
466
467fn query_args(sql: &str, labels: &[String]) -> Vec<String> {
468    // Labels are flags — they MUST precede the positional SQL string.
469    let mut a = vec![
470        "query".into(),
471        "--use_legacy_sql=false".into(),
472        "--format=none".into(),
473    ];
474    a.extend_from_slice(labels);
475    a.push(sql.into());
476    a
477}
478
479fn count_args(fqtn: &str, labels: &[String]) -> Vec<String> {
480    let mut a = vec![
481        "query".into(),
482        "--use_legacy_sql=false".into(),
483        "--format=csv".into(),
484    ];
485    a.extend_from_slice(labels);
486    a.push(format!("SELECT COUNT(*) AS n FROM `{fqtn}`"));
487    a
488}
489
490/// Build `--label k:v` args: the automatic `managed_by:rivet` /
491/// `rivet_op:<op>` / `rivet_table:<table>` labels, the `rivet_run:<id>` label
492/// when a run id is set, plus any `extra` (user) labels. These land in
493/// `INFORMATION_SCHEMA.JOBS.labels` and the billing export, so cost can be
494/// attributed per run and per table.
495fn build_label_flags(op: &str, table: &str, run_id: Option<&str>) -> Vec<String> {
496    let mut labels: Vec<(String, String)> = vec![
497        ("managed_by".into(), "rivet".into()),
498        ("rivet_op".into(), sanitize_label(op)),
499        ("rivet_table".into(), sanitize_label(table)),
500    ];
501    if let Some(id) = run_id {
502        labels.push(("rivet_run".into(), sanitize_label(id)));
503    }
504    labels
505        .into_iter()
506        .flat_map(|(k, v)| ["--label".to_string(), format!("{k}:{v}")])
507        .collect()
508}
509
510/// Coerce a string into BigQuery's label charset: lowercase `[a-z0-9_-]`, other
511/// characters become `_`, truncated to 63 chars. Empty maps to `unnamed`.
512fn sanitize_label(s: &str) -> String {
513    let mut out: String = s
514        .chars()
515        .map(|c| {
516            let c = c.to_ascii_lowercase();
517            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
518                c
519            } else {
520                '_'
521            }
522        })
523        .collect();
524    out.truncate(63);
525    if out.is_empty() {
526        "unnamed".clone_into(&mut out);
527    }
528    out
529}
530
531/// Parse a `bq query --format=csv` count result: a `n` header line then the
532/// value. Take the last line that parses as an integer.
533fn parse_count_csv(stdout: &str) -> Result<u64> {
534    stdout
535        .lines()
536        .rev()
537        .find_map(|l| l.trim().parse::<u64>().ok())
538        .context("could not parse a row count from bq output")
539}
540
541/// Normalize `bq` output for an error message: split the `\r`-driven progress
542/// spinner into lines, drop the "Waiting…/Current status:" noise, and join the
543/// rest — leaving the real error `bq` printed (e.g. the partition-quota text).
544fn clean_bq_output(bytes: &[u8]) -> String {
545    String::from_utf8_lossy(bytes)
546        .replace('\r', "\n")
547        .lines()
548        .map(str::trim)
549        .filter(|l| !l.is_empty() && !l.starts_with("Waiting on") && !l.contains("Current status:"))
550        .collect::<Vec<_>>()
551        .join(" ")
552}
553
554/// Turn BigQuery's partition-quota failure into an actionable error.
555fn augment_partition_limit(e: anyhow::Error) -> anyhow::Error {
556    let s = e.to_string().to_lowercase();
557    if s.contains("partition")
558        && (s.contains("4000") || s.contains("quota") || s.contains("exceed"))
559    {
560        return e.context(
561            "BigQuery caps a single load/query job at 4,000 modified partitions — split the \
562             Parquet URIs into batches whose partition span is <= 4,000 (e.g. load by date range)",
563        );
564    }
565    e
566}
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::types::target::TargetStatus;
571
572    fn spec(name: &str, cast: Option<&str>, status: TargetStatus) -> TargetColumnSpec {
573        TargetColumnSpec {
574            column_name: name.into(),
575            target_type: "X".into(),
576            autoload_type: "Y".into(),
577            status,
578            note: None,
579            cast_sql: cast.map(String::from),
580        }
581    }
582
583    fn uris() -> Vec<String> {
584        vec!["gs://b/a.parquet".into(), "gs://b/b.parquet".into()]
585    }
586
587    fn typed(name: &str, target_type: &str) -> TargetColumnSpec {
588        TargetColumnSpec {
589            column_name: name.into(),
590            target_type: target_type.into(),
591            autoload_type: "BYTES".into(),
592            status: TargetStatus::Ok,
593            note: None,
594            cast_sql: None,
595        }
596    }
597
598    #[test]
599    fn schema_declares_each_columns_native_target_type() {
600        let s = build_schema(&[
601            typed("id", "INT64"),
602            typed("json_col", "JSON"),
603            typed("dt_col", "DATETIME"),
604        ]);
605        assert!(s.contains("id INT64"));
606        assert!(s.contains("json_col JSON"));
607        assert!(s.contains("dt_col DATETIME"));
608    }
609
610    #[test]
611    fn load_data_declares_native_schema_and_is_a_free_batch_load() {
612        let schema = build_schema(&[typed("id", "INT64"), typed("json_col", "JSON")]);
613        let sql = build_load_data_sql("p.d.orders", true, &schema, &None, &[], &uris());
614        assert!(sql.starts_with("LOAD DATA OVERWRITE `p.d.orders` ("));
615        // Native types declared inline → BigQuery coerces on load, for free.
616        assert!(sql.contains("json_col JSON"));
617        assert!(sql.contains("format = 'PARQUET'"));
618        assert!(sql.contains("'gs://b/a.parquet'"));
619        assert!(!sql.contains("PARTITION BY"));
620    }
621
622    #[test]
623    fn load_data_append_uses_into() {
624        let schema = build_schema(&[typed("id", "INT64")]);
625        let sql = build_load_data_sql("p.d.orders", false, &schema, &None, &[], &uris());
626        assert!(sql.starts_with("LOAD DATA INTO `p.d.orders`"));
627    }
628
629    #[test]
630    fn load_data_emits_partition_and_cluster_when_configured() {
631        let schema = build_schema(&[typed("id", "INT64")]);
632        let sql = build_load_data_sql(
633            "p.d.orders",
634            true,
635            &schema,
636            &Some("DATE(created_at)".into()),
637            &["customer_id".into(), "region".into()],
638            &uris(),
639        );
640        assert!(sql.contains("PARTITION BY DATE(created_at)"));
641        assert!(sql.contains("CLUSTER BY customer_id, region"));
642    }
643
644    #[test]
645    fn create_changes_clusters_on_pk_capped_at_four_columns() {
646        let schema = build_schema(&[typed("__op", "STRING"), typed("id", "INT64")]);
647        let sql = build_create_changes_sql("p.d.orders__changes", &schema, &["id".into()]);
648        assert!(sql.starts_with("CREATE TABLE IF NOT EXISTS `p.d.orders__changes` ("));
649        assert!(sql.contains("CLUSTER BY id"));
650        // A >4-column PK is capped to BigQuery's clustering limit.
651        let wide: Vec<String> = ["a", "b", "c", "d", "e"]
652            .iter()
653            .map(|s| s.to_string())
654            .collect();
655        let sql2 = build_create_changes_sql("t", &schema, &wide);
656        assert!(sql2.contains("CLUSTER BY a, b, c, d"));
657        assert!(!sql2.contains(", e"));
658    }
659
660    #[test]
661    fn is_meta_column_matches_only_the_three_cdc_columns() {
662        assert!(is_meta_column("__op") && is_meta_column("__pos") && is_meta_column("__seq"));
663        assert!(!is_meta_column("id") && !is_meta_column("__op_code"));
664    }
665
666    #[test]
667    fn count_csv_skips_header() {
668        assert_eq!(parse_count_csv("n\n42\n").unwrap(), 42);
669        assert_eq!(parse_count_csv("n\n0\n").unwrap(), 0);
670        assert!(parse_count_csv("n\n").is_err());
671    }
672
673    #[test]
674    fn clean_bq_output_drops_standalone_status_and_waiting_lines() {
675        // A bare "Waiting on" line (no status) AND a bare "Current status:" line
676        // (not part of a Waiting line) must BOTH be dropped — pins each `&&` in
677        // the filter (an `||` there would leak one of them into the message).
678        let raw = b"Waiting on bqjob_x\nCurrent status: RUNNING\nError: boom\n";
679        assert_eq!(clean_bq_output(raw), "Error: boom");
680    }
681
682    #[test]
683    fn augment_partition_limit_fires_only_on_partition_plus_signal() {
684        let aug = |m: &str| augment_partition_limit(anyhow::anyhow!("{m}")).to_string();
685        // partition + exactly one of {4000, quota, exceed} → augmented (pins each `||`).
686        assert!(aug("too many partitions, allowed 4000").contains("split the"));
687        assert!(aug("partition quota reached").contains("split the"));
688        assert!(aug("partition count will exceed the limit").contains("split the"));
689        // partition alone, or a signal alone → NOT augmented (pins the outer `&&`).
690        assert!(!aug("partition pruning is disabled").contains("split the"));
691        assert!(!aug("row quota 4000 reached").contains("split the"));
692    }
693
694    #[test]
695    fn partition_limit_error_is_augmented() {
696        let raw = anyhow::anyhow!("Too many partitions: cannot modify more than 4000 partitions");
697        let msg = augment_partition_limit(raw).to_string();
698        assert!(
699            msg.contains("split the"),
700            "expected the actionable hint: {msg}"
701        );
702    }
703
704    #[test]
705    fn job_labels_tag_managed_by_op_and_table() {
706        let flags = build_label_flags("recover", "Orders", Some("Run-7"));
707        let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
708        assert!(kv.iter().any(|s| *s == "managed_by:rivet"));
709        assert!(kv.iter().any(|s| *s == "rivet_op:recover"));
710        assert!(kv.iter().any(|s| *s == "rivet_table:orders")); // sanitized to lowercase
711        assert!(kv.iter().any(|s| *s == "rivet_run:run-7")); // sanitized to lowercase
712        // Each label value is preceded by a `--label` flag.
713        assert!(flags.iter().step_by(2).all(|s| s == "--label"));
714    }
715
716    #[test]
717    fn no_run_id_omits_the_rivet_run_label() {
718        let flags = build_label_flags("load", "orders", None);
719        let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
720        assert!(kv.iter().any(|s| *s == "rivet_table:orders"));
721        assert!(!kv.iter().any(|s| s.starts_with("rivet_run:")));
722    }
723
724    #[test]
725    fn fqtn_qualifies_project_dataset_table() {
726        let l = BigQueryLoader::new("proj", "ds");
727        assert_eq!(l.fqtn("orders"), "proj.ds.orders");
728    }
729
730    #[test]
731    fn sanitize_label_coerces_to_bq_charset() {
732        assert_eq!(sanitize_label("My.Table!"), "my_table_");
733        assert_eq!(sanitize_label(""), "unnamed");
734        assert_eq!(sanitize_label("ok-name_1"), "ok-name_1");
735        assert_eq!(sanitize_label(&"x".repeat(80)).len(), 63);
736    }
737
738    #[test]
739    fn clean_bq_output_keeps_real_error_drops_spinner() {
740        // Regression: bq prints the failure reason on STDOUT; stderr is just
741        // the spinner. run_bq must surface stdout so augment_partition_limit
742        // can see the quota text (live-caught: the reason was being dropped).
743        let stdout = b"Error in query string: Too many partitions produced by query, \
744                       allowed 4000, query produces at least 4200 partitions";
745        let cleaned = clean_bq_output(stdout);
746        assert!(cleaned.contains("Too many partitions") && cleaned.contains("4000"));
747        // The augment fires end-to-end on the cleaned stdout.
748        let augmented = augment_partition_limit(anyhow::anyhow!("{cleaned}")).to_string();
749        assert!(augmented.contains("split the"), "{augmented}");
750        // The stderr spinner collapses away.
751        let stderr = "Waiting on bqjob_x ... (0s) Current status: RUNNING\r\
752                      Waiting on bqjob_x ... (0s) Current status: DONE";
753        assert!(clean_bq_output(stderr.as_bytes()).is_empty());
754    }
755
756    /// THE foreign-table safety test. A table rivet did not create keeps its
757    /// previous owner's shape — `CREATE TABLE IF NOT EXISTS` is a no-op on it —
758    /// so the only way to add a column is ALTER. A replace would impose our
759    /// schema on the customer's history and destroy it.
760    #[test]
761    fn schema_reconciliation_adds_columns_and_never_replaces() {
762        let specs = [
763            spec("id", None, TargetStatus::Ok),
764            spec("_rivet_row_hash", None, TargetStatus::Ok),
765        ];
766        let sql = build_alter_add_columns_sql("p.d.t__changes", &specs).unwrap();
767        assert!(sql.starts_with("ALTER TABLE `p.d.t__changes`"), "{sql}");
768        // IF NOT EXISTS on every column: the statement runs on every load, and
769        // a load must not fail because a column it declares is already there.
770        assert_eq!(sql.matches("ADD COLUMN IF NOT EXISTS").count(), 2, "{sql}");
771        assert!(sql.contains("`_rivet_row_hash` X"), "{sql}");
772        for forbidden in ["REPLACE", "DROP", "CREATE", "TRUNCATE", "OVERWRITE"] {
773            assert!(
774                !sql.contains(forbidden),
775                "reconciliation must be additive only, found {forbidden}: {sql}"
776            );
777        }
778    }
779
780    /// Nothing to add ⇒ no statement, so the loader skips the round trip
781    /// instead of sending `ALTER TABLE t ;`.
782    #[test]
783    fn schema_reconciliation_emits_nothing_for_an_empty_spec_list() {
784        assert!(build_alter_add_columns_sql("p.d.t", &[]).is_none());
785    }
786
787    /// The changelog is only ever CREATEd IF NOT EXISTS and LOADed INTO —
788    /// never OVERWRITE. This pins the pairing: a pre-existing `__changes` table
789    /// must survive a load with its rows intact.
790    #[test]
791    fn changelog_sql_is_create_if_not_exists_plus_append_only() {
792        let create = build_create_changes_sql("p.d.t__changes", "  `id` INT64", &["id".into()]);
793        assert!(create.starts_with("CREATE TABLE IF NOT EXISTS"), "{create}");
794        let load =
795            build_load_data_sql("p.d.t__changes", false, "  `id` INT64", &None, &[], &uris());
796        assert!(load.starts_with("LOAD DATA INTO"), "{load}");
797        assert!(!load.contains("OVERWRITE"), "{load}");
798    }
799
800    #[test]
801    fn materialize_refuses_too_many_cluster_columns() {
802        // A >4-column CLUSTER BY is a below-the-seam adapter limit (BigQuery's),
803        // caught in `materialize` before any `bq` call. (Empty-URI and Fail-spec
804        // refusals are the driver's — see `load::tests`.)
805        let l = BigQueryLoader::new("p", "d").cluster_by(vec![
806            "a".into(),
807            "b".into(),
808            "c".into(),
809            "d".into(),
810            "e".into(),
811        ]);
812        let err = l
813            .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
814            .unwrap_err()
815            .to_string();
816        assert!(err.contains("clustering"), "{err}");
817    }
818
819    #[test]
820    fn materialize_refuses_a_non_identifier_cluster_column() {
821        // A clustering column splices raw into `CLUSTER BY <cols>`; a
822        // non-identifier name is an injection vector and must be refused in
823        // `materialize` before any `bq` call — the sibling of the table/column/pk
824        // gate for the BigQuery shape clause.
825        let l = BigQueryLoader::new("p", "d").cluster_by(vec!["id) FROM secrets; --".into()]);
826        let err = l
827            .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
828            .unwrap_err()
829            .to_string();
830        assert!(
831            err.contains("not a plain SQL identifier") && err.contains("CLUSTER BY"),
832            "{err}"
833        );
834    }
835
836    #[test]
837    fn hive_partition_value_parses_col_segment() {
838        assert_eq!(
839            hive_partition_value("gs://b/t/d=2023-01-01/part-0.parquet", "d").as_deref(),
840            Some("2023-01-01")
841        );
842        assert_eq!(
843            hive_partition_value("gs://b/t/created_at=2023-01-01/p.parquet", "created_at")
844                .as_deref(),
845            Some("2023-01-01")
846        );
847        assert!(hive_partition_value("gs://b/t/part-0.parquet", "d").is_none());
848    }
849
850    #[test]
851    fn is_bare_column_rejects_expressions() {
852        assert!(is_bare_column("d"));
853        assert!(is_bare_column("created_at"));
854        assert!(!is_bare_column("DATE(d)"));
855        assert!(!is_bare_column("DATE_TRUNC(d, MONTH)"));
856        assert!(!is_bare_column(""));
857    }
858
859    #[test]
860    fn hive_batches_split_by_distinct_partition_cap() {
861        // 5 distinct days (day 01-01 has 2 files), cap 2 → 3 batches.
862        let uris: Vec<String> = [
863            "gs://b/t/d=2023-01-01/a.parquet",
864            "gs://b/t/d=2023-01-01/b.parquet",
865            "gs://b/t/d=2023-01-02/a.parquet",
866            "gs://b/t/d=2023-01-03/a.parquet",
867            "gs://b/t/d=2023-01-04/a.parquet",
868            "gs://b/t/d=2023-01-05/a.parquet",
869        ]
870        .iter()
871        .map(|s| s.to_string())
872        .collect();
873        let batches = plan_hive_batches(&uris, "d", 2).unwrap();
874        assert_eq!(batches.len(), 3);
875        for b in &batches {
876            let mut days: Vec<_> = b
877                .iter()
878                .map(|u| hive_partition_value(u, "d").unwrap())
879                .collect();
880            days.sort();
881            days.dedup();
882            assert!(
883                days.len() <= 2,
884                "batch touches {} distinct days",
885                days.len()
886            );
887        }
888        // Files that share a day stay together; the union is the whole input.
889        assert_eq!(batches.iter().map(Vec::len).sum::<usize>(), uris.len());
890    }
891
892    #[test]
893    fn hive_batches_single_when_under_cap() {
894        let uris = vec![
895            "gs://b/t/d=2023-01-01/a.parquet".to_string(),
896            "gs://b/t/d=2023-01-02/a.parquet".to_string(),
897        ];
898        assert_eq!(plan_hive_batches(&uris, "d", 4000).unwrap().len(), 1);
899    }
900
901    #[test]
902    fn hive_batches_error_when_uri_lacks_segment() {
903        let uris = vec!["gs://b/t/no-hive/a.parquet".to_string()];
904        assert!(plan_hive_batches(&uris, "d", 2).is_err());
905    }
906
907    /// Live BigQuery load. Requires the `bq` CLI + ADC, a dataset, and a GCS
908    /// Parquet URI. NOT run offline; drive it with:
909    ///
910    ///   BIGQUERY_TEST_PROJECT=my-proj RIVET_BQ_TEST_DATASET=rivet_test \
911    ///   RIVET_BQ_TEST_PARQUET_URI=gs://bucket/orders/part-0.parquet \
912    ///   cargo test -- --ignored bigquery_live
913    #[test]
914    #[ignore = "live: needs bq CLI + ADC + a GCS Parquet fixture"]
915    fn bigquery_live_load_round_trips() {
916        // Soft-skip when the live BigQuery project isn't configured: CI sweeps
917        // `--ignored` (ci.yml) without warehouse creds, so a hard `.expect` here
918        // would fail the run. With the project set (a live/nightly box) it runs.
919        let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
920            eprintln!("skipping bigquery_live_load_round_trips: BIGQUERY_TEST_PROJECT unset");
921            return;
922        };
923        let dataset =
924            std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
925        let uri = std::env::var("RIVET_BQ_TEST_PARQUET_URI").expect(
926            "set RIVET_BQ_TEST_PARQUET_URI to a GCS Parquet object matching the specs below",
927        );
928
929        // A plain column (no cast) exercises the FREE LOAD DATA path.
930        let specs = vec![spec("id", None, TargetStatus::Ok)];
931
932        let loader = BigQueryLoader::new(project, dataset);
933        // Drive it through the real driver (no gate, no cleanup) — same path prod
934        // takes, exercising validate → materialize.
935        let report =
936            crate::load::run_load(&loader, "rivet_bq_live_test", &specs, &[uri], None, None)
937                .expect("live load should succeed");
938        assert!(
939            report.rows_loaded > 0,
940            "expected rows, got {}",
941            report.rows_loaded
942        );
943    }
944
945    /// Live BigQuery CDC round-trip: append a change-log Parquet into
946    /// `<table>__changes` and build the dedup view. Loading the **same** file
947    /// twice exercises the at-least-once path — `<table>__changes` doubles, but
948    /// the current-state view must be unchanged (duplicates lose the
949    /// `(__pos,__seq)` tiebreak). Soft delete: the view keeps one row per PK
950    /// including tombstones (`__is_deleted = true`), so `RIVET_BQ_CDC_EXPECTED_STATE`
951    /// is the distinct-PK count *including* deleted rows. Drive it with:
952    ///
953    ///   BIGQUERY_TEST_PROJECT=my-proj RIVET_BQ_TEST_DATASET=rivet_test \
954    ///   RIVET_BQ_CDC_PARQUET_URI=gs://bucket/orders_cdc/part-0.parquet \
955    ///   RIVET_BQ_CDC_PK=id RIVET_BQ_CDC_DATA_COLS=id:INT64,val:STRING \
956    ///   RIVET_BQ_CDC_EXPECTED_STATE=3 \
957    ///   cargo test -- --ignored bigquery_live_cdc
958    #[test]
959    #[ignore = "live: needs bq CLI + ADC + a CDC change-log Parquet fixture"]
960    fn bigquery_live_cdc_view_dedups_at_least_once() {
961        // Soft-skip when unconfigured — see bigquery_live_load_round_trips.
962        let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
963            eprintln!(
964                "skipping bigquery_live_cdc_view_dedups_at_least_once: BIGQUERY_TEST_PROJECT unset"
965            );
966            return;
967        };
968        let dataset =
969            std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
970        let uri = std::env::var("RIVET_BQ_CDC_PARQUET_URI")
971            .expect("set RIVET_BQ_CDC_PARQUET_URI to a CDC change-log Parquet object");
972        let pk = std::env::var("RIVET_BQ_CDC_PK").unwrap_or_else(|_| "id".to_string());
973        // The fixture's data columns as `name:TYPE,name:TYPE` (meta columns are
974        // prepended by the loader). Defaults to a minimal `id:INT64`.
975        let data_cols =
976            std::env::var("RIVET_BQ_CDC_DATA_COLS").unwrap_or_else(|_| "id:INT64".to_string());
977        let specs: Vec<TargetColumnSpec> = data_cols
978            .split(',')
979            .map(|c| {
980                let (name, ty) = c.split_once(':').expect("data col must be name:TYPE");
981                typed(name, ty)
982            })
983            .collect();
984        let expected_state: u64 = std::env::var("RIVET_BQ_CDC_EXPECTED_STATE")
985            .ok()
986            .and_then(|s| s.parse().ok())
987            .unwrap_or(0);
988
989        let table = "rivet_bq_live_cdc_test";
990        let pk_cols: Vec<String> = pk.split(',').map(str::to_string).collect();
991        let loader = BigQueryLoader::new(&project, &dataset);
992
993        // Load the same change log twice (at-least-once). No delta gate here —
994        // the fixture's row count is the operator's to assert externally.
995        crate::load::run_load_cdc(
996            &loader,
997            table,
998            &specs,
999            std::slice::from_ref(&uri),
1000            &pk_cols,
1001            crate::load::cdc::SourceEngine::MySql,
1002            None,
1003            None,
1004        )
1005        .expect("first CDC append + view build should succeed");
1006        let second = crate::load::run_load_cdc(
1007            &loader,
1008            table,
1009            &specs,
1010            &[uri],
1011            &pk_cols,
1012            crate::load::cdc::SourceEngine::MySql,
1013            None,
1014            None,
1015        )
1016        .expect("second CDC append (at-least-once) should succeed");
1017        assert!(second.rows_appended > 0, "second append added rows");
1018
1019        // The dedup VIEW must report the current state, independent of how many
1020        // times the log was appended.
1021        let state_rows = loader
1022            .count_rows(&second.view, table)
1023            .expect("counting the dedup view should succeed");
1024        if expected_state > 0 {
1025            assert_eq!(
1026                state_rows, expected_state,
1027                "the view must collapse duplicates to {expected_state} distinct-PK rows \
1028                 (incl tombstones), got {state_rows}"
1029            );
1030        }
1031    }
1032}