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        // Count before / append (free LOAD DATA INTO) / count after — the delta
279        // is what THIS load added; the driver gates it against the manifest total.
280        let before = self.count_rows(&changes_fqtn, &changes)?;
281        let load = build_load_data_sql(&changes_fqtn, false, &schema, &None, &[], uris);
282        self.run_sql(&load, "load", &changes)?;
283        let after = self.count_rows(&changes_fqtn, &changes)?;
284        Ok(after.saturating_sub(before))
285    }
286
287    fn warehouse(&self) -> crate::load::cdc::Warehouse {
288        crate::load::cdc::Warehouse::BigQuery
289    }
290
291    fn create_view(&self, table: &str, view_sql: &str) -> Result<()> {
292        self.run_sql(view_sql, "view", table)?;
293        Ok(())
294    }
295}
296
297/// Whether a column name is one of rivet's CDC meta columns — filtered out of
298/// the data specs before the meta columns are prepended, so a schema can never
299/// declare `__op`/`__pos`/`__seq` twice.
300fn is_meta_column(name: &str) -> bool {
301    crate::load::cdc::is_meta_column(name)
302}
303
304/// `CREATE TABLE IF NOT EXISTS` for the change log, clustered on the PK (capped
305/// at BigQuery's 4 clustering columns). Idempotent — the log is created once and
306/// appended to on every CDC load.
307fn build_create_changes_sql(fqtn: &str, schema: &str, pk: &[String]) -> String {
308    let cluster_cols = pk
309        .iter()
310        .take(MAX_CLUSTER_COLUMNS)
311        .cloned()
312        .collect::<Vec<_>>()
313        .join(", ");
314    format!("CREATE TABLE IF NOT EXISTS `{fqtn}` (\n{schema}\n)\nCLUSTER BY {cluster_cols};")
315}
316
317/// Whether `c` is a bare column identifier (so it matches a Hive path key),
318/// not an expression like `DATE(x)` or `DATE_TRUNC(d, MONTH)`.
319fn is_bare_column(c: &str) -> bool {
320    !c.is_empty() && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
321}
322
323/// The Hive partition value for `column` in a URI path, e.g.
324/// `gs://b/t/d=2023-01-01/part-0.parquet` + `d` → `2023-01-01`.
325fn hive_partition_value(uri: &str, column: &str) -> Option<String> {
326    let needle = format!("{column}=");
327    uri.split('/')
328        .find_map(|seg| seg.strip_prefix(&needle).map(str::to_string))
329}
330
331/// Group `uris` so each batch holds at most `max` distinct Hive partition
332/// values of `column`. URIs sharing a value stay together. Errors if any URI
333/// lacks the `<column>=` segment (caller falls back to a single batch).
334fn plan_hive_batches(uris: &[String], column: &str, max: usize) -> Result<Vec<Vec<String>>> {
335    let pairs: Vec<(&String, String)> = uris
336        .iter()
337        .map(|u| {
338            hive_partition_value(u, column)
339                .map(|v| (u, v))
340                .ok_or_else(|| anyhow::anyhow!("uri has no `{column}=` Hive segment: {u}"))
341        })
342        .collect::<Result<_>>()?;
343
344    let mut values: Vec<&str> = pairs.iter().map(|(_, v)| v.as_str()).collect();
345    values.sort_unstable();
346    values.dedup();
347    if values.len() <= max {
348        return Ok(vec![uris.to_vec()]);
349    }
350
351    // Contiguous windows of `max` distinct (sorted) values → one batch each.
352    let batch_of: HashMap<&str, usize> = values
353        .iter()
354        .enumerate()
355        .map(|(i, v)| (*v, i / max))
356        .collect();
357    let mut batches: Vec<Vec<String>> = vec![Vec::new(); values.len().div_ceil(max)];
358    for (u, v) in &pairs {
359        batches[batch_of[v.as_str()]].push((*u).clone());
360    }
361    Ok(batches)
362}
363
364/// `PARTITION BY … / CLUSTER BY …` clauses (empty when unset). Both apply only
365/// at table creation, per BigQuery.
366fn table_shape_clauses(partition_by: &Option<String>, cluster_by: &[String]) -> String {
367    let mut s = String::new();
368    if let Some(expr) = partition_by {
369        s.push_str(&format!("\nPARTITION BY {expr}"));
370    }
371    if !cluster_by.is_empty() {
372        s.push_str(&format!("\nCLUSTER BY {}", cluster_by.join(", ")));
373    }
374    s
375}
376
377/// A `FROM FILES(...)` Parquet source list.
378///
379/// `enable_list_inference = true` collapses rivet's 3-level Parquet LIST
380/// (`col.list.item`) one level, so an array column loads as the declared
381/// `ARRAY<STRUCT<item T>>` (== REPEATED RECORD{item}) instead of empty. It is a
382/// no-op for non-list columns, so it is always safe to set.
383fn from_files(uris: &[String]) -> String {
384    let list = uris
385        .iter()
386        .map(|u| format!("    '{u}'"))
387        .collect::<Vec<_>>()
388        .join(",\n");
389    format!(
390        "FROM FILES (\n  format = 'PARQUET',\n  enable_list_inference = true,\n  uris = [\n{list}\n  ]\n)"
391    )
392}
393
394/// The BigQuery column schema declared inline in LOAD DATA, from each spec's
395/// native `target_type`. Declaring native types makes BigQuery coerce the
396/// Parquet on load — for FREE (a load job, not a query) — so JSON / DATETIME /
397/// TIME / NUMERIC / … land natively without a post-load CTAS. Verified live.
398fn build_schema(specs: &[TargetColumnSpec]) -> String {
399    specs
400        .iter()
401        .map(|s| format!("  {} {}", s.column_name, s.target_type))
402        .collect::<Vec<_>>()
403        .join(",\n")
404}
405
406/// A free `LOAD DATA` batch-load statement declaring the native `schema`, so
407/// BigQuery coerces the Parquet to native types on load.
408fn build_load_data_sql(
409    fqtn: &str,
410    overwrite: bool,
411    schema: &str,
412    partition_by: &Option<String>,
413    cluster_by: &[String],
414    uris: &[String],
415) -> String {
416    let kw = if overwrite { "OVERWRITE" } else { "INTO" };
417    let clauses = table_shape_clauses(partition_by, cluster_by);
418    format!(
419        "LOAD DATA {kw} `{fqtn}` (\n{schema}\n){clauses}\n{};",
420        from_files(uris)
421    )
422}
423
424fn query_args(sql: &str, labels: &[String]) -> Vec<String> {
425    // Labels are flags — they MUST precede the positional SQL string.
426    let mut a = vec![
427        "query".into(),
428        "--use_legacy_sql=false".into(),
429        "--format=none".into(),
430    ];
431    a.extend_from_slice(labels);
432    a.push(sql.into());
433    a
434}
435
436fn count_args(fqtn: &str, labels: &[String]) -> Vec<String> {
437    let mut a = vec![
438        "query".into(),
439        "--use_legacy_sql=false".into(),
440        "--format=csv".into(),
441    ];
442    a.extend_from_slice(labels);
443    a.push(format!("SELECT COUNT(*) AS n FROM `{fqtn}`"));
444    a
445}
446
447/// Build `--label k:v` args: the automatic `managed_by:rivet` /
448/// `rivet_op:<op>` / `rivet_table:<table>` labels, the `rivet_run:<id>` label
449/// when a run id is set, plus any `extra` (user) labels. These land in
450/// `INFORMATION_SCHEMA.JOBS.labels` and the billing export, so cost can be
451/// attributed per run and per table.
452fn build_label_flags(op: &str, table: &str, run_id: Option<&str>) -> Vec<String> {
453    let mut labels: Vec<(String, String)> = vec![
454        ("managed_by".into(), "rivet".into()),
455        ("rivet_op".into(), sanitize_label(op)),
456        ("rivet_table".into(), sanitize_label(table)),
457    ];
458    if let Some(id) = run_id {
459        labels.push(("rivet_run".into(), sanitize_label(id)));
460    }
461    labels
462        .into_iter()
463        .flat_map(|(k, v)| ["--label".to_string(), format!("{k}:{v}")])
464        .collect()
465}
466
467/// Coerce a string into BigQuery's label charset: lowercase `[a-z0-9_-]`, other
468/// characters become `_`, truncated to 63 chars. Empty maps to `unnamed`.
469fn sanitize_label(s: &str) -> String {
470    let mut out: String = s
471        .chars()
472        .map(|c| {
473            let c = c.to_ascii_lowercase();
474            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
475                c
476            } else {
477                '_'
478            }
479        })
480        .collect();
481    out.truncate(63);
482    if out.is_empty() {
483        "unnamed".clone_into(&mut out);
484    }
485    out
486}
487
488/// Parse a `bq query --format=csv` count result: a `n` header line then the
489/// value. Take the last line that parses as an integer.
490fn parse_count_csv(stdout: &str) -> Result<u64> {
491    stdout
492        .lines()
493        .rev()
494        .find_map(|l| l.trim().parse::<u64>().ok())
495        .context("could not parse a row count from bq output")
496}
497
498/// Normalize `bq` output for an error message: split the `\r`-driven progress
499/// spinner into lines, drop the "Waiting…/Current status:" noise, and join the
500/// rest — leaving the real error `bq` printed (e.g. the partition-quota text).
501fn clean_bq_output(bytes: &[u8]) -> String {
502    String::from_utf8_lossy(bytes)
503        .replace('\r', "\n")
504        .lines()
505        .map(str::trim)
506        .filter(|l| !l.is_empty() && !l.starts_with("Waiting on") && !l.contains("Current status:"))
507        .collect::<Vec<_>>()
508        .join(" ")
509}
510
511/// Turn BigQuery's partition-quota failure into an actionable error.
512fn augment_partition_limit(e: anyhow::Error) -> anyhow::Error {
513    let s = e.to_string().to_lowercase();
514    if s.contains("partition")
515        && (s.contains("4000") || s.contains("quota") || s.contains("exceed"))
516    {
517        return e.context(
518            "BigQuery caps a single load/query job at 4,000 modified partitions — split the \
519             Parquet URIs into batches whose partition span is <= 4,000 (e.g. load by date range)",
520        );
521    }
522    e
523}
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::types::target::TargetStatus;
528
529    fn spec(name: &str, cast: Option<&str>, status: TargetStatus) -> TargetColumnSpec {
530        TargetColumnSpec {
531            column_name: name.into(),
532            target_type: "X".into(),
533            autoload_type: "Y".into(),
534            status,
535            note: None,
536            cast_sql: cast.map(String::from),
537        }
538    }
539
540    fn uris() -> Vec<String> {
541        vec!["gs://b/a.parquet".into(), "gs://b/b.parquet".into()]
542    }
543
544    fn typed(name: &str, target_type: &str) -> TargetColumnSpec {
545        TargetColumnSpec {
546            column_name: name.into(),
547            target_type: target_type.into(),
548            autoload_type: "BYTES".into(),
549            status: TargetStatus::Ok,
550            note: None,
551            cast_sql: None,
552        }
553    }
554
555    #[test]
556    fn schema_declares_each_columns_native_target_type() {
557        let s = build_schema(&[
558            typed("id", "INT64"),
559            typed("json_col", "JSON"),
560            typed("dt_col", "DATETIME"),
561        ]);
562        assert!(s.contains("id INT64"));
563        assert!(s.contains("json_col JSON"));
564        assert!(s.contains("dt_col DATETIME"));
565    }
566
567    #[test]
568    fn load_data_declares_native_schema_and_is_a_free_batch_load() {
569        let schema = build_schema(&[typed("id", "INT64"), typed("json_col", "JSON")]);
570        let sql = build_load_data_sql("p.d.orders", true, &schema, &None, &[], &uris());
571        assert!(sql.starts_with("LOAD DATA OVERWRITE `p.d.orders` ("));
572        // Native types declared inline → BigQuery coerces on load, for free.
573        assert!(sql.contains("json_col JSON"));
574        assert!(sql.contains("format = 'PARQUET'"));
575        assert!(sql.contains("'gs://b/a.parquet'"));
576        assert!(!sql.contains("PARTITION BY"));
577    }
578
579    #[test]
580    fn load_data_append_uses_into() {
581        let schema = build_schema(&[typed("id", "INT64")]);
582        let sql = build_load_data_sql("p.d.orders", false, &schema, &None, &[], &uris());
583        assert!(sql.starts_with("LOAD DATA INTO `p.d.orders`"));
584    }
585
586    #[test]
587    fn load_data_emits_partition_and_cluster_when_configured() {
588        let schema = build_schema(&[typed("id", "INT64")]);
589        let sql = build_load_data_sql(
590            "p.d.orders",
591            true,
592            &schema,
593            &Some("DATE(created_at)".into()),
594            &["customer_id".into(), "region".into()],
595            &uris(),
596        );
597        assert!(sql.contains("PARTITION BY DATE(created_at)"));
598        assert!(sql.contains("CLUSTER BY customer_id, region"));
599    }
600
601    #[test]
602    fn create_changes_clusters_on_pk_capped_at_four_columns() {
603        let schema = build_schema(&[typed("__op", "STRING"), typed("id", "INT64")]);
604        let sql = build_create_changes_sql("p.d.orders__changes", &schema, &["id".into()]);
605        assert!(sql.starts_with("CREATE TABLE IF NOT EXISTS `p.d.orders__changes` ("));
606        assert!(sql.contains("CLUSTER BY id"));
607        // A >4-column PK is capped to BigQuery's clustering limit.
608        let wide: Vec<String> = ["a", "b", "c", "d", "e"]
609            .iter()
610            .map(|s| s.to_string())
611            .collect();
612        let sql2 = build_create_changes_sql("t", &schema, &wide);
613        assert!(sql2.contains("CLUSTER BY a, b, c, d"));
614        assert!(!sql2.contains(", e"));
615    }
616
617    #[test]
618    fn is_meta_column_matches_only_the_three_cdc_columns() {
619        assert!(is_meta_column("__op") && is_meta_column("__pos") && is_meta_column("__seq"));
620        assert!(!is_meta_column("id") && !is_meta_column("__op_code"));
621    }
622
623    #[test]
624    fn count_csv_skips_header() {
625        assert_eq!(parse_count_csv("n\n42\n").unwrap(), 42);
626        assert_eq!(parse_count_csv("n\n0\n").unwrap(), 0);
627        assert!(parse_count_csv("n\n").is_err());
628    }
629
630    #[test]
631    fn clean_bq_output_drops_standalone_status_and_waiting_lines() {
632        // A bare "Waiting on" line (no status) AND a bare "Current status:" line
633        // (not part of a Waiting line) must BOTH be dropped — pins each `&&` in
634        // the filter (an `||` there would leak one of them into the message).
635        let raw = b"Waiting on bqjob_x\nCurrent status: RUNNING\nError: boom\n";
636        assert_eq!(clean_bq_output(raw), "Error: boom");
637    }
638
639    #[test]
640    fn augment_partition_limit_fires_only_on_partition_plus_signal() {
641        let aug = |m: &str| augment_partition_limit(anyhow::anyhow!("{m}")).to_string();
642        // partition + exactly one of {4000, quota, exceed} → augmented (pins each `||`).
643        assert!(aug("too many partitions, allowed 4000").contains("split the"));
644        assert!(aug("partition quota reached").contains("split the"));
645        assert!(aug("partition count will exceed the limit").contains("split the"));
646        // partition alone, or a signal alone → NOT augmented (pins the outer `&&`).
647        assert!(!aug("partition pruning is disabled").contains("split the"));
648        assert!(!aug("row quota 4000 reached").contains("split the"));
649    }
650
651    #[test]
652    fn partition_limit_error_is_augmented() {
653        let raw = anyhow::anyhow!("Too many partitions: cannot modify more than 4000 partitions");
654        let msg = augment_partition_limit(raw).to_string();
655        assert!(
656            msg.contains("split the"),
657            "expected the actionable hint: {msg}"
658        );
659    }
660
661    #[test]
662    fn job_labels_tag_managed_by_op_and_table() {
663        let flags = build_label_flags("recover", "Orders", Some("Run-7"));
664        let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
665        assert!(kv.iter().any(|s| *s == "managed_by:rivet"));
666        assert!(kv.iter().any(|s| *s == "rivet_op:recover"));
667        assert!(kv.iter().any(|s| *s == "rivet_table:orders")); // sanitized to lowercase
668        assert!(kv.iter().any(|s| *s == "rivet_run:run-7")); // sanitized to lowercase
669        // Each label value is preceded by a `--label` flag.
670        assert!(flags.iter().step_by(2).all(|s| s == "--label"));
671    }
672
673    #[test]
674    fn no_run_id_omits_the_rivet_run_label() {
675        let flags = build_label_flags("load", "orders", None);
676        let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
677        assert!(kv.iter().any(|s| *s == "rivet_table:orders"));
678        assert!(!kv.iter().any(|s| s.starts_with("rivet_run:")));
679    }
680
681    #[test]
682    fn fqtn_qualifies_project_dataset_table() {
683        let l = BigQueryLoader::new("proj", "ds");
684        assert_eq!(l.fqtn("orders"), "proj.ds.orders");
685    }
686
687    #[test]
688    fn sanitize_label_coerces_to_bq_charset() {
689        assert_eq!(sanitize_label("My.Table!"), "my_table_");
690        assert_eq!(sanitize_label(""), "unnamed");
691        assert_eq!(sanitize_label("ok-name_1"), "ok-name_1");
692        assert_eq!(sanitize_label(&"x".repeat(80)).len(), 63);
693    }
694
695    #[test]
696    fn clean_bq_output_keeps_real_error_drops_spinner() {
697        // Regression: bq prints the failure reason on STDOUT; stderr is just
698        // the spinner. run_bq must surface stdout so augment_partition_limit
699        // can see the quota text (live-caught: the reason was being dropped).
700        let stdout = b"Error in query string: Too many partitions produced by query, \
701                       allowed 4000, query produces at least 4200 partitions";
702        let cleaned = clean_bq_output(stdout);
703        assert!(cleaned.contains("Too many partitions") && cleaned.contains("4000"));
704        // The augment fires end-to-end on the cleaned stdout.
705        let augmented = augment_partition_limit(anyhow::anyhow!("{cleaned}")).to_string();
706        assert!(augmented.contains("split the"), "{augmented}");
707        // The stderr spinner collapses away.
708        let stderr = "Waiting on bqjob_x ... (0s) Current status: RUNNING\r\
709                      Waiting on bqjob_x ... (0s) Current status: DONE";
710        assert!(clean_bq_output(stderr.as_bytes()).is_empty());
711    }
712
713    #[test]
714    fn materialize_refuses_too_many_cluster_columns() {
715        // A >4-column CLUSTER BY is a below-the-seam adapter limit (BigQuery's),
716        // caught in `materialize` before any `bq` call. (Empty-URI and Fail-spec
717        // refusals are the driver's — see `load::tests`.)
718        let l = BigQueryLoader::new("p", "d").cluster_by(vec![
719            "a".into(),
720            "b".into(),
721            "c".into(),
722            "d".into(),
723            "e".into(),
724        ]);
725        let err = l
726            .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
727            .unwrap_err()
728            .to_string();
729        assert!(err.contains("clustering"), "{err}");
730    }
731
732    #[test]
733    fn materialize_refuses_a_non_identifier_cluster_column() {
734        // A clustering column splices raw into `CLUSTER BY <cols>`; a
735        // non-identifier name is an injection vector and must be refused in
736        // `materialize` before any `bq` call — the sibling of the table/column/pk
737        // gate for the BigQuery shape clause.
738        let l = BigQueryLoader::new("p", "d").cluster_by(vec!["id) FROM secrets; --".into()]);
739        let err = l
740            .materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
741            .unwrap_err()
742            .to_string();
743        assert!(
744            err.contains("not a plain SQL identifier") && err.contains("CLUSTER BY"),
745            "{err}"
746        );
747    }
748
749    #[test]
750    fn hive_partition_value_parses_col_segment() {
751        assert_eq!(
752            hive_partition_value("gs://b/t/d=2023-01-01/part-0.parquet", "d").as_deref(),
753            Some("2023-01-01")
754        );
755        assert_eq!(
756            hive_partition_value("gs://b/t/created_at=2023-01-01/p.parquet", "created_at")
757                .as_deref(),
758            Some("2023-01-01")
759        );
760        assert!(hive_partition_value("gs://b/t/part-0.parquet", "d").is_none());
761    }
762
763    #[test]
764    fn is_bare_column_rejects_expressions() {
765        assert!(is_bare_column("d"));
766        assert!(is_bare_column("created_at"));
767        assert!(!is_bare_column("DATE(d)"));
768        assert!(!is_bare_column("DATE_TRUNC(d, MONTH)"));
769        assert!(!is_bare_column(""));
770    }
771
772    #[test]
773    fn hive_batches_split_by_distinct_partition_cap() {
774        // 5 distinct days (day 01-01 has 2 files), cap 2 → 3 batches.
775        let uris: Vec<String> = [
776            "gs://b/t/d=2023-01-01/a.parquet",
777            "gs://b/t/d=2023-01-01/b.parquet",
778            "gs://b/t/d=2023-01-02/a.parquet",
779            "gs://b/t/d=2023-01-03/a.parquet",
780            "gs://b/t/d=2023-01-04/a.parquet",
781            "gs://b/t/d=2023-01-05/a.parquet",
782        ]
783        .iter()
784        .map(|s| s.to_string())
785        .collect();
786        let batches = plan_hive_batches(&uris, "d", 2).unwrap();
787        assert_eq!(batches.len(), 3);
788        for b in &batches {
789            let mut days: Vec<_> = b
790                .iter()
791                .map(|u| hive_partition_value(u, "d").unwrap())
792                .collect();
793            days.sort();
794            days.dedup();
795            assert!(
796                days.len() <= 2,
797                "batch touches {} distinct days",
798                days.len()
799            );
800        }
801        // Files that share a day stay together; the union is the whole input.
802        assert_eq!(batches.iter().map(Vec::len).sum::<usize>(), uris.len());
803    }
804
805    #[test]
806    fn hive_batches_single_when_under_cap() {
807        let uris = vec![
808            "gs://b/t/d=2023-01-01/a.parquet".to_string(),
809            "gs://b/t/d=2023-01-02/a.parquet".to_string(),
810        ];
811        assert_eq!(plan_hive_batches(&uris, "d", 4000).unwrap().len(), 1);
812    }
813
814    #[test]
815    fn hive_batches_error_when_uri_lacks_segment() {
816        let uris = vec!["gs://b/t/no-hive/a.parquet".to_string()];
817        assert!(plan_hive_batches(&uris, "d", 2).is_err());
818    }
819
820    /// Live BigQuery load. Requires the `bq` CLI + ADC, a dataset, and a GCS
821    /// Parquet URI. NOT run offline; drive it with:
822    ///
823    ///   BIGQUERY_TEST_PROJECT=my-proj RIVET_BQ_TEST_DATASET=rivet_test \
824    ///   RIVET_BQ_TEST_PARQUET_URI=gs://bucket/orders/part-0.parquet \
825    ///   cargo test -- --ignored bigquery_live
826    #[test]
827    #[ignore = "live: needs bq CLI + ADC + a GCS Parquet fixture"]
828    fn bigquery_live_load_round_trips() {
829        // Soft-skip when the live BigQuery project isn't configured: CI sweeps
830        // `--ignored` (ci.yml) without warehouse creds, so a hard `.expect` here
831        // would fail the run. With the project set (a live/nightly box) it runs.
832        let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
833            eprintln!("skipping bigquery_live_load_round_trips: BIGQUERY_TEST_PROJECT unset");
834            return;
835        };
836        let dataset =
837            std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
838        let uri = std::env::var("RIVET_BQ_TEST_PARQUET_URI").expect(
839            "set RIVET_BQ_TEST_PARQUET_URI to a GCS Parquet object matching the specs below",
840        );
841
842        // A plain column (no cast) exercises the FREE LOAD DATA path.
843        let specs = vec![spec("id", None, TargetStatus::Ok)];
844
845        let loader = BigQueryLoader::new(project, dataset);
846        // Drive it through the real driver (no gate, no cleanup) — same path prod
847        // takes, exercising validate → materialize.
848        let report =
849            crate::load::run_load(&loader, "rivet_bq_live_test", &specs, &[uri], None, None)
850                .expect("live load should succeed");
851        assert!(
852            report.rows_loaded > 0,
853            "expected rows, got {}",
854            report.rows_loaded
855        );
856    }
857
858    /// Live BigQuery CDC round-trip: append a change-log Parquet into
859    /// `<table>__changes` and build the dedup view. Loading the **same** file
860    /// twice exercises the at-least-once path — `<table>__changes` doubles, but
861    /// the current-state view must be unchanged (duplicates lose the
862    /// `(__pos,__seq)` tiebreak). Soft delete: the view keeps one row per PK
863    /// including tombstones (`__is_deleted = true`), so `RIVET_BQ_CDC_EXPECTED_STATE`
864    /// is the distinct-PK count *including* deleted rows. Drive it with:
865    ///
866    ///   BIGQUERY_TEST_PROJECT=my-proj RIVET_BQ_TEST_DATASET=rivet_test \
867    ///   RIVET_BQ_CDC_PARQUET_URI=gs://bucket/orders_cdc/part-0.parquet \
868    ///   RIVET_BQ_CDC_PK=id RIVET_BQ_CDC_DATA_COLS=id:INT64,val:STRING \
869    ///   RIVET_BQ_CDC_EXPECTED_STATE=3 \
870    ///   cargo test -- --ignored bigquery_live_cdc
871    #[test]
872    #[ignore = "live: needs bq CLI + ADC + a CDC change-log Parquet fixture"]
873    fn bigquery_live_cdc_view_dedups_at_least_once() {
874        // Soft-skip when unconfigured — see bigquery_live_load_round_trips.
875        let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
876            eprintln!(
877                "skipping bigquery_live_cdc_view_dedups_at_least_once: BIGQUERY_TEST_PROJECT unset"
878            );
879            return;
880        };
881        let dataset =
882            std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
883        let uri = std::env::var("RIVET_BQ_CDC_PARQUET_URI")
884            .expect("set RIVET_BQ_CDC_PARQUET_URI to a CDC change-log Parquet object");
885        let pk = std::env::var("RIVET_BQ_CDC_PK").unwrap_or_else(|_| "id".to_string());
886        // The fixture's data columns as `name:TYPE,name:TYPE` (meta columns are
887        // prepended by the loader). Defaults to a minimal `id:INT64`.
888        let data_cols =
889            std::env::var("RIVET_BQ_CDC_DATA_COLS").unwrap_or_else(|_| "id:INT64".to_string());
890        let specs: Vec<TargetColumnSpec> = data_cols
891            .split(',')
892            .map(|c| {
893                let (name, ty) = c.split_once(':').expect("data col must be name:TYPE");
894                typed(name, ty)
895            })
896            .collect();
897        let expected_state: u64 = std::env::var("RIVET_BQ_CDC_EXPECTED_STATE")
898            .ok()
899            .and_then(|s| s.parse().ok())
900            .unwrap_or(0);
901
902        let table = "rivet_bq_live_cdc_test";
903        let pk_cols: Vec<String> = pk.split(',').map(str::to_string).collect();
904        let loader = BigQueryLoader::new(&project, &dataset);
905
906        // Load the same change log twice (at-least-once). No delta gate here —
907        // the fixture's row count is the operator's to assert externally.
908        crate::load::run_load_cdc(
909            &loader,
910            table,
911            &specs,
912            std::slice::from_ref(&uri),
913            &pk_cols,
914            crate::load::cdc::SourceEngine::MySql,
915            None,
916            None,
917        )
918        .expect("first CDC append + view build should succeed");
919        let second = crate::load::run_load_cdc(
920            &loader,
921            table,
922            &specs,
923            &[uri],
924            &pk_cols,
925            crate::load::cdc::SourceEngine::MySql,
926            None,
927            None,
928        )
929        .expect("second CDC append (at-least-once) should succeed");
930        assert!(second.rows_appended > 0, "second append added rows");
931
932        // The dedup VIEW must report the current state, independent of how many
933        // times the log was appended.
934        let state_rows = loader
935            .count_rows(&second.view, table)
936            .expect("counting the dedup view should succeed");
937        if expected_state > 0 {
938            assert_eq!(
939                state_rows, expected_state,
940                "the view must collapse duplicates to {expected_state} distinct-PK rows \
941                 (incl tombstones), got {state_rows}"
942            );
943        }
944    }
945}