use crate::auth_catalog::AuthCatalog;
use crate::config::{ExecutionSpec, PipelineConfig};
use crate::error::{CliError, CliResult};
use crate::executor::{ExecuteOptions, run_expanded};
use crate::expand::{ExpandedNode, expand};
use crate::registry::build_source;
use crate::replication::compiled::CompiledReplication;
use crate::replication::state::{
Phase, Plan, ReplicationState, cdc_state_key, marker_key, plan_from_marker,
};
use crate::state::build_state_store;
use chrono::{DateTime, FixedOffset};
use tokio_util::sync::CancellationToken;
pub struct ReplicationOptions {
pub pipeline_name: String,
pub execution: Option<ExecutionSpec>,
pub auth: AuthCatalog,
pub clock: DateTime<FixedOffset>,
pub resilience: Option<faucet_core::ResiliencePolicy>,
pub sla: Option<crate::sla::SlaSpec>,
#[cfg(feature = "notify")]
pub notifier: Option<std::sync::Arc<crate::notify::Notifier>>,
#[cfg(feature = "catalog")]
pub catalog: Option<crate::catalog::CatalogHandle>,
}
pub(crate) fn build_snapshot_node(
cdc_node: &ExpandedNode,
snapshot_source: crate::config::ConnectorSpec,
) -> ExpandedNode {
let mut n = cdc_node.clone();
n.id = "snapshot".to_string();
n.source = snapshot_source;
n.delivery = faucet_core::DeliveryMode::AtLeastOnce;
if n.delivery_guarantee
!= faucet_core::DeliveryGuarantee::EffectivelyOnce(
faucet_core::EffectivelyOnceMechanism::KeyedUpsert,
)
{
n.delivery_guarantee = faucet_core::DeliveryGuarantee::AtLeastOnce;
}
n.transforms.retain(|t| t.kind != "cdc_unwrap");
n
}
fn phase_failure(summary: &crate::executor::RunSummary, phase: &str) -> CliError {
let detail = summary
.invocations
.iter()
.find_map(|i| i.error.clone())
.unwrap_or_else(|| "unknown error".to_string());
CliError::Internal(format!("replication {phase} phase failed: {detail}"))
}
fn make_opts(opts: &ReplicationOptions, cancel: Option<CancellationToken>) -> ExecuteOptions {
ExecuteOptions {
pipeline_name: opts.pipeline_name.clone(),
execution: opts.execution.clone(),
dry_run: false,
limit: None,
state_path_override: None,
shard: None,
auth: opts.auth.clone(),
clock: opts.clock,
cancel,
resilience: opts.resilience.clone(),
sla: opts.sla.clone(),
#[cfg(feature = "lineage")]
lineage: None,
#[cfg(feature = "lineage")]
lineage_cfg: None,
#[cfg(feature = "notify")]
notifier: opts.notifier.clone(),
#[cfg(feature = "catalog")]
catalog: opts.catalog.clone(),
}
}
pub(crate) fn spawn_cancel_on_signal(token: CancellationToken) {
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
token.cancel();
});
}
pub async fn run_replication(
cfg: &PipelineConfig,
compiled: &CompiledReplication,
opts: ReplicationOptions,
) -> CliResult<()> {
let mut nodes = expand(cfg)?;
let mut cdc_node = nodes
.drain(..)
.next()
.ok_or_else(|| CliError::Internal("replication: expand produced no node".into()))?;
cdc_node.id = "cdc".to_string();
let snapshot_node = build_snapshot_node(&cdc_node, compiled.snapshot_source.clone());
let state_spec = cfg
.pipeline
.state
.as_ref()
.ok_or_else(|| CliError::Config("replication requires a state store".into()))?;
let store = build_state_store(state_spec).await?;
let marker_k = marker_key(&opts.pipeline_name);
let cdc_k = cdc_state_key(&opts.pipeline_name);
let marker = match store.get(&marker_k).await? {
Some(v) => Some(ReplicationState::from_value(v)?),
None => None,
};
if plan_from_marker(marker.as_ref()) == Plan::Bootstrap {
let cdc_source = build_source(
&cdc_node.source.kind,
cdc_node.source.config.clone(),
&opts.auth,
None,
)
.await?;
let position = cdc_source.capture_resume_position().await?.ok_or_else(|| {
CliError::Config(format!(
"replication: source '{}' does not support position capture",
cdc_node.source.kind
))
})?;
store.put(&cdc_k, &position).await?;
store
.put(
&marker_k,
&ReplicationState {
phase: Phase::Snapshot,
snapshot_done: false,
position: position.clone(),
}
.to_value()?,
)
.await?;
tracing::info!(pipeline = %opts.pipeline_name, "replication bootstrap: captured CDC position, seeded bookmark");
}
let marker = match store.get(&marker_k).await? {
Some(v) => ReplicationState::from_value(v)?,
None => {
return Err(CliError::Internal(
"replication: marker missing after bootstrap".into(),
));
}
};
let cancel = CancellationToken::new();
spawn_cancel_on_signal(cancel.clone());
if !marker.snapshot_done {
tracing::info!(pipeline = %opts.pipeline_name, "replication: running snapshot phase (Ctrl-C / SIGTERM to stop)");
let summary = run_expanded(
vec![snapshot_node.clone()],
make_opts(&opts, Some(cancel.clone())),
)
.await?;
if summary.had_failures() {
return Err(phase_failure(&summary, "snapshot"));
}
if cancel.is_cancelled() {
tracing::warn!(
pipeline = %opts.pipeline_name,
"replication: snapshot interrupted by shutdown before completion; \
it will be redone on the next run"
);
return Ok(());
}
store
.put(
&marker_k,
&ReplicationState {
phase: Phase::Cdc,
snapshot_done: true,
position: marker.position.clone(),
}
.to_value()?,
)
.await?;
tracing::info!(pipeline = %opts.pipeline_name, "replication: snapshot complete; handing off to CDC");
}
if compiled.continuous {
tracing::info!(pipeline = %opts.pipeline_name, "replication: streaming CDC (Ctrl-C / SIGTERM to stop)");
}
let mut backoff = std::time::Duration::from_secs(1);
const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(60);
loop {
let cycle: CliResult<()> = async {
let summary = run_expanded(
vec![cdc_node.clone()],
make_opts(&opts, Some(cancel.clone())),
)
.await?;
if summary.had_failures() {
return Err(phase_failure(&summary, "CDC"));
}
Ok(())
}
.await;
match cdc_loop_action(cycle.is_ok(), compiled.continuous, cancel.is_cancelled()) {
CdcLoopAction::Break => break,
CdcLoopAction::Continue => {
backoff = std::time::Duration::from_secs(1); }
CdcLoopAction::Propagate => return Err(cycle.unwrap_err()),
CdcLoopAction::Backoff => {
tracing::warn!(
pipeline = %opts.pipeline_name,
error = %cycle.unwrap_err(),
backoff_secs = backoff.as_secs(),
"replication: CDC cycle failed; resuming from bookmark after backoff"
);
tokio::select! {
biased;
_ = cancel.cancelled() => break,
_ = tokio::time::sleep(backoff) => {}
}
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CdcLoopAction {
Break,
Continue,
Propagate,
Backoff,
}
fn cdc_loop_action(cycle_ok: bool, continuous: bool, cancelled: bool) -> CdcLoopAction {
match (cycle_ok, continuous && !cancelled) {
(true, false) => CdcLoopAction::Break, (true, true) => CdcLoopAction::Continue, (false, false) => CdcLoopAction::Propagate, (false, true) => CdcLoopAction::Backoff, }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConnectorSpec;
use crate::expand::expand;
fn cdc_node() -> ExpandedNode {
let cfg = crate::config::parse_with_extension(
r#"
version: 1
pipeline:
source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
state: { type: file, config: { path: ./st } }
"#,
"yaml",
)
.unwrap();
expand(&cfg).unwrap().into_iter().next().unwrap()
}
#[test]
fn cdc_loop_action_continuous_resumes_on_transient_failure() {
assert_eq!(cdc_loop_action(false, true, false), CdcLoopAction::Backoff);
assert_eq!(cdc_loop_action(true, true, false), CdcLoopAction::Continue);
assert_eq!(cdc_loop_action(true, true, true), CdcLoopAction::Break);
assert_eq!(cdc_loop_action(false, true, true), CdcLoopAction::Propagate);
assert_eq!(cdc_loop_action(true, false, false), CdcLoopAction::Break);
assert_eq!(
cdc_loop_action(false, false, false),
CdcLoopAction::Propagate
);
}
#[test]
fn snapshot_node_swaps_source_and_forces_at_least_once() {
let mut cdc = cdc_node();
cdc.id = "cdc".into();
cdc.delivery = faucet_core::DeliveryMode::ExactlyOnce;
let snap_src = ConnectorSpec {
kind: "postgres".into(),
config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
transforms: None,
inherit_transforms: true,
status: None,
tags: Vec::new(),
};
let node = build_snapshot_node(&cdc, snap_src);
assert_eq!(node.id, "snapshot");
assert_eq!(node.source.kind, "postgres");
assert_eq!(node.sink.kind, "postgres"); assert_eq!(node.delivery, faucet_core::DeliveryMode::AtLeastOnce);
}
#[test]
fn snapshot_node_strips_cdc_unwrap_but_keeps_other_transforms() {
let mut cdc = cdc_node();
cdc.id = "cdc".into();
cdc.transforms = vec![
crate::config::TransformSpec {
kind: "cdc_unwrap".into(),
config: serde_json::json!({}),
},
crate::config::TransformSpec {
kind: "flatten".into(),
config: serde_json::json!({ "separator": "_" }),
},
];
let snap_src = ConnectorSpec {
kind: "postgres".into(),
config: serde_json::json!({ "connection_url": "postgres://x", "query": "SELECT * FROM t" }),
transforms: None,
inherit_transforms: true,
status: None,
tags: Vec::new(),
};
let node = build_snapshot_node(&cdc, snap_src);
let kinds: Vec<&str> = node.transforms.iter().map(|t| t.kind.as_str()).collect();
assert_eq!(kinds, vec!["flatten"], "cdc_unwrap dropped, flatten kept");
}
#[test]
fn phase_failure_surfaces_phase_and_underlying_error() {
let summary = crate::executor::RunSummary {
invocations: vec![crate::executor::InvocationOutcome {
row_id: "snapshot".into(),
parent_record_key: None,
records_written: 0,
error: Some("connection refused".into()),
metrics: None,
}],
};
let err = phase_failure(&summary, "snapshot");
assert!(matches!(err, CliError::Internal(_)), "{err:?}");
let msg = format!("{err}");
assert!(msg.contains("snapshot"), "phase named: {msg}");
assert!(
msg.contains("connection refused"),
"underlying error: {msg}"
);
}
#[test]
fn phase_failure_falls_back_to_unknown_error() {
let summary = crate::executor::RunSummary {
invocations: vec![crate::executor::InvocationOutcome {
row_id: "cdc".into(),
parent_record_key: None,
records_written: 0,
error: None,
metrics: None,
}],
};
let err = phase_failure(&summary, "CDC");
let msg = format!("{err}");
assert!(msg.contains("CDC"), "phase named: {msg}");
assert!(msg.contains("unknown error"), "fallback used: {msg}");
}
}