Skip to main content

faucet_cli/replication/
compiled.rs

1//! Load-time validation of the `replication:` block against the pipeline.
2
3use crate::config::{ConnectorSpec, PipelineConfig};
4use crate::error::{CliError, CliResult};
5use crate::replication::spec::ReplicationSpec;
6
7/// Validated replication config, ready for the orchestrator.
8#[derive(Debug, Clone)]
9pub struct CompiledReplication {
10    /// The bulk-read snapshot source (phase 1).
11    pub snapshot_source: ConnectorSpec,
12    /// Keep streaming CDC after the snapshot completes.
13    pub continuous: bool,
14}
15
16impl CompiledReplication {
17    /// Validate every replication-specific requirement up front so
18    /// `faucet validate` / `faucet replicate` fail fast with a clear message.
19    /// The generic per-row gates (exactly-once, write_mode×sink) are enforced
20    /// separately by [`crate::expand::expand`].
21    pub fn compile(spec: &ReplicationSpec, cfg: &PipelineConfig) -> CliResult<Self> {
22        // No matrix fan-out in v1 — replication is a single pipeline.
23        if !cfg.matrix.is_empty() {
24            return Err(CliError::Config(
25                "replication does not support a `matrix:` — define a single CDC \
26                 pipeline (pipeline.source + pipeline.sink) plus replication.snapshot"
27                    .into(),
28            ));
29        }
30        // The main pipeline.source must be a capture-capable CDC source.
31        let cdc = cfg.pipeline.source.as_ref().ok_or_else(|| {
32            CliError::Config(
33                "replication requires `pipeline.source` to be the CDC source \
34                 (postgres-cdc / mysql-cdc / mongodb-cdc)"
35                    .into(),
36            )
37        })?;
38        if !crate::registry::source_supports_exactly_once(&cdc.kind) {
39            return Err(CliError::Config(format!(
40                "replication `pipeline.source` must be a CDC source \
41                 (postgres-cdc / mysql-cdc / mongodb-cdc); got '{}'",
42                cdc.kind
43            )));
44        }
45        // The snapshot source must be a non-CDC bulk reader, and must exist.
46        let snap = &spec.snapshot.source;
47        if crate::registry::source_supports_exactly_once(&snap.kind) {
48            return Err(CliError::Config(format!(
49                "replication.snapshot.source must be a non-CDC bulk source \
50                 (e.g. postgres / mysql / mongodb); got CDC source '{}'",
51                snap.kind
52            )));
53        }
54        crate::registry::source_schema(&snap.kind)?; // typed UnknownConnector if absent
55        // A destination sink is required.
56        let sink = cfg.pipeline.sink.as_ref().ok_or_else(|| {
57            CliError::Config("replication requires `pipeline.sink` (the destination)".into())
58        })?;
59        // A durable, shared state backend is required: the orchestrator seeds
60        // the CDC bookmark and persists the phase marker, and the executor must
61        // read them back. `memory` is per-instance (not shared) and would also
62        // lose the phase marker on restart, defeating resumability.
63        let state = cfg.pipeline.state.as_ref().ok_or_else(|| {
64            CliError::Config(
65                "replication requires a `state:` store (for the phase + bookmark)".into(),
66            )
67        })?;
68        if state.kind == "memory" {
69            return Err(CliError::Config(
70                "replication requires a durable state backend (file / redis / postgres), \
71                 not `memory` — the snapshot→CDC handoff and resume depend on it"
72                    .into(),
73            ));
74        }
75        // Recommend upsert for a true mirror; warn (don't fail) otherwise.
76        let write_mode = sink
77            .config
78            .get("write_mode")
79            .and_then(|v| v.as_str())
80            .unwrap_or("append");
81        if write_mode != "upsert" {
82            tracing::warn!(
83                write_mode,
84                "replication sink is not in upsert mode — the snapshot↔CDC boundary may \
85                 produce duplicate rows; use write_mode: upsert (with a key) for a true mirror"
86            );
87        }
88        Ok(Self {
89            snapshot_source: snap.clone(),
90            continuous: spec.continuous,
91        })
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::config::parse_with_extension;
99
100    fn cfg(yaml: &str) -> PipelineConfig {
101        parse_with_extension(yaml, "yaml").unwrap()
102    }
103
104    const GOOD: &str = r#"
105version: 1
106name: mirror
107pipeline:
108  source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
109  sink:   { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
110  state:  { type: file, config: { path: ./st } }
111replication:
112  mode: snapshot_then_cdc
113  snapshot:
114    source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
115"#;
116
117    #[test]
118    fn accepts_valid_config() {
119        let c = cfg(GOOD);
120        let r = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap();
121        assert_eq!(r.snapshot_source.kind, "postgres");
122        assert!(r.continuous);
123    }
124
125    #[test]
126    fn rejects_non_cdc_pipeline_source() {
127        let c = cfg(&GOOD.replace("postgres-cdc", "postgres"));
128        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
129        assert!(format!("{err}").contains("CDC source"), "{err}");
130    }
131
132    #[test]
133    fn rejects_memory_state() {
134        let c = cfg(&GOOD.replace(
135            "type: file, config: { path: ./st }",
136            "type: memory, config: {}",
137        ));
138        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
139        assert!(format!("{err}").contains("durable state"), "{err}");
140    }
141
142    #[test]
143    fn rejects_cdc_snapshot_source() {
144        let bad = GOOD.replace(
145            "source: { type: postgres, config: { connection_url: \"postgres://x\", query: \"SELECT * FROM t\" } }",
146            "source: { type: postgres-cdc, config: {} }",
147        );
148        let c = cfg(&bad);
149        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
150        assert!(format!("{err}").contains("non-CDC"), "{err}");
151    }
152
153    #[test]
154    fn rejects_matrix() {
155        let bad = format!("{GOOD}matrix:\n  - id: a\n");
156        let c = cfg(&bad);
157        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
158        assert!(format!("{err}").contains("matrix"), "{err}");
159    }
160
161    #[test]
162    fn rejects_missing_sink() {
163        // Drop the `sink:` line entirely — `pipeline.sink` is then `None`.
164        let bad = GOOD
165            .lines()
166            .filter(|l| !l.trim_start().starts_with("sink:"))
167            .collect::<Vec<_>>()
168            .join("\n");
169        let c = cfg(&bad);
170        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
171        assert!(format!("{err}").contains("sink"), "{err}");
172    }
173
174    #[test]
175    fn rejects_missing_source() {
176        // Drop the `source:` line — `pipeline.source` is then `None`.
177        let bad = GOOD
178            .lines()
179            .filter(|l| !l.trim_start().starts_with("source: { type: postgres-cdc"))
180            .collect::<Vec<_>>()
181            .join("\n");
182        let c = cfg(&bad);
183        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
184        // The CDC-source error message names the CDC source requirement.
185        assert!(format!("{err}").contains("CDC source"), "{err}");
186    }
187
188    #[test]
189    fn rejects_unknown_snapshot_source_kind() {
190        // A snapshot source kind that isn't a registered connector is rejected by
191        // `registry::source_schema` (typed UnknownConnector).
192        let bad = GOOD.replace(
193            "source: { type: postgres, config: { connection_url: \"postgres://x\", query: \"SELECT * FROM t\" } }",
194            "source: { type: not_a_source, config: {} }",
195        );
196        let c = cfg(&bad);
197        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
198        assert!(format!("{err}").contains("not_a_source"), "{err}");
199    }
200
201    #[test]
202    fn rejects_missing_state() {
203        // Drop the `state:` line — `pipeline.state` is then `None`.
204        let bad = GOOD
205            .lines()
206            .filter(|l| !l.trim_start().starts_with("state:"))
207            .collect::<Vec<_>>()
208            .join("\n");
209        let c = cfg(&bad);
210        let err = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c).unwrap_err();
211        assert!(format!("{err}").contains("state"), "{err}");
212    }
213
214    #[test]
215    fn non_upsert_sink_compiles_ok_with_warning() {
216        // A sink with `write_mode: append` (or no write_mode) still compiles —
217        // `compile` only warns (it does not fail) so a non-mirror replication is
218        // allowed. This exercises the warn branch.
219        let appendish = GOOD.replace(", write_mode: upsert, key: [id]", "");
220        let c = cfg(&appendish);
221        let r = CompiledReplication::compile(c.replication.as_ref().unwrap(), &c)
222            .expect("non-upsert sink should still compile (warn, not fail)");
223        assert_eq!(r.snapshot_source.kind, "postgres");
224    }
225}