Skip to main content

faucet_cli/replication/
orchestrator.rs

1//! Two-phase snapshot→CDC orchestration. Reuses `executor::run_expanded` for
2//! each phase; seeds the CDC bookmark from a position captured before the
3//! snapshot so the handoff has no gap (idempotent at the boundary under
4//! `write_mode: upsert`).
5
6use 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
20/// Inputs for one `faucet replicate` invocation.
21pub struct ReplicationOptions {
22    pub pipeline_name: String,
23    pub execution: Option<ExecutionSpec>,
24    pub auth: AuthCatalog,
25    pub clock: DateTime<FixedOffset>,
26    /// Optional resilience policy, applied to both the snapshot and CDC phases.
27    pub resilience: Option<faucet_core::ResiliencePolicy>,
28}
29
30/// Build the snapshot-phase node by cloning the CDC node and swapping in the
31/// bulk-read source. The snapshot always runs at-least-once (the query source
32/// is not exactly-once-capable; upsert makes re-runs idempotent).
33///
34/// The `cdc_unwrap` transform is dropped from the snapshot node: it normalizes a
35/// CDC change-event envelope (`{op, before, after, …}`) and would silently drop
36/// the snapshot's plain table rows (no `after`/`op` image). The snapshot source
37/// already yields destination-shaped rows, so it must reach the sink directly;
38/// any other (non-`cdc_unwrap`) transforms are kept so common shaping still
39/// applies to both phases.
40pub(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
52/// Build a descriptive error from a failed phase, surfacing the first
53/// underlying invocation error. The executor only emits that error via
54/// `tracing::error!`, which a caller without a subscriber (e.g. a test, or a
55/// `faucet validate`-style path) would otherwise lose — collapsing the failure
56/// to an opaque count. Naming the phase + the real error makes replication
57/// failures diagnosable.
58fn 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
67/// Build a fresh `ExecuteOptions` for one phase run.
68fn 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
87/// Spawn a task that cancels `token` on SIGTERM (Unix) or Ctrl-C.
88fn 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
113/// Run the two-phase replication. Validates, bootstraps (captures + seeds the
114/// CDC position), snapshots (if not already done), then streams CDC (looping
115/// until SIGTERM when `continuous`).
116pub async fn run_replication(
117    cfg: &PipelineConfig,
118    compiled: &CompiledReplication,
119    opts: ReplicationOptions,
120) -> CliResult<()> {
121    // expand() runs the generic gates (exactly-once, write_mode×sink). With no
122    // matrix (enforced by CompiledReplication) there is exactly one node.
123    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    // ── Bootstrap: capture the CDC position before the snapshot ──────────────
147    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        // Seed the CDC bookmark (bare value — exactly-once's unwrap_state reads a
162        // bare value as seq=0, so this works for both delivery modes) and record
163        // the phase marker.
164        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    // Re-read the marker (it now exists). Decide the remaining work.
180    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    // Install graceful-shutdown handling UP FRONT — before the snapshot phase
190    // — so a SIGTERM / Ctrl-C during a long snapshot cancels cooperatively and
191    // lets the sink flush at the next page boundary, instead of hard-killing
192    // the process mid-write (F40). The same token feeds both the snapshot and
193    // CDC runs (`faucet schedule` installs its handler up front the same way).
194    let cancel = CancellationToken::new();
195    spawn_cancel_on_signal(cancel.clone());
196
197    // ── Snapshot phase (idempotent redo on resume) ───────────────────────────
198    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        // A SIGTERM mid-snapshot flushes a *partial* result — the snapshot is
209        // NOT complete. Do not mark `snapshot_done`: a restart redoes the whole
210        // snapshot idempotently from the bootstrap position (F40). Marking it
211        // done here would skip the un-snapshotted rows on restart (CDC only
212        // replays changes after the captured position, not pre-existing rows).
213        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    // ── CDC phase (loop until SIGTERM when continuous) ───────────────────────
236    if compiled.continuous {
237        tracing::info!(pipeline = %opts.pipeline_name, "replication: streaming CDC (Ctrl-C / SIGTERM to stop)");
238    }
239    // In continuous mode the CDC phase is an always-on mirror: a long-lived
240    // CDC connection routinely hits transient failures (network blips, server
241    // restarts, slot read errors). Those must NOT crash-exit the mirror — the
242    // run loops, re-running `run_expanded` which resumes from the persisted
243    // bookmark (lossless: the bookmark only advances after the pipeline
244    // persists, so a retry replays nothing already committed). We log, back off
245    // (capped, reset on a clean cycle), and continue. A one-shot run
246    // (`continuous: false`) still surfaces the error to the caller (F20).
247    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); // reset on a clean cycle
267            }
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/// What the CDC phase loop should do after one `run_expanded` cycle (F20).
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290enum CdcLoopAction {
291    /// Stop the loop (one-shot success, or cancelled).
292    Break,
293    /// Clean cycle in continuous mode — reset backoff and loop again.
294    Continue,
295    /// Surface the error to the caller (one-shot failure, or cancelled).
296    Propagate,
297    /// Transient failure in continuous mode — back off and resume (the mirror
298    /// must not crash-exit on a routine network blip / server restart).
299    Backoff,
300}
301
302/// Pure decision for the CDC phase loop. In continuous mode a cycle failure is
303/// recoverable (re-running resumes from the persisted bookmark, replaying
304/// nothing already committed); a one-shot run still surfaces the error.
305fn cdc_loop_action(cycle_ok: bool, continuous: bool, cancelled: bool) -> CdcLoopAction {
306    match (cycle_ok, continuous && !cancelled) {
307        (true, false) => CdcLoopAction::Break, // one-shot or cancelled success
308        (true, true) => CdcLoopAction::Continue, // keep mirroring
309        (false, false) => CdcLoopAction::Propagate, // one-shot or cancelled failure
310        (false, true) => CdcLoopAction::Backoff, // transient — resume after backoff
311    }
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        // F20: a continuous mirror backs off and resumes on a cycle failure
338        // instead of crash-exiting; a clean cycle keeps mirroring.
339        assert_eq!(cdc_loop_action(false, true, false), CdcLoopAction::Backoff);
340        assert_eq!(cdc_loop_action(true, true, false), CdcLoopAction::Continue);
341        // Cancellation stops the loop either way.
342        assert_eq!(cdc_loop_action(true, true, true), CdcLoopAction::Break);
343        assert_eq!(cdc_loop_action(false, true, true), CdcLoopAction::Propagate);
344        // One-shot runs are unchanged: success stops, failure surfaces.
345        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"); // sink preserved
367        assert_eq!(node.delivery, faucet_core::DeliveryMode::AtLeastOnce);
368    }
369
370    #[test]
371    fn snapshot_node_strips_cdc_unwrap_but_keeps_other_transforms() {
372        // The CDC pipeline normalizes envelopes with `cdc_unwrap` and then maybe
373        // shapes further (e.g. `flatten`). The snapshot source yields plain table
374        // rows, so `cdc_unwrap` (which would drop them) must be dropped while the
375        // other transforms are preserved.
376        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        // A failed run whose invocation carries no error string still produces a
422        // descriptive error (the count-only failure case).
423        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}