use super::TargetLoader;
use crate::types::target::TargetColumnSpec;
use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::process::{Command, Output};
const MAX_CLUSTER_COLUMNS: usize = 4;
const DEFAULT_MAX_PARTITIONS_PER_JOB: usize = 4000;
#[derive(Debug, Clone)]
pub struct BigQueryLoader {
pub project: String,
pub dataset: String,
pub partition_by: Option<String>,
pub cluster_by: Vec<String>,
pub run_id: Option<String>,
pub max_partitions_per_job: usize,
}
impl BigQueryLoader {
pub fn new(project: impl Into<String>, dataset: impl Into<String>) -> Self {
Self {
project: project.into(),
dataset: dataset.into(),
partition_by: None,
cluster_by: Vec::new(),
run_id: None,
max_partitions_per_job: DEFAULT_MAX_PARTITIONS_PER_JOB,
}
}
pub fn partition_by(mut self, expr: impl Into<String>) -> Self {
self.partition_by = Some(expr.into());
self
}
pub fn run_id(mut self, id: impl Into<String>) -> Self {
self.run_id = Some(id.into());
self
}
pub fn cluster_by(mut self, columns: Vec<String>) -> Self {
self.cluster_by = columns;
self
}
fn run_bq(&self, args: &[String]) -> Result<Output> {
let out = Command::new("bq")
.arg(format!("--project_id={}", self.project))
.args(args)
.output()
.context("failed to run `bq` — is the Google Cloud SDK installed and on PATH?")?;
if !out.status.success() {
let detail = [clean_bq_output(&out.stdout), clean_bq_output(&out.stderr)]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" | ");
bail!(
"bq {} failed: {detail}",
args.first()
.map(String::as_str)
.unwrap_or("<no-subcommand>"),
);
}
Ok(out)
}
fn label_flags(&self, op: &str, table: &str) -> Vec<String> {
build_label_flags(op, table, self.run_id.as_deref())
}
fn run_sql(&self, sql: &str, op: &str, table: &str) -> Result<Output> {
self.run_bq(&query_args(sql, &self.label_flags(op, table)))
.map_err(augment_partition_limit)
}
fn count_rows(&self, fqtn: &str, table: &str) -> Result<u64> {
let out = self.run_bq(&count_args(fqtn, &self.label_flags("count", table)))?;
parse_count_csv(&String::from_utf8_lossy(&out.stdout))
}
fn plan_load_batches(&self, uris: &[String]) -> Vec<Vec<String>> {
match self.partition_by.as_deref() {
Some(col) if is_bare_column(col) => {
plan_hive_batches(uris, col, self.max_partitions_per_job)
.unwrap_or_else(|_| vec![uris.to_vec()])
}
_ => vec![uris.to_vec()],
}
}
}
impl TargetLoader for BigQueryLoader {
fn fqtn(&self, table: &str) -> String {
format!("{}.{}.{}", self.project, self.dataset, table)
}
fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64> {
if self.cluster_by.len() > MAX_CLUSTER_COLUMNS {
bail!(
"BigQuery allows at most {MAX_CLUSTER_COLUMNS} clustering columns, got {}",
self.cluster_by.len()
);
}
let target = self.fqtn(table);
let schema = build_schema(specs);
for (i, batch) in self.plan_load_batches(uris).iter().enumerate() {
let sql = build_load_data_sql(
&target,
i == 0, &schema,
&self.partition_by,
&self.cluster_by,
batch,
);
self.run_sql(&sql, "load", table)?;
}
self.count_rows(&target, table)
}
fn append_changelog(
&self,
table: &str,
specs: &[TargetColumnSpec],
uris: &[String],
pk: &[String],
) -> Result<u64> {
use crate::load::cdc::Warehouse;
let mut full = crate::load::cdc::meta_column_specs(Warehouse::BigQuery);
full.extend(
specs
.iter()
.filter(|s| !is_meta_column(&s.column_name))
.cloned(),
);
let schema = build_schema(&full);
let changes = format!("{table}__changes");
let changes_fqtn = self.fqtn(&changes);
let create = build_create_changes_sql(&changes_fqtn, &schema, pk);
self.run_sql(&create, "create", &changes)?;
let before = self.count_rows(&changes_fqtn, &changes)?;
let load = build_load_data_sql(&changes_fqtn, false, &schema, &None, &[], uris);
self.run_sql(&load, "load", &changes)?;
let after = self.count_rows(&changes_fqtn, &changes)?;
Ok(after.saturating_sub(before))
}
fn warehouse(&self) -> crate::load::cdc::Warehouse {
crate::load::cdc::Warehouse::BigQuery
}
fn create_view(&self, table: &str, view_sql: &str) -> Result<()> {
self.run_sql(view_sql, "view", table)?;
Ok(())
}
}
fn is_meta_column(name: &str) -> bool {
matches!(name, "__op" | "__pos" | "__seq")
}
fn build_create_changes_sql(fqtn: &str, schema: &str, pk: &[String]) -> String {
let cluster_cols = pk
.iter()
.take(MAX_CLUSTER_COLUMNS)
.cloned()
.collect::<Vec<_>>()
.join(", ");
format!("CREATE TABLE IF NOT EXISTS `{fqtn}` (\n{schema}\n)\nCLUSTER BY {cluster_cols};")
}
fn is_bare_column(c: &str) -> bool {
!c.is_empty() && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
fn hive_partition_value(uri: &str, column: &str) -> Option<String> {
let needle = format!("{column}=");
uri.split('/')
.find_map(|seg| seg.strip_prefix(&needle).map(str::to_string))
}
fn plan_hive_batches(uris: &[String], column: &str, max: usize) -> Result<Vec<Vec<String>>> {
let pairs: Vec<(&String, String)> = uris
.iter()
.map(|u| {
hive_partition_value(u, column)
.map(|v| (u, v))
.ok_or_else(|| anyhow::anyhow!("uri has no `{column}=` Hive segment: {u}"))
})
.collect::<Result<_>>()?;
let mut values: Vec<&str> = pairs.iter().map(|(_, v)| v.as_str()).collect();
values.sort_unstable();
values.dedup();
if values.len() <= max {
return Ok(vec![uris.to_vec()]);
}
let batch_of: HashMap<&str, usize> = values
.iter()
.enumerate()
.map(|(i, v)| (*v, i / max))
.collect();
let mut batches: Vec<Vec<String>> = vec![Vec::new(); values.len().div_ceil(max)];
for (u, v) in &pairs {
batches[batch_of[v.as_str()]].push((*u).clone());
}
Ok(batches)
}
fn table_shape_clauses(partition_by: &Option<String>, cluster_by: &[String]) -> String {
let mut s = String::new();
if let Some(expr) = partition_by {
s.push_str(&format!("\nPARTITION BY {expr}"));
}
if !cluster_by.is_empty() {
s.push_str(&format!("\nCLUSTER BY {}", cluster_by.join(", ")));
}
s
}
fn from_files(uris: &[String]) -> String {
let list = uris
.iter()
.map(|u| format!(" '{u}'"))
.collect::<Vec<_>>()
.join(",\n");
format!("FROM FILES (\n format = 'PARQUET',\n uris = [\n{list}\n ]\n)")
}
fn build_schema(specs: &[TargetColumnSpec]) -> String {
specs
.iter()
.map(|s| format!(" {} {}", s.column_name, s.target_type))
.collect::<Vec<_>>()
.join(",\n")
}
fn build_load_data_sql(
fqtn: &str,
overwrite: bool,
schema: &str,
partition_by: &Option<String>,
cluster_by: &[String],
uris: &[String],
) -> String {
let kw = if overwrite { "OVERWRITE" } else { "INTO" };
let clauses = table_shape_clauses(partition_by, cluster_by);
format!(
"LOAD DATA {kw} `{fqtn}` (\n{schema}\n){clauses}\n{};",
from_files(uris)
)
}
fn query_args(sql: &str, labels: &[String]) -> Vec<String> {
let mut a = vec![
"query".into(),
"--use_legacy_sql=false".into(),
"--format=none".into(),
];
a.extend_from_slice(labels);
a.push(sql.into());
a
}
fn count_args(fqtn: &str, labels: &[String]) -> Vec<String> {
let mut a = vec![
"query".into(),
"--use_legacy_sql=false".into(),
"--format=csv".into(),
];
a.extend_from_slice(labels);
a.push(format!("SELECT COUNT(*) AS n FROM `{fqtn}`"));
a
}
fn build_label_flags(op: &str, table: &str, run_id: Option<&str>) -> Vec<String> {
let mut labels: Vec<(String, String)> = vec![
("managed_by".into(), "rivet".into()),
("rivet_op".into(), sanitize_label(op)),
("rivet_table".into(), sanitize_label(table)),
];
if let Some(id) = run_id {
labels.push(("rivet_run".into(), sanitize_label(id)));
}
labels
.into_iter()
.flat_map(|(k, v)| ["--label".to_string(), format!("{k}:{v}")])
.collect()
}
fn sanitize_label(s: &str) -> String {
let mut out: String = s
.chars()
.map(|c| {
let c = c.to_ascii_lowercase();
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
out.truncate(63);
if out.is_empty() {
"unnamed".clone_into(&mut out);
}
out
}
fn parse_count_csv(stdout: &str) -> Result<u64> {
stdout
.lines()
.rev()
.find_map(|l| l.trim().parse::<u64>().ok())
.context("could not parse a row count from bq output")
}
fn clean_bq_output(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.replace('\r', "\n")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("Waiting on") && !l.contains("Current status:"))
.collect::<Vec<_>>()
.join(" ")
}
fn augment_partition_limit(e: anyhow::Error) -> anyhow::Error {
let s = e.to_string().to_lowercase();
if s.contains("partition")
&& (s.contains("4000") || s.contains("quota") || s.contains("exceed"))
{
return e.context(
"BigQuery caps a single load/query job at 4,000 modified partitions — split the \
Parquet URIs into batches whose partition span is <= 4,000 (e.g. load by date range)",
);
}
e
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::target::TargetStatus;
fn spec(name: &str, cast: Option<&str>, status: TargetStatus) -> TargetColumnSpec {
TargetColumnSpec {
column_name: name.into(),
target_type: "X".into(),
autoload_type: "Y".into(),
status,
note: None,
cast_sql: cast.map(String::from),
}
}
fn uris() -> Vec<String> {
vec!["gs://b/a.parquet".into(), "gs://b/b.parquet".into()]
}
fn typed(name: &str, target_type: &str) -> TargetColumnSpec {
TargetColumnSpec {
column_name: name.into(),
target_type: target_type.into(),
autoload_type: "BYTES".into(),
status: TargetStatus::Ok,
note: None,
cast_sql: None,
}
}
#[test]
fn schema_declares_each_columns_native_target_type() {
let s = build_schema(&[
typed("id", "INT64"),
typed("json_col", "JSON"),
typed("dt_col", "DATETIME"),
]);
assert!(s.contains("id INT64"));
assert!(s.contains("json_col JSON"));
assert!(s.contains("dt_col DATETIME"));
}
#[test]
fn load_data_declares_native_schema_and_is_a_free_batch_load() {
let schema = build_schema(&[typed("id", "INT64"), typed("json_col", "JSON")]);
let sql = build_load_data_sql("p.d.orders", true, &schema, &None, &[], &uris());
assert!(sql.starts_with("LOAD DATA OVERWRITE `p.d.orders` ("));
assert!(sql.contains("json_col JSON"));
assert!(sql.contains("format = 'PARQUET'"));
assert!(sql.contains("'gs://b/a.parquet'"));
assert!(!sql.contains("PARTITION BY"));
}
#[test]
fn load_data_append_uses_into() {
let schema = build_schema(&[typed("id", "INT64")]);
let sql = build_load_data_sql("p.d.orders", false, &schema, &None, &[], &uris());
assert!(sql.starts_with("LOAD DATA INTO `p.d.orders`"));
}
#[test]
fn load_data_emits_partition_and_cluster_when_configured() {
let schema = build_schema(&[typed("id", "INT64")]);
let sql = build_load_data_sql(
"p.d.orders",
true,
&schema,
&Some("DATE(created_at)".into()),
&["customer_id".into(), "region".into()],
&uris(),
);
assert!(sql.contains("PARTITION BY DATE(created_at)"));
assert!(sql.contains("CLUSTER BY customer_id, region"));
}
#[test]
fn create_changes_clusters_on_pk_capped_at_four_columns() {
let schema = build_schema(&[typed("__op", "STRING"), typed("id", "INT64")]);
let sql = build_create_changes_sql("p.d.orders__changes", &schema, &["id".into()]);
assert!(sql.starts_with("CREATE TABLE IF NOT EXISTS `p.d.orders__changes` ("));
assert!(sql.contains("CLUSTER BY id"));
let wide: Vec<String> = ["a", "b", "c", "d", "e"]
.iter()
.map(|s| s.to_string())
.collect();
let sql2 = build_create_changes_sql("t", &schema, &wide);
assert!(sql2.contains("CLUSTER BY a, b, c, d"));
assert!(!sql2.contains(", e"));
}
#[test]
fn is_meta_column_matches_only_the_three_cdc_columns() {
assert!(is_meta_column("__op") && is_meta_column("__pos") && is_meta_column("__seq"));
assert!(!is_meta_column("id") && !is_meta_column("__op_code"));
}
#[test]
fn count_csv_skips_header() {
assert_eq!(parse_count_csv("n\n42\n").unwrap(), 42);
assert_eq!(parse_count_csv("n\n0\n").unwrap(), 0);
assert!(parse_count_csv("n\n").is_err());
}
#[test]
fn clean_bq_output_drops_standalone_status_and_waiting_lines() {
let raw = b"Waiting on bqjob_x\nCurrent status: RUNNING\nError: boom\n";
assert_eq!(clean_bq_output(raw), "Error: boom");
}
#[test]
fn augment_partition_limit_fires_only_on_partition_plus_signal() {
let aug = |m: &str| augment_partition_limit(anyhow::anyhow!("{m}")).to_string();
assert!(aug("too many partitions, allowed 4000").contains("split the"));
assert!(aug("partition quota reached").contains("split the"));
assert!(aug("partition count will exceed the limit").contains("split the"));
assert!(!aug("partition pruning is disabled").contains("split the"));
assert!(!aug("row quota 4000 reached").contains("split the"));
}
#[test]
fn partition_limit_error_is_augmented() {
let raw = anyhow::anyhow!("Too many partitions: cannot modify more than 4000 partitions");
let msg = augment_partition_limit(raw).to_string();
assert!(
msg.contains("split the"),
"expected the actionable hint: {msg}"
);
}
#[test]
fn job_labels_tag_managed_by_op_and_table() {
let flags = build_label_flags("recover", "Orders", Some("Run-7"));
let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
assert!(kv.iter().any(|s| *s == "managed_by:rivet"));
assert!(kv.iter().any(|s| *s == "rivet_op:recover"));
assert!(kv.iter().any(|s| *s == "rivet_table:orders")); assert!(kv.iter().any(|s| *s == "rivet_run:run-7")); assert!(flags.iter().step_by(2).all(|s| s == "--label"));
}
#[test]
fn no_run_id_omits_the_rivet_run_label() {
let flags = build_label_flags("load", "orders", None);
let kv: Vec<&String> = flags.iter().skip(1).step_by(2).collect();
assert!(kv.iter().any(|s| *s == "rivet_table:orders"));
assert!(!kv.iter().any(|s| s.starts_with("rivet_run:")));
}
#[test]
fn fqtn_qualifies_project_dataset_table() {
let l = BigQueryLoader::new("proj", "ds");
assert_eq!(l.fqtn("orders"), "proj.ds.orders");
}
#[test]
fn sanitize_label_coerces_to_bq_charset() {
assert_eq!(sanitize_label("My.Table!"), "my_table_");
assert_eq!(sanitize_label(""), "unnamed");
assert_eq!(sanitize_label("ok-name_1"), "ok-name_1");
assert_eq!(sanitize_label(&"x".repeat(80)).len(), 63);
}
#[test]
fn clean_bq_output_keeps_real_error_drops_spinner() {
let stdout = b"Error in query string: Too many partitions produced by query, \
allowed 4000, query produces at least 4200 partitions";
let cleaned = clean_bq_output(stdout);
assert!(cleaned.contains("Too many partitions") && cleaned.contains("4000"));
let augmented = augment_partition_limit(anyhow::anyhow!("{cleaned}")).to_string();
assert!(augmented.contains("split the"), "{augmented}");
let stderr = "Waiting on bqjob_x ... (0s) Current status: RUNNING\r\
Waiting on bqjob_x ... (0s) Current status: DONE";
assert!(clean_bq_output(stderr.as_bytes()).is_empty());
}
#[test]
fn materialize_refuses_too_many_cluster_columns() {
let l = BigQueryLoader::new("p", "d").cluster_by(vec![
"a".into(),
"b".into(),
"c".into(),
"d".into(),
"e".into(),
]);
let err = l
.materialize("t", &[spec("id", None, TargetStatus::Ok)], &uris())
.unwrap_err()
.to_string();
assert!(err.contains("clustering"), "{err}");
}
#[test]
fn hive_partition_value_parses_col_segment() {
assert_eq!(
hive_partition_value("gs://b/t/d=2023-01-01/part-0.parquet", "d").as_deref(),
Some("2023-01-01")
);
assert_eq!(
hive_partition_value("gs://b/t/created_at=2023-01-01/p.parquet", "created_at")
.as_deref(),
Some("2023-01-01")
);
assert!(hive_partition_value("gs://b/t/part-0.parquet", "d").is_none());
}
#[test]
fn is_bare_column_rejects_expressions() {
assert!(is_bare_column("d"));
assert!(is_bare_column("created_at"));
assert!(!is_bare_column("DATE(d)"));
assert!(!is_bare_column("DATE_TRUNC(d, MONTH)"));
assert!(!is_bare_column(""));
}
#[test]
fn hive_batches_split_by_distinct_partition_cap() {
let uris: Vec<String> = [
"gs://b/t/d=2023-01-01/a.parquet",
"gs://b/t/d=2023-01-01/b.parquet",
"gs://b/t/d=2023-01-02/a.parquet",
"gs://b/t/d=2023-01-03/a.parquet",
"gs://b/t/d=2023-01-04/a.parquet",
"gs://b/t/d=2023-01-05/a.parquet",
]
.iter()
.map(|s| s.to_string())
.collect();
let batches = plan_hive_batches(&uris, "d", 2).unwrap();
assert_eq!(batches.len(), 3);
for b in &batches {
let mut days: Vec<_> = b
.iter()
.map(|u| hive_partition_value(u, "d").unwrap())
.collect();
days.sort();
days.dedup();
assert!(
days.len() <= 2,
"batch touches {} distinct days",
days.len()
);
}
assert_eq!(batches.iter().map(Vec::len).sum::<usize>(), uris.len());
}
#[test]
fn hive_batches_single_when_under_cap() {
let uris = vec![
"gs://b/t/d=2023-01-01/a.parquet".to_string(),
"gs://b/t/d=2023-01-02/a.parquet".to_string(),
];
assert_eq!(plan_hive_batches(&uris, "d", 4000).unwrap().len(), 1);
}
#[test]
fn hive_batches_error_when_uri_lacks_segment() {
let uris = vec!["gs://b/t/no-hive/a.parquet".to_string()];
assert!(plan_hive_batches(&uris, "d", 2).is_err());
}
#[test]
#[ignore = "live: needs bq CLI + ADC + a GCS Parquet fixture"]
fn bigquery_live_load_round_trips() {
let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
eprintln!("skipping bigquery_live_load_round_trips: BIGQUERY_TEST_PROJECT unset");
return;
};
let dataset =
std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
let uri = std::env::var("RIVET_BQ_TEST_PARQUET_URI").expect(
"set RIVET_BQ_TEST_PARQUET_URI to a GCS Parquet object matching the specs below",
);
let specs = vec![spec("id", None, TargetStatus::Ok)];
let loader = BigQueryLoader::new(project, dataset);
let report =
crate::load::run_load(&loader, "rivet_bq_live_test", &specs, &[uri], None, None)
.expect("live load should succeed");
assert!(
report.rows_loaded > 0,
"expected rows, got {}",
report.rows_loaded
);
}
#[test]
#[ignore = "live: needs bq CLI + ADC + a CDC change-log Parquet fixture"]
fn bigquery_live_cdc_view_dedups_at_least_once() {
let Ok(project) = std::env::var("BIGQUERY_TEST_PROJECT") else {
eprintln!(
"skipping bigquery_live_cdc_view_dedups_at_least_once: BIGQUERY_TEST_PROJECT unset"
);
return;
};
let dataset =
std::env::var("RIVET_BQ_TEST_DATASET").unwrap_or_else(|_| "rivet_test".to_string());
let uri = std::env::var("RIVET_BQ_CDC_PARQUET_URI")
.expect("set RIVET_BQ_CDC_PARQUET_URI to a CDC change-log Parquet object");
let pk = std::env::var("RIVET_BQ_CDC_PK").unwrap_or_else(|_| "id".to_string());
let data_cols =
std::env::var("RIVET_BQ_CDC_DATA_COLS").unwrap_or_else(|_| "id:INT64".to_string());
let specs: Vec<TargetColumnSpec> = data_cols
.split(',')
.map(|c| {
let (name, ty) = c.split_once(':').expect("data col must be name:TYPE");
typed(name, ty)
})
.collect();
let expected_state: u64 = std::env::var("RIVET_BQ_CDC_EXPECTED_STATE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let table = "rivet_bq_live_cdc_test";
let pk_cols: Vec<String> = pk.split(',').map(str::to_string).collect();
let loader = BigQueryLoader::new(&project, &dataset);
crate::load::run_load_cdc(
&loader,
table,
&specs,
std::slice::from_ref(&uri),
&pk_cols,
crate::load::cdc::SourceEngine::MySql,
None,
None,
)
.expect("first CDC append + view build should succeed");
let second = crate::load::run_load_cdc(
&loader,
table,
&specs,
&[uri],
&pk_cols,
crate::load::cdc::SourceEngine::MySql,
None,
None,
)
.expect("second CDC append (at-least-once) should succeed");
assert!(second.rows_appended > 0, "second append added rows");
let state_rows = loader
.count_rows(&second.view, table)
.expect("counting the dedup view should succeed");
if expected_state > 0 {
assert_eq!(
state_rows, expected_state,
"the view must collapse duplicates to {expected_state} distinct-PK rows \
(incl tombstones), got {state_rows}"
);
}
}
}