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