Skip to main content

faucet_core/
write_mode.rs

1//! Unified write-mode types + planner shared by every upsert-capable sink.
2
3use crate::error::FaucetError;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::collections::HashMap;
7
8/// Write semantics for a sink. Serialized snake_case. Default `Append`.
9#[derive(
10    Debug, Clone, Copy, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq,
11)]
12#[serde(rename_all = "snake_case")]
13pub enum WriteMode {
14    /// Insert every record (today's behaviour).
15    #[default]
16    Append,
17    /// Insert-or-update by `key`; optionally route delete-marked rows to deletes.
18    Upsert,
19    /// Delete by `key` for every record.
20    Delete,
21}
22
23impl WriteMode {
24    /// Lowercase wire name, for error messages.
25    pub fn as_str(&self) -> &'static str {
26        match self {
27            WriteMode::Append => "append",
28            WriteMode::Upsert => "upsert",
29            WriteMode::Delete => "delete",
30        }
31    }
32}
33
34/// Identifies a record as a delete (vs. an upsert) by a marker field's value.
35/// e.g. `{ field: "__op", values: ["d", "delete"] }`.
36#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
37pub struct DeleteMarker {
38    /// Field name whose value flags a delete.
39    pub field: String,
40    /// Values of `field` that mean "this row is a delete".
41    pub values: Vec<String>,
42}
43
44/// Shared write-mode config, embedded in each upsert-capable sink config via
45/// `#[serde(flatten)]` so `write_mode` / `key` / `delete_marker` appear at the
46/// sink-config top level.
47#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
48pub struct WriteSpec {
49    /// Append (default), upsert, or delete.
50    #[serde(default)]
51    pub write_mode: WriteMode,
52    /// Key columns. Required and non-empty for upsert/delete; ignored for append.
53    #[serde(default)]
54    pub key: Vec<String>,
55    /// Optional. Upsert only: rows whose `field` matches one of `values` are
56    /// deletes; all others are upserts. The marker field is stripped from
57    /// upsert rows before writing.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub delete_marker: Option<DeleteMarker>,
60}
61
62impl WriteSpec {
63    /// Validate internal consistency at config-load time.
64    pub fn validate(&self) -> Result<(), FaucetError> {
65        if matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && self.key.is_empty() {
66            return Err(FaucetError::Config(format!(
67                "write_mode: {} requires a non-empty `key`",
68                self.write_mode.as_str()
69            )));
70        }
71        Ok(())
72    }
73
74    /// Whether this spec makes writes converge by key — `write_mode: upsert`
75    /// or `delete` with a non-empty `key`. The canonical implementation of
76    /// [`Sink::dedups_by_key`](crate::Sink::dedups_by_key) for sinks that
77    /// flatten a `WriteSpec` into their config.
78    pub fn dedups_by_key(&self) -> bool {
79        matches!(self.write_mode, WriteMode::Upsert | WriteMode::Delete) && !self.key.is_empty()
80    }
81}
82
83/// Ordered key column → value pairs, in `key` declaration order.
84#[derive(Debug, Clone, PartialEq)]
85pub struct KeyTuple(pub Vec<(String, Value)>);
86
87/// The partition of a page by write mode. Infallible to build — per-row
88/// failures (missing/null key) land in `failed` with their original page index
89/// so the caller can route them to a DLQ or abort.
90#[derive(Debug, Default)]
91pub struct WritePlan {
92    /// Rows to insert-or-update, deduped (last-write-wins), marker stripped.
93    pub upserts: Vec<Value>,
94    /// Key tuples to delete, deduped.
95    pub deletes: Vec<KeyTuple>,
96    /// `(page_index, message)` for rows whose key could not be extracted.
97    pub failed: Vec<(usize, String)>,
98}
99
100#[derive(Clone)]
101enum Action {
102    Upsert(Value),
103    Delete(KeyTuple),
104}
105
106/// Partition `page` into upserts + deletes per `spec`. The single place all six
107/// sinks share. `WriteMode::Append` should never reach here (callers route
108/// append separately); if it does, every row is treated as an upsert.
109pub fn plan_writes(page: &[Value], spec: &WriteSpec) -> WritePlan {
110    debug_assert!(
111        spec.write_mode != WriteMode::Append,
112        "plan_writes called with WriteMode::Append — callers must route append separately"
113    );
114    let mut plan = WritePlan::default();
115    let mut index: HashMap<String, usize> = HashMap::new();
116    let mut order: Vec<Action> = Vec::new();
117
118    for (i, rec) in page.iter().enumerate() {
119        let key_tuple = match extract_key(rec, &spec.key) {
120            Ok(k) => k,
121            Err(msg) => {
122                plan.failed.push((i, msg));
123                continue;
124            }
125        };
126        let canon = canonical(&key_tuple);
127
128        let is_delete = match spec.write_mode {
129            WriteMode::Delete => true,
130            WriteMode::Upsert => is_delete_marked(rec, spec.delete_marker.as_ref()),
131            WriteMode::Append => false,
132        };
133
134        let action = if is_delete {
135            Action::Delete(key_tuple)
136        } else {
137            Action::Upsert(strip_marker(rec.clone(), spec.delete_marker.as_ref()))
138        };
139
140        match index.get(&canon) {
141            Some(&slot) => order[slot] = action,
142            None => {
143                index.insert(canon, order.len());
144                order.push(action);
145            }
146        }
147    }
148
149    for action in order {
150        match action {
151            Action::Upsert(v) => plan.upserts.push(v),
152            Action::Delete(k) => plan.deletes.push(k),
153        }
154    }
155    plan
156}
157
158/// Pull the key columns out of a record in `key` order. Missing key or null
159/// key value is an error.
160fn extract_key(rec: &Value, key: &[String]) -> Result<KeyTuple, String> {
161    let obj = rec
162        .as_object()
163        .ok_or_else(|| "record is not a JSON object".to_string())?;
164    let mut out = Vec::with_capacity(key.len());
165    for col in key {
166        match obj.get(col) {
167            None => return Err(format!("missing key column '{col}'")),
168            Some(Value::Null) => return Err(format!("null value for key column '{col}'")),
169            Some(v) => out.push((col.clone(), v.clone())),
170        }
171    }
172    Ok(KeyTuple(out))
173}
174
175fn is_delete_marked(rec: &Value, marker: Option<&DeleteMarker>) -> bool {
176    let Some(dm) = marker else { return false };
177    let Some(v) = rec.get(&dm.field) else {
178        return false;
179    };
180    let Some(s) = v.as_str() else { return false };
181    dm.values.iter().any(|m| m == s)
182}
183
184fn strip_marker(mut rec: Value, marker: Option<&DeleteMarker>) -> Value {
185    if let (Some(dm), Value::Object(map)) = (marker, &mut rec) {
186        map.remove(&dm.field);
187    }
188    rec
189}
190
191/// Stable canonical string for a key tuple, for dedup.
192fn canonical(k: &KeyTuple) -> String {
193    let arr: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
194    serde_json::to_string(&arr).expect("a Vec<&serde_json::Value> always serializes")
195}
196
197/// Render a key tuple into a single document id (Elasticsearch `_id`).
198///
199/// A single-column key is rendered as its plain string / JSON form (no
200/// separator can collide). A **composite** key is rendered as a canonical JSON
201/// array of its values rather than a separator-join: a plain join is not
202/// injective — e.g. `["a_", "b"]` and `["a", "_b"]` both collapse to `"a__b"`
203/// under separator `"_"`, silently overwriting two distinct rows with one. JSON
204/// encoding escapes any separator-like characters in the values, so distinct key
205/// tuples always map to distinct ids.
206///
207/// Assumes each key column has a consistent JSON type across records (the
208/// normal case for SQL and CDC sources); it does not disambiguate, e.g., the
209/// integer `7` from the string `"7"` in the same column.
210pub fn key_to_doc_id(k: &KeyTuple, separator: &str) -> String {
211    let _ = separator; // retained for API stability; no separator can collide now
212    if k.0.len() == 1 {
213        return match &k.0[0].1 {
214            Value::String(s) => s.clone(),
215            other => other.to_string(),
216        };
217    }
218    let values: Vec<&Value> = k.0.iter().map(|(_, v)| v).collect();
219    serde_json::to_string(&values).expect("a Vec<&serde_json::Value> always serializes")
220}
221
222/// Build a Mongo/ES filter document `{ col: value, … }` from a key tuple.
223pub fn key_to_filter(k: &KeyTuple) -> Map<String, Value> {
224    k.0.iter().map(|(c, v)| (c.clone(), v.clone())).collect()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use serde_json::json;
231
232    fn upsert_spec(keys: &[&str]) -> WriteSpec {
233        WriteSpec {
234            write_mode: WriteMode::Upsert,
235            key: keys.iter().map(|s| s.to_string()).collect(),
236            delete_marker: None,
237        }
238    }
239
240    #[test]
241    fn upsert_extracts_key_and_keeps_row() {
242        let plan = plan_writes(&[json!({"id": 1, "name": "a"})], &upsert_spec(&["id"]));
243        assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
244        assert!(plan.deletes.is_empty());
245        assert!(plan.failed.is_empty());
246    }
247
248    #[test]
249    fn key_to_doc_id_single_key_is_plain() {
250        let k = KeyTuple(vec![("id".into(), json!(7))]);
251        assert_eq!(key_to_doc_id(&k, "_"), "7");
252        let k = KeyTuple(vec![("name".into(), json!("alice"))]);
253        assert_eq!(key_to_doc_id(&k, "_"), "alice");
254    }
255
256    #[test]
257    fn key_to_doc_id_composite_is_injective() {
258        // ["a_", "b"] and ["a", "_b"] must NOT collide (the F13 separator bug).
259        let k1 = KeyTuple(vec![("x".into(), json!("a_")), ("y".into(), json!("b"))]);
260        let k2 = KeyTuple(vec![("x".into(), json!("a")), ("y".into(), json!("_b"))]);
261        let id1 = key_to_doc_id(&k1, "_");
262        let id2 = key_to_doc_id(&k2, "_");
263        assert_ne!(id1, id2, "distinct composite keys must map to distinct ids");
264        // Mixed types also stay distinct.
265        let k3 = KeyTuple(vec![("x".into(), json!(1)), ("y".into(), json!("2"))]);
266        let k4 = KeyTuple(vec![("x".into(), json!("1")), ("y".into(), json!(2))]);
267        assert_ne!(key_to_doc_id(&k3, "_"), key_to_doc_id(&k4, "_"));
268    }
269
270    #[test]
271    fn missing_key_goes_to_failed_with_original_index() {
272        let plan = plan_writes(
273            &[json!({"id": 1}), json!({"name": "no-key"})],
274            &upsert_spec(&["id"]),
275        );
276        assert_eq!(plan.upserts.len(), 1);
277        assert_eq!(plan.failed.len(), 1);
278        assert_eq!(plan.failed[0].0, 1, "failed row keeps its page index");
279    }
280
281    #[test]
282    fn null_key_value_is_a_failure() {
283        let plan = plan_writes(&[json!({"id": null})], &upsert_spec(&["id"]));
284        assert!(plan.upserts.is_empty());
285        assert_eq!(plan.failed.len(), 1);
286    }
287
288    #[test]
289    fn delete_marker_routes_to_deletes_and_strips_marker() {
290        let spec = WriteSpec {
291            write_mode: WriteMode::Upsert,
292            key: vec!["id".into()],
293            delete_marker: Some(DeleteMarker {
294                field: "__op".into(),
295                values: vec!["d".into()],
296            }),
297        };
298        let plan = plan_writes(
299            &[
300                json!({"id": 1, "name": "a", "__op": "u"}),
301                json!({"id": 2, "__op": "d"}),
302            ],
303            &spec,
304        );
305        assert_eq!(plan.upserts, vec![json!({"id": 1, "name": "a"})]);
306        assert_eq!(plan.deletes.len(), 1);
307        assert_eq!(plan.deletes[0].0, vec![("id".to_string(), json!(2))]);
308    }
309
310    #[test]
311    fn last_write_wins_dedup_keeps_final_upsert() {
312        let plan = plan_writes(
313            &[json!({"id": 1, "v": "old"}), json!({"id": 1, "v": "new"})],
314            &upsert_spec(&["id"]),
315        );
316        assert_eq!(plan.upserts, vec![json!({"id": 1, "v": "new"})]);
317    }
318
319    #[test]
320    fn last_write_wins_delete_after_upsert_is_a_delete() {
321        let spec = WriteSpec {
322            write_mode: WriteMode::Upsert,
323            key: vec!["id".into()],
324            delete_marker: Some(DeleteMarker {
325                field: "__op".into(),
326                values: vec!["d".into()],
327            }),
328        };
329        let plan = plan_writes(
330            &[json!({"id": 1, "__op": "u"}), json!({"id": 1, "__op": "d"})],
331            &spec,
332        );
333        assert!(plan.upserts.is_empty());
334        assert_eq!(plan.deletes.len(), 1);
335    }
336
337    #[test]
338    fn delete_mode_routes_every_row_to_deletes() {
339        let spec = WriteSpec {
340            write_mode: WriteMode::Delete,
341            key: vec!["id".into()],
342            delete_marker: None,
343        };
344        let plan = plan_writes(&[json!({"id": 1}), json!({"id": 2})], &spec);
345        assert!(plan.upserts.is_empty());
346        assert_eq!(plan.deletes.len(), 2);
347    }
348
349    #[test]
350    fn composite_key_tuple_is_ordered() {
351        let plan = plan_writes(
352            &[json!({"a": 1, "b": 2, "v": 9})],
353            &upsert_spec(&["a", "b"]),
354        );
355        assert_eq!(plan.upserts.len(), 1);
356        let plan2 = plan_writes(
357            &[
358                json!({"a": 1, "b": 2, "v": "x"}),
359                json!({"a": 1, "b": 3, "v": "y"}),
360            ],
361            &upsert_spec(&["a", "b"]),
362        );
363        assert_eq!(plan2.upserts.len(), 2, "(1,2) and (1,3) are distinct keys");
364    }
365
366    #[test]
367    fn validate_rejects_upsert_without_key() {
368        let spec = WriteSpec {
369            write_mode: WriteMode::Upsert,
370            key: vec![],
371            delete_marker: None,
372        };
373        assert!(spec.validate().is_err());
374    }
375
376    #[test]
377    fn validate_allows_append_without_key() {
378        assert!(WriteSpec::default().validate().is_ok());
379    }
380
381    #[test]
382    fn dedups_by_key_requires_keyed_upsert_or_delete() {
383        assert!(!WriteSpec::default().dedups_by_key());
384        let upsert = WriteSpec {
385            write_mode: WriteMode::Upsert,
386            key: vec!["id".into()],
387            delete_marker: None,
388        };
389        assert!(upsert.dedups_by_key());
390        let delete = WriteSpec {
391            write_mode: WriteMode::Delete,
392            key: vec!["id".into()],
393            delete_marker: None,
394        };
395        assert!(delete.dedups_by_key());
396        // An (invalid) keyless upsert never claims keyed dedup.
397        let keyless = WriteSpec {
398            write_mode: WriteMode::Upsert,
399            key: vec![],
400            delete_marker: None,
401        };
402        assert!(!keyless.dedups_by_key());
403    }
404
405    #[test]
406    fn last_write_wins_upsert_after_delete_is_an_upsert() {
407        // Inverse of the delete-after-upsert case: [delete, upsert] → upsert wins.
408        let spec = WriteSpec {
409            write_mode: WriteMode::Upsert,
410            key: vec!["id".into()],
411            delete_marker: Some(DeleteMarker {
412                field: "__op".into(),
413                values: vec!["d".into()],
414            }),
415        };
416        let plan = plan_writes(
417            &[
418                json!({"id": 1, "__op": "d"}),
419                json!({"id": 1, "v": 9, "__op": "u"}),
420            ],
421            &spec,
422        );
423        assert!(plan.deletes.is_empty());
424        assert_eq!(plan.upserts, vec![json!({"id": 1, "v": 9})]);
425    }
426
427    #[test]
428    fn empty_page_produces_empty_plan() {
429        let plan = plan_writes(&[], &upsert_spec(&["id"]));
430        assert!(plan.upserts.is_empty());
431        assert!(plan.deletes.is_empty());
432        assert!(plan.failed.is_empty());
433    }
434
435    #[test]
436    fn delete_mode_dedups_repeated_key() {
437        // Same key deleted twice in one page collapses to a single delete.
438        let spec = WriteSpec {
439            write_mode: WriteMode::Delete,
440            key: vec!["id".into()],
441            delete_marker: None,
442        };
443        let plan = plan_writes(&[json!({"id": 1}), json!({"id": 1})], &spec);
444        assert_eq!(plan.deletes.len(), 1);
445    }
446}