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    /// Optional freshness/volume SLA (#202), evaluated after each phase's runs.
29    pub sla: Option<crate::sla::SlaSpec>,
30    /// Optional completeness reconciliation (#502), evaluated after each phase's runs.
31    pub reconcile: Option<crate::reconcile::ReconcileSpec>,
32    /// Optional notifier (#280), shared across both phases' runs.
33    #[cfg(feature = "notify")]
34    pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
35    /// Optional Data Movement Catalog store (#279), recorded into after both
36    /// the snapshot and each CDC phase run.
37    #[cfg(feature = "catalog")]
38    pub catalog: Option<crate::catalog::CatalogHandle>,
39}
40
41/// Build the snapshot-phase node by cloning the CDC node and swapping in the
42/// bulk-read source. The snapshot always runs at-least-once (the query source
43/// is not exactly-once-capable; upsert makes re-runs idempotent).
44///
45/// The `cdc_unwrap` transform is dropped from the snapshot node: it normalizes a
46/// CDC change-event envelope (`{op, before, after, …}`) and would silently drop
47/// the snapshot's plain table rows (no `after`/`op` image). The snapshot source
48/// already yields destination-shaped rows, so it must reach the sink directly;
49/// any other (non-`cdc_unwrap`) transforms are kept so common shaping still
50/// applies to both phases.
51pub(crate) fn build_snapshot_node(
52    cdc_node: &ExpandedNode,
53    snapshot_source: crate::config::ConnectorSpec,
54) -> ExpandedNode {
55    let mut n = cdc_node.clone();
56    n.id = "snapshot".to_string();
57    n.source = snapshot_source;
58    n.delivery = faucet_core::DeliveryMode::AtLeastOnce;
59    // Keep the derived-guarantee report truthful for the forced-at-least-once
60    // snapshot phase — unless the sink dedups by key, in which case the
61    // snapshot inherits keyed-upsert effectively-once (the recommended
62    // `write_mode: upsert` mirror setup).
63    if n.delivery_guarantee
64        != faucet_core::DeliveryGuarantee::EffectivelyOnce(
65            faucet_core::EffectivelyOnceMechanism::KeyedUpsert,
66        )
67    {
68        n.delivery_guarantee = faucet_core::DeliveryGuarantee::AtLeastOnce;
69    }
70    n.transforms.retain(|t| t.kind != "cdc_unwrap");
71    n
72}
73
74/// Build a descriptive error from a failed phase, surfacing the first
75/// underlying invocation error. The executor only emits that error via
76/// `tracing::error!`, which a caller without a subscriber (e.g. a test, or a
77/// `faucet validate`-style path) would otherwise lose — collapsing the failure
78/// to an opaque count. Naming the phase + the real error makes replication
79/// failures diagnosable.
80fn phase_failure(summary: &crate::executor::RunSummary, phase: &str) -> CliError {
81    let detail = summary
82        .invocations
83        .iter()
84        .find_map(|i| i.error.clone())
85        .unwrap_or_else(|| "unknown error".to_string());
86    CliError::Internal(format!("replication {phase} phase failed: {detail}"))
87}
88
89/// Build a fresh `ExecuteOptions` for one phase run.
90fn make_opts(opts: &ReplicationOptions, cancel: Option<CancellationToken>) -> ExecuteOptions {
91    ExecuteOptions {
92        pipeline_name: opts.pipeline_name.clone(),
93        run_id: None,
94        execution: opts.execution.clone(),
95        dry_run: false,
96        limit: None,
97        state_path_override: None,
98        shard: None,
99        auth: opts.auth.clone(),
100        clock: opts.clock,
101        cancel,
102        resilience: opts.resilience.clone(),
103        sla: opts.sla.clone(),
104        reconcile: opts.reconcile.clone(),
105        #[cfg(feature = "lineage")]
106        lineage: None,
107        #[cfg(feature = "lineage")]
108        lineage_cfg: None,
109        #[cfg(feature = "notify")]
110        notifier: opts.notifier.clone(),
111        #[cfg(feature = "catalog")]
112        catalog: opts.catalog.clone(),
113    }
114}
115
116/// Spawn a task that cancels `token` on SIGTERM (Unix) or Ctrl-C. Shared
117/// with the backfill orchestrator.
118pub(crate) fn spawn_cancel_on_signal(token: CancellationToken) {
119    tokio::spawn(async move {
120        #[cfg(unix)]
121        {
122            use tokio::signal::unix::{SignalKind, signal};
123            match signal(SignalKind::terminate()) {
124                Ok(mut sigterm) => {
125                    tokio::select! {
126                        _ = tokio::signal::ctrl_c() => {}
127                        _ = sigterm.recv() => {}
128                    }
129                }
130                Err(_) => {
131                    let _ = tokio::signal::ctrl_c().await;
132                }
133            }
134        }
135        #[cfg(not(unix))]
136        {
137            let _ = tokio::signal::ctrl_c().await;
138        }
139        token.cancel();
140    });
141}
142
143/// Run the two-phase replication. Validates, bootstraps (captures + seeds the
144/// CDC position), snapshots (if not already done), then streams CDC (looping
145/// until SIGTERM when `continuous`).
146pub async fn run_replication(
147    cfg: &PipelineConfig,
148    compiled: &CompiledReplication,
149    opts: ReplicationOptions,
150) -> CliResult<()> {
151    // expand() runs the generic gates (exactly-once, write_mode×sink). With no
152    // matrix (enforced by CompiledReplication) there is exactly one node.
153    let mut nodes = expand(cfg)?;
154    let mut cdc_node = nodes
155        .drain(..)
156        .next()
157        .ok_or_else(|| CliError::Internal("replication: expand produced no node".into()))?;
158    cdc_node.id = "cdc".to_string();
159    let snapshot_node = build_snapshot_node(&cdc_node, compiled.snapshot_source.clone());
160
161    let state_spec = cfg
162        .pipeline
163        .state
164        .as_ref()
165        .ok_or_else(|| CliError::Config("replication requires a state store".into()))?;
166    let store = build_state_store(state_spec).await?;
167
168    let marker_k = marker_key(&opts.pipeline_name);
169    let cdc_k = cdc_state_key(&opts.pipeline_name);
170
171    let marker = match store.get(&marker_k).await? {
172        Some(v) => Some(ReplicationState::from_value(v)?),
173        None => None,
174    };
175
176    // ── Bootstrap: capture the CDC position before the snapshot ──────────────
177    if plan_from_marker(marker.as_ref()) == Plan::Bootstrap {
178        let cdc_source = build_source(
179            &cdc_node.source.kind,
180            cdc_node.source.config.clone(),
181            &opts.auth,
182            None,
183        )
184        .await?;
185        let position = cdc_source.capture_resume_position().await?.ok_or_else(|| {
186            CliError::Config(format!(
187                "replication: source '{}' does not support position capture",
188                cdc_node.source.kind
189            ))
190        })?;
191        // Seed the CDC bookmark (bare value — exactly-once's unwrap_state reads a
192        // bare value as seq=0, so this works for both delivery modes) and record
193        // the phase marker.
194        store.put(&cdc_k, &position).await?;
195        store
196            .put(
197                &marker_k,
198                &ReplicationState {
199                    phase: Phase::Snapshot,
200                    snapshot_done: false,
201                    position: position.clone(),
202                }
203                .to_value()?,
204            )
205            .await?;
206        tracing::info!(pipeline = %opts.pipeline_name, "replication bootstrap: captured CDC position, seeded bookmark");
207    }
208
209    // Re-read the marker (it now exists). Decide the remaining work.
210    let marker = match store.get(&marker_k).await? {
211        Some(v) => ReplicationState::from_value(v)?,
212        None => {
213            return Err(CliError::Internal(
214                "replication: marker missing after bootstrap".into(),
215            ));
216        }
217    };
218
219    // Install graceful-shutdown handling UP FRONT — before the snapshot phase
220    // — so a SIGTERM / Ctrl-C during a long snapshot cancels cooperatively and
221    // lets the sink flush at the next page boundary, instead of hard-killing
222    // the process mid-write (F40). The same token feeds both the snapshot and
223    // CDC runs (`faucet schedule` installs its handler up front the same way).
224    let cancel = CancellationToken::new();
225    spawn_cancel_on_signal(cancel.clone());
226
227    // ── Snapshot phase (idempotent redo on resume) ───────────────────────────
228    if !marker.snapshot_done {
229        tracing::info!(pipeline = %opts.pipeline_name, "replication: running snapshot phase (Ctrl-C / SIGTERM to stop)");
230        let summary = run_expanded(
231            vec![snapshot_node.clone()],
232            make_opts(&opts, Some(cancel.clone())),
233        )
234        .await?;
235        if summary.had_failures() {
236            return Err(phase_failure(&summary, "snapshot"));
237        }
238        // A SIGTERM mid-snapshot flushes a *partial* result — the snapshot is
239        // NOT complete. Do not mark `snapshot_done`: a restart redoes the whole
240        // snapshot idempotently from the bootstrap position (F40). Marking it
241        // done here would skip the un-snapshotted rows on restart (CDC only
242        // replays changes after the captured position, not pre-existing rows).
243        if cancel.is_cancelled() {
244            tracing::warn!(
245                pipeline = %opts.pipeline_name,
246                "replication: snapshot interrupted by shutdown before completion; \
247                 it will be redone on the next run"
248            );
249            return Ok(());
250        }
251        store
252            .put(
253                &marker_k,
254                &ReplicationState {
255                    phase: Phase::Cdc,
256                    snapshot_done: true,
257                    position: marker.position.clone(),
258                }
259                .to_value()?,
260            )
261            .await?;
262        tracing::info!(pipeline = %opts.pipeline_name, "replication: snapshot complete; handing off to CDC");
263    }
264
265    // ── CDC phase (loop until SIGTERM when continuous) ───────────────────────
266    if compiled.continuous {
267        tracing::info!(pipeline = %opts.pipeline_name, "replication: streaming CDC (Ctrl-C / SIGTERM to stop)");
268    }
269    // In continuous mode the CDC phase is an always-on mirror: a long-lived
270    // CDC connection routinely hits transient failures (network blips, server
271    // restarts, slot read errors). Those must NOT crash-exit the mirror — the
272    // run loops, re-running `run_expanded` which resumes from the persisted
273    // bookmark (lossless: the bookmark only advances after the pipeline
274    // persists, so a retry replays nothing already committed). We log, back off
275    // (capped, reset on a clean cycle), and continue. A one-shot run
276    // (`continuous: false`) still surfaces the error to the caller (F20).
277    let mut backoff = std::time::Duration::from_secs(1);
278    const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
279    loop {
280        let cycle: CliResult<()> = async {
281            let summary = run_expanded(
282                vec![cdc_node.clone()],
283                make_opts(&opts, Some(cancel.clone())),
284            )
285            .await?;
286            if summary.had_failures() {
287                return Err(phase_failure(&summary, "CDC"));
288            }
289            Ok(())
290        }
291        .await;
292
293        match cdc_loop_action(cycle.is_ok(), compiled.continuous, cancel.is_cancelled()) {
294            CdcLoopAction::Break => break,
295            CdcLoopAction::Continue => {
296                backoff = std::time::Duration::from_secs(1); // reset on a clean cycle
297            }
298            CdcLoopAction::Propagate => return Err(cycle.unwrap_err()),
299            CdcLoopAction::Backoff => {
300                tracing::warn!(
301                    pipeline = %opts.pipeline_name,
302                    error = %cycle.unwrap_err(),
303                    backoff_secs = backoff.as_secs(),
304                    "replication: CDC cycle failed; resuming from bookmark after backoff"
305                );
306                tokio::select! {
307                    biased;
308                    _ = cancel.cancelled() => break,
309                    _ = tokio::time::sleep(backoff) => {}
310                }
311                backoff = (backoff * 2).min(MAX_BACKOFF);
312            }
313        }
314    }
315    Ok(())
316}
317
318/// What the CDC phase loop should do after one `run_expanded` cycle (F20).
319#[derive(Debug, Clone, Copy, PartialEq, Eq)]
320enum CdcLoopAction {
321    /// Stop the loop (one-shot success, or cancelled).
322    Break,
323    /// Clean cycle in continuous mode — reset backoff and loop again.
324    Continue,
325    /// Surface the error to the caller (one-shot failure, or cancelled).
326    Propagate,
327    /// Transient failure in continuous mode — back off and resume (the mirror
328    /// must not crash-exit on a routine network blip / server restart).
329    Backoff,
330}
331
332/// Pure decision for the CDC phase loop. In continuous mode a cycle failure is
333/// recoverable (re-running resumes from the persisted bookmark, replaying
334/// nothing already committed); a one-shot run still surfaces the error.
335fn cdc_loop_action(cycle_ok: bool, continuous: bool, cancelled: bool) -> CdcLoopAction {
336    match (cycle_ok, continuous && !cancelled) {
337        (true, false) => CdcLoopAction::Break, // one-shot or cancelled success
338        (true, true) => CdcLoopAction::Continue, // keep mirroring
339        (false, false) => CdcLoopAction::Propagate, // one-shot or cancelled failure
340        (false, true) => CdcLoopAction::Backoff, // transient — resume after backoff
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::config::ConnectorSpec;
348    use crate::expand::expand;
349
350    fn cdc_node() -> ExpandedNode {
351        let cfg = crate::config::parse_with_extension(
352            r#"
353version: 1
354pipeline:
355  source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
356  sink:   { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
357  state:  { type: file, config: { path: ./st } }
358"#,
359            "yaml",
360        )
361        .unwrap();
362        expand(&cfg).unwrap().into_iter().next().unwrap()
363    }
364
365    #[test]
366    fn cdc_loop_action_continuous_resumes_on_transient_failure() {
367        // F20: a continuous mirror backs off and resumes on a cycle failure
368        // instead of crash-exiting; a clean cycle keeps mirroring.
369        assert_eq!(cdc_loop_action(false, true, false), CdcLoopAction::Backoff);
370        assert_eq!(cdc_loop_action(true, true, false), CdcLoopAction::Continue);
371        // Cancellation stops the loop either way.
372        assert_eq!(cdc_loop_action(true, true, true), CdcLoopAction::Break);
373        assert_eq!(cdc_loop_action(false, true, true), CdcLoopAction::Propagate);
374        // One-shot runs are unchanged: success stops, failure surfaces.
375        assert_eq!(cdc_loop_action(true, false, false), CdcLoopAction::Break);
376        assert_eq!(
377            cdc_loop_action(false, false, false),
378            CdcLoopAction::Propagate
379        );
380    }
381
382    #[test]
383    fn snapshot_node_swaps_source_and_forces_at_least_once() {
384        let mut cdc = cdc_node();
385        cdc.id = "cdc".into();
386        cdc.delivery = faucet_core::DeliveryMode::ExactlyOnce;
387        let snap_src = ConnectorSpec {
388            kind: "postgres".into(),
389            config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
390            transforms: None,
391            inherit_transforms: true,
392            status: None,
393            tags: Vec::new(),
394            complete_for: None,
395        };
396        let node = build_snapshot_node(&cdc, snap_src);
397        assert_eq!(node.id, "snapshot");
398        assert_eq!(node.source.kind, "postgres");
399        assert_eq!(node.sink.kind, "postgres"); // sink preserved
400        assert_eq!(node.delivery, faucet_core::DeliveryMode::AtLeastOnce);
401    }
402
403    #[test]
404    fn snapshot_node_strips_cdc_unwrap_but_keeps_other_transforms() {
405        // The CDC pipeline normalizes envelopes with `cdc_unwrap` and then maybe
406        // shapes further (e.g. `flatten`). The snapshot source yields plain table
407        // rows, so `cdc_unwrap` (which would drop them) must be dropped while the
408        // other transforms are preserved.
409        let mut cdc = cdc_node();
410        cdc.id = "cdc".into();
411        cdc.transforms = vec![
412            crate::config::TransformSpec {
413                kind: "cdc_unwrap".into(),
414                config: serde_json::json!({}),
415            },
416            crate::config::TransformSpec {
417                kind: "flatten".into(),
418                config: serde_json::json!({ "separator": "_" }),
419            },
420        ];
421        let snap_src = ConnectorSpec {
422            kind: "postgres".into(),
423            config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
424            transforms: None,
425            inherit_transforms: true,
426            status: None,
427            tags: Vec::new(),
428            complete_for: None,
429        };
430        let node = build_snapshot_node(&cdc, snap_src);
431        let kinds: Vec<&str> = node.transforms.iter().map(|t| t.kind.as_str()).collect();
432        assert_eq!(kinds, vec!["flatten"], "cdc_unwrap dropped, flatten kept");
433    }
434
435    #[test]
436    fn phase_failure_surfaces_phase_and_underlying_error() {
437        let summary = crate::executor::RunSummary {
438            invocations: vec![crate::executor::InvocationOutcome {
439                row_id: "snapshot".into(),
440                parent_record_key: None,
441                records_written: 0,
442                error: Some("connection refused".into()),
443                metrics: None,
444            }],
445        };
446        let err = phase_failure(&summary, "snapshot");
447        assert!(matches!(err, CliError::Internal(_)), "{err:?}");
448        let msg = format!("{err}");
449        assert!(msg.contains("snapshot"), "phase named: {msg}");
450        assert!(
451            msg.contains("connection refused"),
452            "underlying error: {msg}"
453        );
454    }
455
456    #[test]
457    fn phase_failure_falls_back_to_unknown_error() {
458        // A failed run whose invocation carries no error string still produces a
459        // descriptive error (the count-only failure case).
460        let summary = crate::executor::RunSummary {
461            invocations: vec![crate::executor::InvocationOutcome {
462                row_id: "cdc".into(),
463                parent_record_key: None,
464                records_written: 0,
465                error: None,
466                metrics: None,
467            }],
468        };
469        let err = phase_failure(&summary, "CDC");
470        let msg = format!("{err}");
471        assert!(msg.contains("CDC"), "phase named: {msg}");
472        assert!(msg.contains("unknown error"), "fallback used: {msg}");
473    }
474}