faucet_cli/replication/
compiled.rs1use crate::config::{ConnectorSpec, PipelineConfig};
4use crate::error::{CliError, CliResult};
5use crate::replication::spec::ReplicationSpec;
6
7#[derive(Debug, Clone)]
9pub struct CompiledReplication {
10 pub snapshot_source: ConnectorSpec,
12 pub continuous: bool,
14}
15
16impl CompiledReplication {
17 pub fn compile(spec: &ReplicationSpec, cfg: &PipelineConfig) -> CliResult<Self> {
22 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 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 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)?; let sink = cfg.pipeline.sink.as_ref().ok_or_else(|| {
57 CliError::Config("replication requires `pipeline.sink` (the destination)".into())
58 })?;
59 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 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 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 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 assert!(format!("{err}").contains("CDC source"), "{err}");
186 }
187
188 #[test]
189 fn rejects_unknown_snapshot_source_kind() {
190 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 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 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}