1use crate::destination::gcs::GcsStore;
13use crate::types::target::{TargetColumnSpec, TargetStatus};
14use anyhow::{Context, Result, bail};
15
16mod bigquery;
17pub mod cdc;
18pub mod plan;
19pub mod reconcile;
20mod snowflake;
21
22pub use bigquery::BigQueryLoader;
23pub use snowflake::SnowflakeLoader;
24
25#[derive(Debug, Clone)]
27pub struct LoadReport {
28 pub rows_loaded: u64,
29 pub target_table: String,
30 pub source_cleaned: bool,
32}
33
34#[derive(Debug, Clone)]
37pub struct CdcLoadReport {
38 pub rows_appended: u64,
39 pub changes_table: String,
40 pub view: String,
41 pub source_cleaned: bool,
45}
46
47pub trait TargetLoader {
55 fn fqtn(&self, table: &str) -> String;
58
59 fn materialize(&self, table: &str, specs: &[TargetColumnSpec], uris: &[String]) -> Result<u64>;
62
63 fn append_changelog(
67 &self,
68 table: &str,
69 specs: &[TargetColumnSpec],
70 uris: &[String],
71 pk: &[String],
72 ) -> Result<u64>;
73
74 fn warehouse(&self) -> cdc::Warehouse;
78
79 fn create_view(&self, table: &str, view_sql: &str) -> Result<()>;
84}
85
86fn validate_specs(table: &str, specs: &[TargetColumnSpec]) -> Result<()> {
89 if specs.is_empty() {
90 bail!("no column specs for `{table}` — nothing to build a schema from");
91 }
92 let failed: Vec<&str> = specs
93 .iter()
94 .filter(|s| s.status == TargetStatus::Fail)
95 .map(|s| s.column_name.as_str())
96 .collect();
97 if !failed.is_empty() {
98 bail!(
99 "cannot load `{table}`: {} column(s) do not map to the warehouse: {}",
100 failed.len(),
101 failed.join(", ")
102 );
103 }
104 Ok(())
105}
106
107fn maybe_cleanup(cleanup: Option<(&GcsStore, &str)>) -> bool {
112 match cleanup {
113 Some((store, prefix)) => match delete_under(store, prefix) {
114 Ok(()) => true,
115 Err(e) => {
116 eprintln!("warning: source cleanup failed (data is safely loaded): {e:#}");
117 false
118 }
119 },
120 None => false,
121 }
122}
123
124#[allow(private_interfaces)]
133pub fn run_load(
134 loader: &dyn TargetLoader,
135 table: &str,
136 specs: &[TargetColumnSpec],
137 uris: &[String],
138 expected_rows: Option<u64>,
139 cleanup: Option<(&GcsStore, &str)>,
140) -> Result<LoadReport> {
141 if uris.is_empty() {
142 bail!("no Parquet URIs to load into `{table}`");
143 }
144 validate_specs(table, specs)?;
145
146 let rows_loaded = loader.materialize(table, specs, uris)?;
147
148 if let Some(expected) = expected_rows
149 && rows_loaded != expected
150 {
151 bail!(
152 "count validation failed for `{}`: loaded {rows_loaded} rows, expected {expected} — \
153 NOT cleaning up source; investigate before re-running",
154 loader.fqtn(table)
155 );
156 }
157
158 let source_cleaned = maybe_cleanup(cleanup);
159 Ok(LoadReport {
160 rows_loaded,
161 target_table: loader.fqtn(table),
162 source_cleaned,
163 })
164}
165
166#[allow(clippy::too_many_arguments, private_interfaces)]
174fn append_and_view(
180 loader: &dyn TargetLoader,
181 table: &str,
182 specs: &[TargetColumnSpec],
183 uris: &[String],
184 pk: &[String],
185 expected_delta: Option<u64>,
186 cleanup: Option<(&GcsStore, &str)>,
187 label: &str,
188 build_view: impl FnOnce(&dyn TargetLoader) -> Result<()>,
189) -> Result<CdcLoadReport> {
190 if uris.is_empty() {
191 bail!("no Parquet URIs to append into `{table}__changes`");
192 }
193 if pk.is_empty() {
194 bail!("{label} load of `{table}` needs a primary key for the dedup view (pass --pk)");
195 }
196 validate_specs(&format!("{table}__changes"), specs)?;
197
198 let rows_appended = loader.append_changelog(table, specs, uris, pk)?;
199
200 if let Some(expected) = expected_delta
201 && rows_appended != expected
202 {
203 bail!(
204 "{label} count validation failed for `{}__changes`: appended {rows_appended} rows, \
205 expected {expected} from the run manifests — investigate before trusting the view",
206 table
207 );
208 }
209
210 build_view(loader)?;
211 let source_cleaned = maybe_cleanup(cleanup);
217
218 Ok(CdcLoadReport {
219 rows_appended,
220 changes_table: loader.fqtn(&format!("{table}__changes")),
221 view: loader.fqtn(table),
222 source_cleaned,
223 })
224}
225
226#[allow(clippy::too_many_arguments, private_interfaces)]
227pub fn run_load_cdc(
228 loader: &dyn TargetLoader,
229 table: &str,
230 specs: &[TargetColumnSpec],
231 uris: &[String],
232 pk: &[String],
233 engine: cdc::SourceEngine,
234 expected_delta: Option<u64>,
235 cleanup: Option<(&GcsStore, &str)>,
236) -> Result<CdcLoadReport> {
237 append_and_view(
238 loader,
239 table,
240 specs,
241 uris,
242 pk,
243 expected_delta,
244 cleanup,
245 "CDC",
246 |l| {
247 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
248 let sql = cdc::dedup_view_sql(
249 l.warehouse(),
250 &l.fqtn(table),
251 &l.fqtn(&format!("{table}__changes")),
252 &pk_refs,
253 engine,
254 );
255 l.create_view(table, &sql)
256 },
257 )
258}
259
260#[allow(clippy::too_many_arguments, private_interfaces)]
269pub fn run_load_incremental(
270 loader: &dyn TargetLoader,
271 table: &str,
272 specs: &[TargetColumnSpec],
273 uris: &[String],
274 pk: &[String],
275 cursor_column: &str,
276 expected_delta: Option<u64>,
277 cleanup: Option<(&GcsStore, &str)>,
278) -> Result<CdcLoadReport> {
279 if cursor_column.is_empty() {
281 bail!(
282 "incremental load of `{table}` needs a cursor column (the export's `cursor_column:`) \
283 for the dedup view's latest-per-PK ordering"
284 );
285 }
286 if !specs.iter().any(|s| s.column_name == cursor_column) {
293 let cols: Vec<&str> = specs.iter().map(|s| s.column_name.as_str()).collect();
294 bail!(
295 "incremental load of `{table}`: cursor_column `{cursor_column}` is not one of the \
296 exported columns [{}] — add it to the export's SELECT so the dedup view can order \
297 the change log by it",
298 cols.join(", ")
299 );
300 }
301 append_and_view(
302 loader,
303 table,
304 specs,
305 uris,
306 pk,
307 expected_delta,
308 cleanup,
309 "incremental",
310 |l| {
311 let pk_refs: Vec<&str> = pk.iter().map(String::as_str).collect();
312 let sql = cdc::inc_dedup_view_sql(
313 l.warehouse(),
314 &l.fqtn(table),
315 &l.fqtn(&format!("{table}__changes")),
316 &pk_refs,
317 cursor_column,
318 );
319 l.create_view(table, &sql)
320 },
321 )
322}
323
324pub(crate) fn split_gs_uri(uri: &str) -> Result<(&str, &str)> {
327 uri.strip_prefix("gs://")
328 .and_then(|rest| rest.split_once('/'))
329 .with_context(|| format!("not a `gs://bucket/path` URI: {uri}"))
330}
331
332pub(crate) fn delete_under(store: &GcsStore, gs_prefix: &str) -> Result<()> {
338 let (_, rel) = split_gs_uri(gs_prefix)?;
339 store
340 .remove_all(rel)
341 .with_context(|| format!("source cleanup (recursive delete of {gs_prefix}) failed"))
342}
343
344#[allow(private_interfaces)]
353pub fn open_store(dest: &crate::config::DestinationConfig) -> Result<GcsStore> {
354 GcsStore::new(dest)
355}
356
357pub fn build_loader(plan: &plan::LoadPlan, run_id: &str) -> Box<dyn TargetLoader> {
362 use plan::LoadTarget;
363 let load = &plan.load;
364 match &load.target {
365 LoadTarget::Bigquery { project, dataset } => Box::new(build_bigquery_loader(
366 project,
367 dataset,
368 plan.partition_by.as_deref(),
369 &load.cluster_by,
370 run_id,
371 )),
372 LoadTarget::Snowflake {
373 connection,
374 warehouse,
375 database,
376 schema,
377 storage_integration,
378 } => {
379 let mut l = SnowflakeLoader::new(connection.clone());
380 l.warehouse = warehouse.clone();
381 l.database = database.clone();
382 l.schema = schema.clone();
383 l.storage_integration = storage_integration.clone();
384 l.cluster_by = load.cluster_by.clone();
385 l.run_id = Some(run_id.to_string());
386 l.gcs_url = plan.gcs_prefix.replacen("gs://", "gcs://", 1);
388 l.private_key_path = std::env::var("RIVET_SNOWFLAKE_KEY").ok();
390 Box::new(l)
391 }
392 }
393}
394
395fn build_bigquery_loader(
401 project: &str,
402 dataset: &str,
403 partition_by: Option<&str>,
404 cluster_by: &[String],
405 run_id: &str,
406) -> BigQueryLoader {
407 let mut l = BigQueryLoader::new(project, dataset).run_id(run_id);
408 if let Some(part) = partition_by {
409 l = l.partition_by(part);
410 }
411 if !cluster_by.is_empty() {
412 l = l.cluster_by(cluster_by.to_vec());
413 }
414 l
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420 use std::cell::RefCell;
421
422 #[derive(Default)]
425 struct FakeLoader {
426 rows: u64,
427 materialized: RefCell<Vec<String>>,
428 appended: RefCell<Vec<String>>,
429 views: RefCell<Vec<String>>,
430 }
431
432 impl TargetLoader for FakeLoader {
433 fn fqtn(&self, table: &str) -> String {
434 format!("db.{table}")
435 }
436 fn materialize(&self, table: &str, _: &[TargetColumnSpec], _: &[String]) -> Result<u64> {
437 self.materialized.borrow_mut().push(table.into());
438 Ok(self.rows)
439 }
440 fn append_changelog(
441 &self,
442 table: &str,
443 _: &[TargetColumnSpec],
444 _: &[String],
445 _: &[String],
446 ) -> Result<u64> {
447 self.appended.borrow_mut().push(table.into());
448 Ok(self.rows)
449 }
450 fn warehouse(&self) -> cdc::Warehouse {
451 cdc::Warehouse::BigQuery
452 }
453 fn create_view(&self, table: &str, _view_sql: &str) -> Result<()> {
454 self.views.borrow_mut().push(table.into());
455 Ok(())
456 }
457 }
458
459 fn fs_store_with_prefix(dir: &tempfile::TempDir, rel: &str) -> GcsStore {
464 let obj = dir.path().join(rel).join("x.parquet");
465 std::fs::create_dir_all(obj.parent().unwrap()).unwrap();
466 std::fs::write(obj, b"x").unwrap();
467 GcsStore::open_fs(dir.path().to_str().unwrap()).unwrap()
468 }
469
470 fn prefix_populated(store: &GcsStore, rel: &str) -> bool {
472 !store.list_files(rel).unwrap().is_empty()
473 }
474
475 fn spec(status: TargetStatus) -> Vec<TargetColumnSpec> {
476 vec![TargetColumnSpec {
477 column_name: "id".into(),
478 target_type: "INT64".into(),
479 autoload_type: String::new(),
480 status,
481 note: None,
482 cast_sql: None,
483 }]
484 }
485 fn uris() -> Vec<String> {
486 vec!["gs://b/p/x.parquet".into()]
487 }
488 const PREFIX: &str = "gs://b/p";
491 const REL: &str = "p";
492
493 #[test]
494 fn empty_uris_bail_before_materialize() {
495 let f = FakeLoader {
496 rows: 10,
497 ..Default::default()
498 };
499 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &[], Some(10), None).is_err());
500 assert!(f.materialized.borrow().is_empty());
501 }
502
503 #[test]
504 fn fail_spec_bails_before_materialize() {
505 let f = FakeLoader::default();
506 assert!(run_load(&f, "t", &spec(TargetStatus::Fail), &uris(), Some(10), None).is_err());
507 assert!(f.materialized.borrow().is_empty());
508 }
509
510 #[test]
511 fn count_mismatch_bails_without_cleanup() {
512 let f = FakeLoader {
513 rows: 7,
514 ..Default::default()
515 };
516 let dir = tempfile::tempdir().unwrap();
517 let store = fs_store_with_prefix(&dir, REL);
518 let err = run_load(
519 &f,
520 "t",
521 &spec(TargetStatus::Ok),
522 &uris(),
523 Some(10),
524 Some((&store, PREFIX)),
525 )
526 .unwrap_err()
527 .to_string();
528 assert!(err.contains("count validation failed"), "{err}");
529 assert!(
530 prefix_populated(&store, REL),
531 "cleanup must not run on a failed gate — the source prefix stays intact"
532 );
533 }
534
535 #[test]
536 fn match_with_prefix_cleans_once() {
537 let f = FakeLoader {
538 rows: 10,
539 ..Default::default()
540 };
541 let dir = tempfile::tempdir().unwrap();
542 let store = fs_store_with_prefix(&dir, REL);
543 let r = run_load(
544 &f,
545 "t",
546 &spec(TargetStatus::Ok),
547 &uris(),
548 Some(10),
549 Some((&store, PREFIX)),
550 )
551 .unwrap();
552 assert!(r.source_cleaned);
553 assert!(
554 !prefix_populated(&store, REL),
555 "a passed gate drains the source prefix through the injected store"
556 );
557 assert_eq!(r.target_table, "db.t");
558 }
559
560 #[test]
561 fn match_without_prefix_does_not_clean() {
562 let f = FakeLoader {
563 rows: 10,
564 ..Default::default()
565 };
566 let r = run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), Some(10), None).unwrap();
567 assert!(!r.source_cleaned);
568 }
569
570 #[test]
571 fn none_expected_skips_the_gate() {
572 let f = FakeLoader {
573 rows: 999,
574 ..Default::default()
575 };
576 assert!(run_load(&f, "t", &spec(TargetStatus::Ok), &uris(), None, None).is_ok());
578 }
579
580 #[test]
581 fn cdc_delta_mismatch_bails_without_view() {
582 let f = FakeLoader {
583 rows: 3,
584 ..Default::default()
585 };
586 let dir = tempfile::tempdir().unwrap();
587 let store = fs_store_with_prefix(&dir, REL);
588 let err = run_load_cdc(
589 &f,
590 "t",
591 &spec(TargetStatus::Ok),
592 &uris(),
593 &["id".into()],
594 cdc::SourceEngine::MySql,
595 Some(5),
596 Some((&store, PREFIX)),
597 )
598 .unwrap_err()
599 .to_string();
600 assert!(err.contains("CDC count validation failed"), "{err}");
601 assert!(
602 f.views.borrow().is_empty(),
603 "view must not be built on a failed gate"
604 );
605 assert!(
606 prefix_populated(&store, REL),
607 "cleanup must not run on a failed gate"
608 );
609 }
610
611 #[test]
612 fn cdc_match_builds_view_then_cleans() {
613 let f = FakeLoader {
614 rows: 5,
615 ..Default::default()
616 };
617 let dir = tempfile::tempdir().unwrap();
618 let store = fs_store_with_prefix(&dir, REL);
619 let r = run_load_cdc(
620 &f,
621 "t",
622 &spec(TargetStatus::Ok),
623 &uris(),
624 &["id".into()],
625 cdc::SourceEngine::MySql,
626 Some(5),
627 Some((&store, PREFIX)),
628 )
629 .unwrap();
630 assert_eq!(r.rows_appended, 5);
631 assert_eq!(*f.views.borrow(), vec!["t".to_string()]);
632 assert!(
633 !prefix_populated(&store, REL),
634 "a passed CDC gate drains the source prefix after the view is built"
635 );
636 assert_eq!(r.changes_table, "db.t__changes");
637 }
638
639 #[test]
640 fn delete_under_drains_the_prefix_through_the_store() {
641 let dir = tempfile::tempdir().unwrap();
642 let store = fs_store_with_prefix(&dir, REL);
643 assert!(prefix_populated(&store, REL), "seeded object is present");
644 delete_under(&store, PREFIX).unwrap();
645 assert!(
646 !prefix_populated(&store, REL),
647 "delete_under recursively removes the bucket-relative prefix behind the gs:// URI"
648 );
649 }
650
651 #[test]
652 fn build_bigquery_loader_wires_partition_and_cluster_keys() {
653 let l = build_bigquery_loader(
658 "proj",
659 "ds",
660 Some("day"),
661 &["customer_id".to_string(), "region".to_string()],
662 "run-1",
663 );
664 assert_eq!(l.cluster_by, ["customer_id", "region"]);
665 assert_eq!(l.partition_by.as_deref(), Some("day"));
666
667 let bare = build_bigquery_loader("proj", "ds", None, &[], "run-1");
669 assert!(bare.cluster_by.is_empty());
670 assert!(bare.partition_by.is_none());
671 }
672
673 #[test]
674 fn split_gs_uri_parses_bucket_and_bucket_relative_key() {
675 assert_eq!(split_gs_uri("gs://b/p").unwrap(), ("b", "p"));
681 assert_eq!(
682 split_gs_uri("gs://bucket/a/b/c.parquet").unwrap(),
683 ("bucket", "a/b/c.parquet"),
684 "only the FIRST '/' splits bucket from key; the rest is the key"
685 );
686 assert!(
687 split_gs_uri("s3://b/p").is_err(),
688 "a non-gs scheme is rejected"
689 );
690 assert!(
691 split_gs_uri("gs://bucket-only").is_err(),
692 "a bucket with no '/' has no (bucket, key) split"
693 );
694 }
695
696 #[test]
697 fn cdc_empty_pk_bails() {
698 let f = FakeLoader::default();
699 assert!(
700 run_load_cdc(
701 &f,
702 "t",
703 &spec(TargetStatus::Ok),
704 &uris(),
705 &[],
706 cdc::SourceEngine::MySql,
707 None,
708 None
709 )
710 .is_err()
711 );
712 assert!(f.appended.borrow().is_empty());
713 }
714
715 #[test]
716 fn incremental_cursor_not_in_specs_bails_before_append() {
717 let f = FakeLoader::default();
718 let err = run_load_incremental(
723 &f,
724 "t",
725 &spec(TargetStatus::Ok),
726 &uris(),
727 &["id".to_string()],
728 "updated_at",
729 None,
730 None,
731 )
732 .unwrap_err()
733 .to_string();
734 assert!(
735 err.contains("updated_at") && err.contains("not one of the exported columns"),
736 "{err}"
737 );
738 assert!(
739 f.appended.borrow().is_empty(),
740 "nothing appended before the bail"
741 );
742 }
743}