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 pub reconcile: Option<crate::reconcile::ReconcileSpec>,
32 #[cfg(feature = "notify")]
34 pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
35 #[cfg(feature = "catalog")]
38 pub catalog: Option<crate::catalog::CatalogHandle>,
39}
40
41pub(crate) fn build_snapshot_node(
52 cdc_node: &ExpandedNode,
53 snapshot_source: crate::config::ConnectorSpec,
54) -> ExpandedNode {
55 let mut n = cdc_node.clone();
56 n.id = "snapshot".to_string();
57 n.source = snapshot_source;
58 n.delivery = faucet_core::DeliveryMode::AtLeastOnce;
59 if n.delivery_guarantee
64 != faucet_core::DeliveryGuarantee::EffectivelyOnce(
65 faucet_core::EffectivelyOnceMechanism::KeyedUpsert,
66 )
67 {
68 n.delivery_guarantee = faucet_core::DeliveryGuarantee::AtLeastOnce;
69 }
70 n.transforms.retain(|t| t.kind != "cdc_unwrap");
71 n
72}
73
74fn phase_failure(summary: &crate::executor::RunSummary, phase: &str) -> CliError {
81 let detail = summary
82 .invocations
83 .iter()
84 .find_map(|i| i.error.clone())
85 .unwrap_or_else(|| "unknown error".to_string());
86 CliError::Internal(format!("replication {phase} phase failed: {detail}"))
87}
88
89fn make_opts(opts: &ReplicationOptions, cancel: Option<CancellationToken>) -> ExecuteOptions {
91 ExecuteOptions {
92 pipeline_name: opts.pipeline_name.clone(),
93 run_id: None,
94 execution: opts.execution.clone(),
95 dry_run: false,
96 limit: None,
97 state_path_override: None,
98 shard: None,
99 auth: opts.auth.clone(),
100 clock: opts.clock,
101 cancel,
102 resilience: opts.resilience.clone(),
103 sla: opts.sla.clone(),
104 reconcile: opts.reconcile.clone(),
105 #[cfg(feature = "lineage")]
106 lineage: None,
107 #[cfg(feature = "lineage")]
108 lineage_cfg: None,
109 #[cfg(feature = "notify")]
110 notifier: opts.notifier.clone(),
111 #[cfg(feature = "catalog")]
112 catalog: opts.catalog.clone(),
113 }
114}
115
116pub(crate) fn spawn_cancel_on_signal(token: CancellationToken) {
119 tokio::spawn(async move {
120 #[cfg(unix)]
121 {
122 use tokio::signal::unix::{SignalKind, signal};
123 match signal(SignalKind::terminate()) {
124 Ok(mut sigterm) => {
125 tokio::select! {
126 _ = tokio::signal::ctrl_c() => {}
127 _ = sigterm.recv() => {}
128 }
129 }
130 Err(_) => {
131 let _ = tokio::signal::ctrl_c().await;
132 }
133 }
134 }
135 #[cfg(not(unix))]
136 {
137 let _ = tokio::signal::ctrl_c().await;
138 }
139 token.cancel();
140 });
141}
142
143pub async fn run_replication(
147 cfg: &PipelineConfig,
148 compiled: &CompiledReplication,
149 opts: ReplicationOptions,
150) -> CliResult<()> {
151 let mut nodes = expand(cfg)?;
154 let mut cdc_node = nodes
155 .drain(..)
156 .next()
157 .ok_or_else(|| CliError::Internal("replication: expand produced no node".into()))?;
158 cdc_node.id = "cdc".to_string();
159 let snapshot_node = build_snapshot_node(&cdc_node, compiled.snapshot_source.clone());
160
161 let state_spec = cfg
162 .pipeline
163 .state
164 .as_ref()
165 .ok_or_else(|| CliError::Config("replication requires a state store".into()))?;
166 let store = build_state_store(state_spec).await?;
167
168 let marker_k = marker_key(&opts.pipeline_name);
169 let cdc_k = cdc_state_key(&opts.pipeline_name);
170
171 let marker = match store.get(&marker_k).await? {
172 Some(v) => Some(ReplicationState::from_value(v)?),
173 None => None,
174 };
175
176 if plan_from_marker(marker.as_ref()) == Plan::Bootstrap {
178 let cdc_source = build_source(
179 &cdc_node.source.kind,
180 cdc_node.source.config.clone(),
181 &opts.auth,
182 None,
183 )
184 .await?;
185 let position = cdc_source.capture_resume_position().await?.ok_or_else(|| {
186 CliError::Config(format!(
187 "replication: source '{}' does not support position capture",
188 cdc_node.source.kind
189 ))
190 })?;
191 store.put(&cdc_k, &position).await?;
195 store
196 .put(
197 &marker_k,
198 &ReplicationState {
199 phase: Phase::Snapshot,
200 snapshot_done: false,
201 position: position.clone(),
202 }
203 .to_value()?,
204 )
205 .await?;
206 tracing::info!(pipeline = %opts.pipeline_name, "replication bootstrap: captured CDC position, seeded bookmark");
207 }
208
209 let marker = match store.get(&marker_k).await? {
211 Some(v) => ReplicationState::from_value(v)?,
212 None => {
213 return Err(CliError::Internal(
214 "replication: marker missing after bootstrap".into(),
215 ));
216 }
217 };
218
219 let cancel = CancellationToken::new();
225 spawn_cancel_on_signal(cancel.clone());
226
227 if !marker.snapshot_done {
229 tracing::info!(pipeline = %opts.pipeline_name, "replication: running snapshot phase (Ctrl-C / SIGTERM to stop)");
230 let summary = run_expanded(
231 vec![snapshot_node.clone()],
232 make_opts(&opts, Some(cancel.clone())),
233 )
234 .await?;
235 if summary.had_failures() {
236 return Err(phase_failure(&summary, "snapshot"));
237 }
238 if cancel.is_cancelled() {
244 tracing::warn!(
245 pipeline = %opts.pipeline_name,
246 "replication: snapshot interrupted by shutdown before completion; \
247 it will be redone on the next run"
248 );
249 return Ok(());
250 }
251 store
252 .put(
253 &marker_k,
254 &ReplicationState {
255 phase: Phase::Cdc,
256 snapshot_done: true,
257 position: marker.position.clone(),
258 }
259 .to_value()?,
260 )
261 .await?;
262 tracing::info!(pipeline = %opts.pipeline_name, "replication: snapshot complete; handing off to CDC");
263 }
264
265 if compiled.continuous {
267 tracing::info!(pipeline = %opts.pipeline_name, "replication: streaming CDC (Ctrl-C / SIGTERM to stop)");
268 }
269 let mut backoff = std::time::Duration::from_secs(1);
278 const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
279 loop {
280 let cycle: CliResult<()> = async {
281 let summary = run_expanded(
282 vec![cdc_node.clone()],
283 make_opts(&opts, Some(cancel.clone())),
284 )
285 .await?;
286 if summary.had_failures() {
287 return Err(phase_failure(&summary, "CDC"));
288 }
289 Ok(())
290 }
291 .await;
292
293 match cdc_loop_action(cycle.is_ok(), compiled.continuous, cancel.is_cancelled()) {
294 CdcLoopAction::Break => break,
295 CdcLoopAction::Continue => {
296 backoff = std::time::Duration::from_secs(1); }
298 CdcLoopAction::Propagate => return Err(cycle.unwrap_err()),
299 CdcLoopAction::Backoff => {
300 tracing::warn!(
301 pipeline = %opts.pipeline_name,
302 error = %cycle.unwrap_err(),
303 backoff_secs = backoff.as_secs(),
304 "replication: CDC cycle failed; resuming from bookmark after backoff"
305 );
306 tokio::select! {
307 biased;
308 _ = cancel.cancelled() => break,
309 _ = tokio::time::sleep(backoff) => {}
310 }
311 backoff = (backoff * 2).min(MAX_BACKOFF);
312 }
313 }
314 }
315 Ok(())
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320enum CdcLoopAction {
321 Break,
323 Continue,
325 Propagate,
327 Backoff,
330}
331
332fn cdc_loop_action(cycle_ok: bool, continuous: bool, cancelled: bool) -> CdcLoopAction {
336 match (cycle_ok, continuous && !cancelled) {
337 (true, false) => CdcLoopAction::Break, (true, true) => CdcLoopAction::Continue, (false, false) => CdcLoopAction::Propagate, (false, true) => CdcLoopAction::Backoff, }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::config::ConnectorSpec;
348 use crate::expand::expand;
349
350 fn cdc_node() -> ExpandedNode {
351 let cfg = crate::config::parse_with_extension(
352 r#"
353version: 1
354pipeline:
355 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
356 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
357 state: { type: file, config: { path: ./st } }
358"#,
359 "yaml",
360 )
361 .unwrap();
362 expand(&cfg).unwrap().into_iter().next().unwrap()
363 }
364
365 #[test]
366 fn cdc_loop_action_continuous_resumes_on_transient_failure() {
367 assert_eq!(cdc_loop_action(false, true, false), CdcLoopAction::Backoff);
370 assert_eq!(cdc_loop_action(true, true, false), CdcLoopAction::Continue);
371 assert_eq!(cdc_loop_action(true, true, true), CdcLoopAction::Break);
373 assert_eq!(cdc_loop_action(false, true, true), CdcLoopAction::Propagate);
374 assert_eq!(cdc_loop_action(true, false, false), CdcLoopAction::Break);
376 assert_eq!(
377 cdc_loop_action(false, false, false),
378 CdcLoopAction::Propagate
379 );
380 }
381
382 #[test]
383 fn snapshot_node_swaps_source_and_forces_at_least_once() {
384 let mut cdc = cdc_node();
385 cdc.id = "cdc".into();
386 cdc.delivery = faucet_core::DeliveryMode::ExactlyOnce;
387 let snap_src = ConnectorSpec {
388 kind: "postgres".into(),
389 config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
390 transforms: None,
391 inherit_transforms: true,
392 status: None,
393 tags: Vec::new(),
394 complete_for: None,
395 };
396 let node = build_snapshot_node(&cdc, snap_src);
397 assert_eq!(node.id, "snapshot");
398 assert_eq!(node.source.kind, "postgres");
399 assert_eq!(node.sink.kind, "postgres"); assert_eq!(node.delivery, faucet_core::DeliveryMode::AtLeastOnce);
401 }
402
403 #[test]
404 fn snapshot_node_strips_cdc_unwrap_but_keeps_other_transforms() {
405 let mut cdc = cdc_node();
410 cdc.id = "cdc".into();
411 cdc.transforms = vec![
412 crate::config::TransformSpec {
413 kind: "cdc_unwrap".into(),
414 config: serde_json::json!({}),
415 },
416 crate::config::TransformSpec {
417 kind: "flatten".into(),
418 config: serde_json::json!({ "separator": "_" }),
419 },
420 ];
421 let snap_src = ConnectorSpec {
422 kind: "postgres".into(),
423 config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
424 transforms: None,
425 inherit_transforms: true,
426 status: None,
427 tags: Vec::new(),
428 complete_for: None,
429 };
430 let node = build_snapshot_node(&cdc, snap_src);
431 let kinds: Vec<&str> = node.transforms.iter().map(|t| t.kind.as_str()).collect();
432 assert_eq!(kinds, vec!["flatten"], "cdc_unwrap dropped, flatten kept");
433 }
434
435 #[test]
436 fn phase_failure_surfaces_phase_and_underlying_error() {
437 let summary = crate::executor::RunSummary {
438 invocations: vec![crate::executor::InvocationOutcome {
439 row_id: "snapshot".into(),
440 parent_record_key: None,
441 records_written: 0,
442 error: Some("connection refused".into()),
443 metrics: None,
444 }],
445 };
446 let err = phase_failure(&summary, "snapshot");
447 assert!(matches!(err, CliError::Internal(_)), "{err:?}");
448 let msg = format!("{err}");
449 assert!(msg.contains("snapshot"), "phase named: {msg}");
450 assert!(
451 msg.contains("connection refused"),
452 "underlying error: {msg}"
453 );
454 }
455
456 #[test]
457 fn phase_failure_falls_back_to_unknown_error() {
458 let summary = crate::executor::RunSummary {
461 invocations: vec![crate::executor::InvocationOutcome {
462 row_id: "cdc".into(),
463 parent_record_key: None,
464 records_written: 0,
465 error: None,
466 metrics: None,
467 }],
468 };
469 let err = phase_failure(&summary, "CDC");
470 let msg = format!("{err}");
471 assert!(msg.contains("CDC"), "phase named: {msg}");
472 assert!(msg.contains("unknown error"), "fallback used: {msg}");
473 }
474}