1use crate::auth_catalog::AuthCatalog;
7use crate::config::{ExecutionSpec, PipelineConfig};
8use crate::error::{CliError, CliResult};
9use crate::executor::{ExecuteOptions, run_expanded};
10use crate::expand::{ExpandedNode, expand};
11use crate::registry::build_source;
12use crate::replication::compiled::CompiledReplication;
13use crate::replication::state::{
14 Phase, Plan, ReplicationState, cdc_state_key, marker_key, plan_from_marker,
15};
16use crate::state::build_state_store;
17use chrono::{DateTime, FixedOffset};
18use tokio_util::sync::CancellationToken;
19
20pub struct ReplicationOptions {
22 pub pipeline_name: String,
23 pub execution: Option<ExecutionSpec>,
24 pub auth: AuthCatalog,
25 pub clock: DateTime<FixedOffset>,
26 pub resilience: Option<faucet_core::ResiliencePolicy>,
28 pub sla: Option<crate::sla::SlaSpec>,
30 #[cfg(feature = "notify")]
32 pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
33 #[cfg(feature = "catalog")]
36 pub catalog: Option<crate::catalog::CatalogHandle>,
37}
38
39pub(crate) fn build_snapshot_node(
50 cdc_node: &ExpandedNode,
51 snapshot_source: crate::config::ConnectorSpec,
52) -> ExpandedNode {
53 let mut n = cdc_node.clone();
54 n.id = "snapshot".to_string();
55 n.source = snapshot_source;
56 n.delivery = faucet_core::DeliveryMode::AtLeastOnce;
57 n.transforms.retain(|t| t.kind != "cdc_unwrap");
58 n
59}
60
61fn phase_failure(summary: &crate::executor::RunSummary, phase: &str) -> CliError {
68 let detail = summary
69 .invocations
70 .iter()
71 .find_map(|i| i.error.clone())
72 .unwrap_or_else(|| "unknown error".to_string());
73 CliError::Internal(format!("replication {phase} phase failed: {detail}"))
74}
75
76fn make_opts(opts: &ReplicationOptions, cancel: Option<CancellationToken>) -> ExecuteOptions {
78 ExecuteOptions {
79 pipeline_name: opts.pipeline_name.clone(),
80 execution: opts.execution.clone(),
81 dry_run: false,
82 limit: None,
83 state_path_override: None,
84 shard: None,
85 auth: opts.auth.clone(),
86 clock: opts.clock,
87 cancel,
88 resilience: opts.resilience.clone(),
89 sla: opts.sla.clone(),
90 #[cfg(feature = "lineage")]
91 lineage: None,
92 #[cfg(feature = "lineage")]
93 lineage_cfg: None,
94 #[cfg(feature = "notify")]
95 notifier: opts.notifier.clone(),
96 #[cfg(feature = "catalog")]
97 catalog: opts.catalog.clone(),
98 }
99}
100
101fn spawn_cancel_on_signal(token: CancellationToken) {
103 tokio::spawn(async move {
104 #[cfg(unix)]
105 {
106 use tokio::signal::unix::{SignalKind, signal};
107 match signal(SignalKind::terminate()) {
108 Ok(mut sigterm) => {
109 tokio::select! {
110 _ = tokio::signal::ctrl_c() => {}
111 _ = sigterm.recv() => {}
112 }
113 }
114 Err(_) => {
115 let _ = tokio::signal::ctrl_c().await;
116 }
117 }
118 }
119 #[cfg(not(unix))]
120 {
121 let _ = tokio::signal::ctrl_c().await;
122 }
123 token.cancel();
124 });
125}
126
127pub async fn run_replication(
131 cfg: &PipelineConfig,
132 compiled: &CompiledReplication,
133 opts: ReplicationOptions,
134) -> CliResult<()> {
135 let mut nodes = expand(cfg)?;
138 let mut cdc_node = nodes
139 .drain(..)
140 .next()
141 .ok_or_else(|| CliError::Internal("replication: expand produced no node".into()))?;
142 cdc_node.id = "cdc".to_string();
143 let snapshot_node = build_snapshot_node(&cdc_node, compiled.snapshot_source.clone());
144
145 let state_spec = cfg
146 .pipeline
147 .state
148 .as_ref()
149 .ok_or_else(|| CliError::Config("replication requires a state store".into()))?;
150 let store = build_state_store(state_spec).await?;
151
152 let marker_k = marker_key(&opts.pipeline_name);
153 let cdc_k = cdc_state_key(&opts.pipeline_name);
154
155 let marker = match store.get(&marker_k).await? {
156 Some(v) => Some(ReplicationState::from_value(v)?),
157 None => None,
158 };
159
160 if plan_from_marker(marker.as_ref()) == Plan::Bootstrap {
162 let cdc_source = build_source(
163 &cdc_node.source.kind,
164 cdc_node.source.config.clone(),
165 &opts.auth,
166 None,
167 )
168 .await?;
169 let position = cdc_source.capture_resume_position().await?.ok_or_else(|| {
170 CliError::Config(format!(
171 "replication: source '{}' does not support position capture",
172 cdc_node.source.kind
173 ))
174 })?;
175 store.put(&cdc_k, &position).await?;
179 store
180 .put(
181 &marker_k,
182 &ReplicationState {
183 phase: Phase::Snapshot,
184 snapshot_done: false,
185 position: position.clone(),
186 }
187 .to_value()?,
188 )
189 .await?;
190 tracing::info!(pipeline = %opts.pipeline_name, "replication bootstrap: captured CDC position, seeded bookmark");
191 }
192
193 let marker = match store.get(&marker_k).await? {
195 Some(v) => ReplicationState::from_value(v)?,
196 None => {
197 return Err(CliError::Internal(
198 "replication: marker missing after bootstrap".into(),
199 ));
200 }
201 };
202
203 let cancel = CancellationToken::new();
209 spawn_cancel_on_signal(cancel.clone());
210
211 if !marker.snapshot_done {
213 tracing::info!(pipeline = %opts.pipeline_name, "replication: running snapshot phase (Ctrl-C / SIGTERM to stop)");
214 let summary = run_expanded(
215 vec![snapshot_node.clone()],
216 make_opts(&opts, Some(cancel.clone())),
217 )
218 .await?;
219 if summary.had_failures() {
220 return Err(phase_failure(&summary, "snapshot"));
221 }
222 if cancel.is_cancelled() {
228 tracing::warn!(
229 pipeline = %opts.pipeline_name,
230 "replication: snapshot interrupted by shutdown before completion; \
231 it will be redone on the next run"
232 );
233 return Ok(());
234 }
235 store
236 .put(
237 &marker_k,
238 &ReplicationState {
239 phase: Phase::Cdc,
240 snapshot_done: true,
241 position: marker.position.clone(),
242 }
243 .to_value()?,
244 )
245 .await?;
246 tracing::info!(pipeline = %opts.pipeline_name, "replication: snapshot complete; handing off to CDC");
247 }
248
249 if compiled.continuous {
251 tracing::info!(pipeline = %opts.pipeline_name, "replication: streaming CDC (Ctrl-C / SIGTERM to stop)");
252 }
253 let mut backoff = std::time::Duration::from_secs(1);
262 const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
263 loop {
264 let cycle: CliResult<()> = async {
265 let summary = run_expanded(
266 vec![cdc_node.clone()],
267 make_opts(&opts, Some(cancel.clone())),
268 )
269 .await?;
270 if summary.had_failures() {
271 return Err(phase_failure(&summary, "CDC"));
272 }
273 Ok(())
274 }
275 .await;
276
277 match cdc_loop_action(cycle.is_ok(), compiled.continuous, cancel.is_cancelled()) {
278 CdcLoopAction::Break => break,
279 CdcLoopAction::Continue => {
280 backoff = std::time::Duration::from_secs(1); }
282 CdcLoopAction::Propagate => return Err(cycle.unwrap_err()),
283 CdcLoopAction::Backoff => {
284 tracing::warn!(
285 pipeline = %opts.pipeline_name,
286 error = %cycle.unwrap_err(),
287 backoff_secs = backoff.as_secs(),
288 "replication: CDC cycle failed; resuming from bookmark after backoff"
289 );
290 tokio::select! {
291 biased;
292 _ = cancel.cancelled() => break,
293 _ = tokio::time::sleep(backoff) => {}
294 }
295 backoff = (backoff * 2).min(MAX_BACKOFF);
296 }
297 }
298 }
299 Ok(())
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304enum CdcLoopAction {
305 Break,
307 Continue,
309 Propagate,
311 Backoff,
314}
315
316fn cdc_loop_action(cycle_ok: bool, continuous: bool, cancelled: bool) -> CdcLoopAction {
320 match (cycle_ok, continuous && !cancelled) {
321 (true, false) => CdcLoopAction::Break, (true, true) => CdcLoopAction::Continue, (false, false) => CdcLoopAction::Propagate, (false, true) => CdcLoopAction::Backoff, }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::config::ConnectorSpec;
332 use crate::expand::expand;
333
334 fn cdc_node() -> ExpandedNode {
335 let cfg = crate::config::parse_with_extension(
336 r#"
337version: 1
338pipeline:
339 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
340 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
341 state: { type: file, config: { path: ./st } }
342"#,
343 "yaml",
344 )
345 .unwrap();
346 expand(&cfg).unwrap().into_iter().next().unwrap()
347 }
348
349 #[test]
350 fn cdc_loop_action_continuous_resumes_on_transient_failure() {
351 assert_eq!(cdc_loop_action(false, true, false), CdcLoopAction::Backoff);
354 assert_eq!(cdc_loop_action(true, true, false), CdcLoopAction::Continue);
355 assert_eq!(cdc_loop_action(true, true, true), CdcLoopAction::Break);
357 assert_eq!(cdc_loop_action(false, true, true), CdcLoopAction::Propagate);
358 assert_eq!(cdc_loop_action(true, false, false), CdcLoopAction::Break);
360 assert_eq!(
361 cdc_loop_action(false, false, false),
362 CdcLoopAction::Propagate
363 );
364 }
365
366 #[test]
367 fn snapshot_node_swaps_source_and_forces_at_least_once() {
368 let mut cdc = cdc_node();
369 cdc.id = "cdc".into();
370 cdc.delivery = faucet_core::DeliveryMode::ExactlyOnce;
371 let snap_src = ConnectorSpec {
372 kind: "postgres".into(),
373 config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
374 transforms: None,
375 inherit_transforms: true,
376 };
377 let node = build_snapshot_node(&cdc, snap_src);
378 assert_eq!(node.id, "snapshot");
379 assert_eq!(node.source.kind, "postgres");
380 assert_eq!(node.sink.kind, "postgres"); assert_eq!(node.delivery, faucet_core::DeliveryMode::AtLeastOnce);
382 }
383
384 #[test]
385 fn snapshot_node_strips_cdc_unwrap_but_keeps_other_transforms() {
386 let mut cdc = cdc_node();
391 cdc.id = "cdc".into();
392 cdc.transforms = vec![
393 crate::config::TransformSpec {
394 kind: "cdc_unwrap".into(),
395 config: serde_json::json!({}),
396 },
397 crate::config::TransformSpec {
398 kind: "flatten".into(),
399 config: serde_json::json!({ "separator": "_" }),
400 },
401 ];
402 let snap_src = ConnectorSpec {
403 kind: "postgres".into(),
404 config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
405 transforms: None,
406 inherit_transforms: true,
407 };
408 let node = build_snapshot_node(&cdc, snap_src);
409 let kinds: Vec<&str> = node.transforms.iter().map(|t| t.kind.as_str()).collect();
410 assert_eq!(kinds, vec!["flatten"], "cdc_unwrap dropped, flatten kept");
411 }
412
413 #[test]
414 fn phase_failure_surfaces_phase_and_underlying_error() {
415 let summary = crate::executor::RunSummary {
416 invocations: vec![crate::executor::InvocationOutcome {
417 row_id: "snapshot".into(),
418 parent_record_key: None,
419 records_written: 0,
420 error: Some("connection refused".into()),
421 }],
422 };
423 let err = phase_failure(&summary, "snapshot");
424 assert!(matches!(err, CliError::Internal(_)), "{err:?}");
425 let msg = format!("{err}");
426 assert!(msg.contains("snapshot"), "phase named: {msg}");
427 assert!(
428 msg.contains("connection refused"),
429 "underlying error: {msg}"
430 );
431 }
432
433 #[test]
434 fn phase_failure_falls_back_to_unknown_error() {
435 let summary = crate::executor::RunSummary {
438 invocations: vec![crate::executor::InvocationOutcome {
439 row_id: "cdc".into(),
440 parent_record_key: None,
441 records_written: 0,
442 error: None,
443 }],
444 };
445 let err = phase_failure(&summary, "CDC");
446 let msg = format!("{err}");
447 assert!(msg.contains("CDC"), "phase named: {msg}");
448 assert!(msg.contains("unknown error"), "fallback used: {msg}");
449 }
450}