1use super::TargetLoader;
75use crate::types::target::TargetColumnSpec;
76use anyhow::{Context, Result, bail};
77use std::collections::HashMap;
78use std::process::{Command, Output};
79const MAX_CLUSTER_COLUMNS: usize = 4;
83
84const DEFAULT_MAX_PARTITIONS_PER_JOB: usize = 4000;
86
87#[derive(Debug, Clone)]
89pub struct BigQueryLoader {
90 pub project: String,
91 pub dataset: String,
92 pub partition_by: Option<String>,
95 pub cluster_by: Vec<String>,
97 pub run_id: Option<String>,
102 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 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 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 fn label_flags(&self, op: &str, table: &str) -> Vec<String> {
166 build_label_flags(op, table, self.run_id.as_deref())
167 }
168
169 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 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 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 for (i, batch) in self.plan_load_batches(uris).iter().enumerate() {
220 let sql = build_load_data_sql(
221 &target,
222 i == 0, &schema,
224 &self.partition_by,
225 &self.cluster_by,
226 batch,
227 );
228 self.run_sql(&sql, "load", table)?;
229 }
230 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 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 let create = build_create_changes_sql(&changes_fqtn, &schema, pk);
261 self.run_sql(&create, "create", &changes)?;
262
263 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
282fn is_meta_column(name: &str) -> bool {
286 matches!(name, "__op" | "__pos" | "__seq")
287}
288
289fn 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
302fn is_bare_column(c: &str) -> bool {
305 !c.is_empty() && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
306}
307
308fn 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
316fn 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 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
349fn 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
362fn 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
372fn 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
384fn 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 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
425fn 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
445fn 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
466fn 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
476fn 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
489fn 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 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 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 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 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 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")); assert!(kv.iter().any(|s| *s == "rivet_run:run-7")); 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 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 let augmented = augment_partition_limit(anyhow::anyhow!("{cleaned}")).to_string();
684 assert!(augmented.contains("split the"), "{augmented}");
685 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 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 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 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 #[test]
788 #[ignore = "live: needs bq CLI + ADC + a GCS Parquet fixture"]
789 fn bigquery_live_load_round_trips() {
790 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 let specs = vec![spec("id", None, TargetStatus::Ok)];
805
806 let loader = BigQueryLoader::new(project, dataset);
807 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 #[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 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 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 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 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}