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