faucet_cli/commands/
replicate.rs1use crate::cli::ReplicateArgs;
5use crate::config::PipelineConfig;
6use crate::error::{CliError, CliResult};
7use crate::replication::compiled::CompiledReplication;
8use crate::replication::{ReplicationOptions, run_replication};
9
10pub async fn run(args: ReplicateArgs) -> CliResult<()> {
12 let cwd = std::env::current_dir()?;
13 let env_path =
14 crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
15 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
16 let path = match args.config {
17 Some(p) => p,
18 None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
19 };
20
21 let cfg = PipelineConfig::from_path_async(&path, args.profile.as_deref()).await?;
22 let spec = cfg.replication.as_ref().ok_or_else(|| {
23 CliError::Config(
24 "no `replication:` block in config — use `faucet run` for a one-shot run, or add a \
25 `replication:` block (see `faucet schema replication`)"
26 .into(),
27 )
28 })?;
29 crate::obs::install(&cfg)?;
33
34 let compiled = CompiledReplication::compile(spec, &cfg)?;
35
36 let pipeline_name = cfg.name.clone().unwrap_or_else(|| {
37 path.file_stem()
38 .and_then(|s| s.to_str())
39 .unwrap_or("pipeline")
40 .to_owned()
41 });
42 let auth = crate::auth_catalog::build_auth_catalog(cfg.auth.as_ref())?;
43 let resilience = match &cfg.resilience {
44 Some(spec) => Some(spec.to_policy()?),
45 None => None,
46 };
47 #[cfg(feature = "notify")]
48 let notifier = crate::notify::Notifier::from_specs(&cfg.notifications)?;
49 #[cfg(feature = "catalog")]
50 let catalog = match cfg.catalog.as_ref() {
51 Some(spec) => Some(crate::catalog::connect_from_spec(spec).await?),
52 None => None,
53 };
54 #[cfg(feature = "catalog")]
58 let snapshot_inputs = catalog.as_ref().and_then(|handle| {
59 crate::expand::expand(&cfg)
60 .ok()
61 .map(|nodes| (handle.clone(), nodes, pipeline_name.clone()))
62 });
63
64 run_replication(
65 &cfg,
66 &compiled,
67 ReplicationOptions {
68 pipeline_name,
69 execution: cfg.execution.clone(),
70 auth,
71 clock: chrono::Utc::now().fixed_offset(),
72 resilience,
73 sla: cfg.sla.clone(),
74 reconcile: cfg.reconcile.clone(),
75 #[cfg(feature = "notify")]
76 notifier,
77 #[cfg(feature = "catalog")]
78 catalog,
79 },
80 )
81 .await?;
82
83 #[cfg(feature = "catalog")]
85 if let Some((handle, nodes, name)) = snapshot_inputs {
86 crate::catalog::snapshot::record_if_ok(
87 Some(&handle),
88 &name,
89 crate::catalog::snapshot::on_error_str(&cfg.execution),
90 &nodes,
91 true,
92 chrono::Utc::now(),
93 )
94 .await;
95 }
96
97 faucet_core::shutdown_otel();
99
100 println!("replication finished");
101 Ok(())
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use std::io::Write;
108
109 fn write_config(yaml: &str) -> std::path::PathBuf {
112 let dir = tempfile::tempdir().expect("tempdir");
113 let path = dir.path().join("repl.yaml");
114 let mut f = std::fs::File::create(&path).expect("create config");
115 f.write_all(yaml.as_bytes()).expect("write config");
116 f.flush().expect("flush");
117 std::mem::forget(dir);
119 path
120 }
121
122 fn args(path: std::path::PathBuf) -> ReplicateArgs {
123 ReplicateArgs {
124 config: Some(path),
125 env_file: None,
126 no_env_file: true,
127 profile: None,
128 }
129 }
130
131 #[tokio::test]
135 async fn errors_when_no_replication_block() {
136 let path = write_config(
137 r#"
138version: 1
139name: plain
140pipeline:
141 source: { type: rest, config: { url: "https://example.com/api" } }
142 sink: { type: jsonl, config: { path: ./out.jsonl } }
143"#,
144 );
145 let err = run(args(path)).await.unwrap_err();
146 assert!(
147 format!("{err}").contains("replication"),
148 "should mention the missing replication block: {err}"
149 );
150 }
151
152 #[cfg(all(
159 feature = "source-postgres-cdc",
160 feature = "source-postgres",
161 feature = "sink-postgres"
162 ))]
163 #[tokio::test]
164 async fn errors_when_replication_spec_invalid() {
165 let path = write_config(
166 r#"
167version: 1
168name: mirror
169pipeline:
170 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
171 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
172 state: { type: memory, config: {} }
173replication:
174 mode: snapshot_then_cdc
175 snapshot:
176 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
177"#,
178 );
179 let err = run(args(path)).await.unwrap_err();
180 assert!(
181 format!("{err}").contains("durable state"),
182 "should reject memory state at compile time: {err}"
183 );
184 }
185}