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