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}
29
30pub(crate) fn build_snapshot_node(
41 cdc_node: &ExpandedNode,
42 snapshot_source: crate::config::ConnectorSpec,
43) -> ExpandedNode {
44 let mut n = cdc_node.clone();
45 n.id = "snapshot".to_string();
46 n.source = snapshot_source;
47 n.delivery = faucet_core::DeliveryMode::AtLeastOnce;
48 n.transforms.retain(|t| t.kind != "cdc_unwrap");
49 n
50}
51
52fn phase_failure(summary: &crate::executor::RunSummary, phase: &str) -> CliError {
59 let detail = summary
60 .invocations
61 .iter()
62 .find_map(|i| i.error.clone())
63 .unwrap_or_else(|| "unknown error".to_string());
64 CliError::Internal(format!("replication {phase} phase failed: {detail}"))
65}
66
67fn make_opts(opts: &ReplicationOptions, cancel: Option<CancellationToken>) -> ExecuteOptions {
69 ExecuteOptions {
70 pipeline_name: opts.pipeline_name.clone(),
71 execution: opts.execution.clone(),
72 dry_run: false,
73 limit: None,
74 state_path_override: None,
75 shard: None,
76 auth: opts.auth.clone(),
77 clock: opts.clock,
78 cancel,
79 resilience: opts.resilience.clone(),
80 #[cfg(feature = "lineage")]
81 lineage: None,
82 #[cfg(feature = "lineage")]
83 lineage_cfg: None,
84 }
85}
86
87fn spawn_cancel_on_signal(token: CancellationToken) {
89 tokio::spawn(async move {
90 #[cfg(unix)]
91 {
92 use tokio::signal::unix::{SignalKind, signal};
93 match signal(SignalKind::terminate()) {
94 Ok(mut sigterm) => {
95 tokio::select! {
96 _ = tokio::signal::ctrl_c() => {}
97 _ = sigterm.recv() => {}
98 }
99 }
100 Err(_) => {
101 let _ = tokio::signal::ctrl_c().await;
102 }
103 }
104 }
105 #[cfg(not(unix))]
106 {
107 let _ = tokio::signal::ctrl_c().await;
108 }
109 token.cancel();
110 });
111}
112
113pub async fn run_replication(
117 cfg: &PipelineConfig,
118 compiled: &CompiledReplication,
119 opts: ReplicationOptions,
120) -> CliResult<()> {
121 let mut nodes = expand(cfg)?;
124 let mut cdc_node = nodes
125 .drain(..)
126 .next()
127 .ok_or_else(|| CliError::Internal("replication: expand produced no node".into()))?;
128 cdc_node.id = "cdc".to_string();
129 let snapshot_node = build_snapshot_node(&cdc_node, compiled.snapshot_source.clone());
130
131 let state_spec = cfg
132 .pipeline
133 .state
134 .as_ref()
135 .ok_or_else(|| CliError::Config("replication requires a state store".into()))?;
136 let store = build_state_store(state_spec).await?;
137
138 let marker_k = marker_key(&opts.pipeline_name);
139 let cdc_k = cdc_state_key(&opts.pipeline_name);
140
141 let marker = match store.get(&marker_k).await? {
142 Some(v) => Some(ReplicationState::from_value(v)?),
143 None => None,
144 };
145
146 if plan_from_marker(marker.as_ref()) == Plan::Bootstrap {
148 let cdc_source = build_source(
149 &cdc_node.source.kind,
150 cdc_node.source.config.clone(),
151 &opts.auth,
152 None,
153 )
154 .await?;
155 let position = cdc_source.capture_resume_position().await?.ok_or_else(|| {
156 CliError::Config(format!(
157 "replication: source '{}' does not support position capture",
158 cdc_node.source.kind
159 ))
160 })?;
161 store.put(&cdc_k, &position).await?;
165 store
166 .put(
167 &marker_k,
168 &ReplicationState {
169 phase: Phase::Snapshot,
170 snapshot_done: false,
171 position: position.clone(),
172 }
173 .to_value()?,
174 )
175 .await?;
176 tracing::info!(pipeline = %opts.pipeline_name, "replication bootstrap: captured CDC position, seeded bookmark");
177 }
178
179 let marker = match store.get(&marker_k).await? {
181 Some(v) => ReplicationState::from_value(v)?,
182 None => {
183 return Err(CliError::Internal(
184 "replication: marker missing after bootstrap".into(),
185 ));
186 }
187 };
188
189 let cancel = CancellationToken::new();
195 spawn_cancel_on_signal(cancel.clone());
196
197 if !marker.snapshot_done {
199 tracing::info!(pipeline = %opts.pipeline_name, "replication: running snapshot phase (Ctrl-C / SIGTERM to stop)");
200 let summary = run_expanded(
201 vec![snapshot_node.clone()],
202 make_opts(&opts, Some(cancel.clone())),
203 )
204 .await?;
205 if summary.had_failures() {
206 return Err(phase_failure(&summary, "snapshot"));
207 }
208 if cancel.is_cancelled() {
214 tracing::warn!(
215 pipeline = %opts.pipeline_name,
216 "replication: snapshot interrupted by shutdown before completion; \
217 it will be redone on the next run"
218 );
219 return Ok(());
220 }
221 store
222 .put(
223 &marker_k,
224 &ReplicationState {
225 phase: Phase::Cdc,
226 snapshot_done: true,
227 position: marker.position.clone(),
228 }
229 .to_value()?,
230 )
231 .await?;
232 tracing::info!(pipeline = %opts.pipeline_name, "replication: snapshot complete; handing off to CDC");
233 }
234
235 if compiled.continuous {
237 tracing::info!(pipeline = %opts.pipeline_name, "replication: streaming CDC (Ctrl-C / SIGTERM to stop)");
238 }
239 let mut backoff = std::time::Duration::from_secs(1);
248 const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
249 loop {
250 let cycle: CliResult<()> = async {
251 let summary = run_expanded(
252 vec![cdc_node.clone()],
253 make_opts(&opts, Some(cancel.clone())),
254 )
255 .await?;
256 if summary.had_failures() {
257 return Err(phase_failure(&summary, "CDC"));
258 }
259 Ok(())
260 }
261 .await;
262
263 match cdc_loop_action(cycle.is_ok(), compiled.continuous, cancel.is_cancelled()) {
264 CdcLoopAction::Break => break,
265 CdcLoopAction::Continue => {
266 backoff = std::time::Duration::from_secs(1); }
268 CdcLoopAction::Propagate => return Err(cycle.unwrap_err()),
269 CdcLoopAction::Backoff => {
270 tracing::warn!(
271 pipeline = %opts.pipeline_name,
272 error = %cycle.unwrap_err(),
273 backoff_secs = backoff.as_secs(),
274 "replication: CDC cycle failed; resuming from bookmark after backoff"
275 );
276 tokio::select! {
277 biased;
278 _ = cancel.cancelled() => break,
279 _ = tokio::time::sleep(backoff) => {}
280 }
281 backoff = (backoff * 2).min(MAX_BACKOFF);
282 }
283 }
284 }
285 Ok(())
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290enum CdcLoopAction {
291 Break,
293 Continue,
295 Propagate,
297 Backoff,
300}
301
302fn cdc_loop_action(cycle_ok: bool, continuous: bool, cancelled: bool) -> CdcLoopAction {
306 match (cycle_ok, continuous && !cancelled) {
307 (true, false) => CdcLoopAction::Break, (true, true) => CdcLoopAction::Continue, (false, false) => CdcLoopAction::Propagate, (false, true) => CdcLoopAction::Backoff, }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::config::ConnectorSpec;
318 use crate::expand::expand;
319
320 fn cdc_node() -> ExpandedNode {
321 let cfg = crate::config::parse_with_extension(
322 r#"
323version: 1
324pipeline:
325 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
326 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
327 state: { type: file, config: { path: ./st } }
328"#,
329 "yaml",
330 )
331 .unwrap();
332 expand(&cfg).unwrap().into_iter().next().unwrap()
333 }
334
335 #[test]
336 fn cdc_loop_action_continuous_resumes_on_transient_failure() {
337 assert_eq!(cdc_loop_action(false, true, false), CdcLoopAction::Backoff);
340 assert_eq!(cdc_loop_action(true, true, false), CdcLoopAction::Continue);
341 assert_eq!(cdc_loop_action(true, true, true), CdcLoopAction::Break);
343 assert_eq!(cdc_loop_action(false, true, true), CdcLoopAction::Propagate);
344 assert_eq!(cdc_loop_action(true, false, false), CdcLoopAction::Break);
346 assert_eq!(
347 cdc_loop_action(false, false, false),
348 CdcLoopAction::Propagate
349 );
350 }
351
352 #[test]
353 fn snapshot_node_swaps_source_and_forces_at_least_once() {
354 let mut cdc = cdc_node();
355 cdc.id = "cdc".into();
356 cdc.delivery = faucet_core::DeliveryMode::ExactlyOnce;
357 let snap_src = ConnectorSpec {
358 kind: "postgres".into(),
359 config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
360 transforms: None,
361 inherit_transforms: true,
362 };
363 let node = build_snapshot_node(&cdc, snap_src);
364 assert_eq!(node.id, "snapshot");
365 assert_eq!(node.source.kind, "postgres");
366 assert_eq!(node.sink.kind, "postgres"); assert_eq!(node.delivery, faucet_core::DeliveryMode::AtLeastOnce);
368 }
369
370 #[test]
371 fn snapshot_node_strips_cdc_unwrap_but_keeps_other_transforms() {
372 let mut cdc = cdc_node();
377 cdc.id = "cdc".into();
378 cdc.transforms = vec![
379 crate::config::TransformSpec {
380 kind: "cdc_unwrap".into(),
381 config: serde_json::json!({}),
382 },
383 crate::config::TransformSpec {
384 kind: "flatten".into(),
385 config: serde_json::json!({ "separator": "_" }),
386 },
387 ];
388 let snap_src = ConnectorSpec {
389 kind: "postgres".into(),
390 config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
391 transforms: None,
392 inherit_transforms: true,
393 };
394 let node = build_snapshot_node(&cdc, snap_src);
395 let kinds: Vec<&str> = node.transforms.iter().map(|t| t.kind.as_str()).collect();
396 assert_eq!(kinds, vec!["flatten"], "cdc_unwrap dropped, flatten kept");
397 }
398
399 #[test]
400 fn phase_failure_surfaces_phase_and_underlying_error() {
401 let summary = crate::executor::RunSummary {
402 invocations: vec![crate::executor::InvocationOutcome {
403 row_id: "snapshot".into(),
404 parent_record_key: None,
405 records_written: 0,
406 error: Some("connection refused".into()),
407 }],
408 };
409 let err = phase_failure(&summary, "snapshot");
410 assert!(matches!(err, CliError::Internal(_)), "{err:?}");
411 let msg = format!("{err}");
412 assert!(msg.contains("snapshot"), "phase named: {msg}");
413 assert!(
414 msg.contains("connection refused"),
415 "underlying error: {msg}"
416 );
417 }
418
419 #[test]
420 fn phase_failure_falls_back_to_unknown_error() {
421 let summary = crate::executor::RunSummary {
424 invocations: vec![crate::executor::InvocationOutcome {
425 row_id: "cdc".into(),
426 parent_record_key: None,
427 records_written: 0,
428 error: None,
429 }],
430 };
431 let err = phase_failure(&summary, "CDC");
432 let msg = format!("{err}");
433 assert!(msg.contains("CDC"), "phase named: {msg}");
434 assert!(msg.contains("unknown error"), "fallback used: {msg}");
435 }
436}