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