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