Skip to main content

faucet_core/
cleanup.rs

1//! Scoped cleanup — delete destination rows a run did not write (#478).
2//!
3//! An incremental sync into an upsert sink **cannot remove records deleted at the
4//! source**: a record that disappears simply stops appearing in the
5//! "updated since X" feed, so `write_mode: upsert` keeps it in the destination
6//! forever. The destination looks healthy, the run reports success, and stale
7//! rows accumulate indefinitely with nothing surfacing the divergence.
8//!
9//! Scoped cleanup closes that hole for the case where the source can make a
10//! **completeness claim**: "for scope S, these are *all* the records". The
11//! canonical shape is a parent/child incremental sync — a child row fetching one
12//! contact's associations is authoritative for `contact_id = <that contact>`.
13//!
14//! ## Why the claim comes from the source
15//!
16//! A sink cannot make it. It observes a page of records and cannot distinguish a
17//! complete set from page 1 of 3, or from a partial page preceding a failure. Two
18//! further reasons the scope is declared upstream rather than on the sink:
19//!
20//! 1. **Drift safety.** The predicate already exists in the source config
21//!    (`/contacts/${contacts.id}/associations`). Declaring it a second time on
22//!    the sink means two copies of one predicate, and when they diverge the
23//!    pipeline deletes the wrong rows.
24//! 2. **The empty-result case, which is decisive.** A contact that had five
25//!    associations and now has none produces a fetch returning **zero records**.
26//!    Any design inferring scopes from observed records never learns the scope
27//!    existed, so the five stale rows survive — i.e. it fails precisely the case
28//!    the feature exists to fix. A scope declared by the invocation comes from
29//!    the parent record and survives an empty result set.
30//!
31//! ## When it runs
32//!
33//! **Once per invocation, after a fully successful run — never per page.** Two
34//! ways to get this wrong, both silent data loss:
35//!
36//! - *Per page*: a scope's records spanning two pages → page 2's
37//!   "delete what I didn't see" wipes what page 1 just wrote.
38//! - *After a partial run*: the fetch dies at 40% → the delete removes the 60%
39//!   that had not yet arrived.
40//!
41//! So [`run_stream`](crate::run_stream) only invokes it when the stream reached
42//! its natural end **uncancelled**, and the CLI additionally attaches the policy
43//! only for real root invocations (never `--dry-run` / `--limit` / a shard).
44//!
45//! ## Key accumulation and the ceiling
46//!
47//! Deleting "what this run did not write" requires knowing what it wrote. The
48//! pipeline accumulates the **key tuples only** (not records) as pages are
49//! written — see [`SeenKeys`]. That is bounded by the scope's row count, which is
50//! small for the per-parent shape this targets, so a ceiling
51//! ([`CleanupPolicy::max_keys`]) guards the pathological case: on breach the
52//! cleanup **aborts with a typed error and deletes nothing**, rather than issuing
53//! a partial delete that would destroy rows it simply forgot about.
54
55use crate::error::FaucetError;
56use crate::traits::Sink;
57use crate::write_mode::KeyTuple;
58use serde::{Deserialize, Serialize};
59use serde_json::Value;
60use std::collections::BTreeMap;
61
62/// Default ceiling on accumulated keys for one invocation's cleanup.
63///
64/// Sized for the shape this feature targets (a scope per parent record — tens to
65/// low thousands of rows). Above it the cleanup refuses rather than guessing.
66pub const DEFAULT_MAX_KEYS: usize = 100_000;
67
68/// What to do about destination rows inside the claimed scope that this run did
69/// not write.
70///
71/// Serialized as the sink-config field `cleanup:` via
72/// [`WriteSpec`](crate::write_mode::WriteSpec), so every upsert-capable sink
73/// accepts it without a per-connector config change.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
75#[serde(rename_all = "snake_case")]
76pub enum CleanupMode {
77    /// Delete rows in the scope whose key was not written by this invocation.
78    DeleteMissing,
79}
80
81/// A compiled cleanup instruction for one invocation.
82#[derive(Debug, Clone)]
83pub struct CleanupPolicy {
84    /// The completeness claim, in **destination column** terms: the rows this
85    /// invocation is authoritative for. Every entry is an equality predicate,
86    /// AND-ed together.
87    ///
88    /// Destination terms rather than source terms because the `DELETE` executes
89    /// against destination columns, and a transform chain may rename fields
90    /// between the two.
91    pub scope: BTreeMap<String, Value>,
92    /// Key columns identifying a row — mirrors the sink's `key`.
93    pub key: Vec<String>,
94    /// Ceiling on accumulated keys before the cleanup refuses (see module docs).
95    pub max_keys: usize,
96}
97
98impl CleanupPolicy {
99    /// Build a policy, validating it is actionable.
100    pub fn new(
101        scope: BTreeMap<String, Value>,
102        key: Vec<String>,
103        max_keys: usize,
104    ) -> Result<Self, FaucetError> {
105        if scope.is_empty() {
106            // An empty scope is an every-row predicate. Refusing here is the
107            // difference between "delete this contact's stale rows" and
108            // "truncate the table".
109            return Err(FaucetError::Config(
110                "cleanup: the completeness claim (`complete_for`) must name at least one \
111                 column — an empty scope would match every row in the destination"
112                    .into(),
113            ));
114        }
115        if key.is_empty() {
116            return Err(FaucetError::Config(
117                "cleanup: requires a non-empty `key` so a written row can be told apart \
118                 from a stale one"
119                    .into(),
120            ));
121        }
122        if scope.values().any(Value::is_null) {
123            return Err(FaucetError::Config(
124                "cleanup: the completeness claim contains a null value — an unresolved \
125                 scope token would delete the wrong rows"
126                    .into(),
127            ));
128        }
129        Ok(Self {
130            scope,
131            key,
132            max_keys: max_keys.max(1),
133        })
134    }
135}
136
137/// The set of key tuples an invocation wrote, accumulated across pages.
138///
139/// Deliberately stores only keys, not records: memory is O(rows in scope × key
140/// width) rather than O(payload).
141#[derive(Debug, Default)]
142pub struct SeenKeys {
143    keys: Vec<KeyTuple>,
144    /// Set once the ceiling is breached. Sticky: a cleanup that lost track of
145    /// even one key must not run at all.
146    overflowed: bool,
147}
148
149impl SeenKeys {
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    /// Record the keys written for one page. Rows missing a key column, or
155    /// carrying a null there, are ignored — they cannot be matched by a keyed
156    /// delete anyway, and the write path already routes them to the DLQ or fails
157    /// the batch.
158    pub fn record_page(&mut self, page: &[Value], key: &[String], max_keys: usize) {
159        if self.overflowed {
160            return;
161        }
162        for rec in page {
163            let Some(obj) = rec.as_object() else { continue };
164            let mut tuple = Vec::with_capacity(key.len());
165            let mut complete = true;
166            for k in key {
167                match obj.get(k) {
168                    Some(v) if !v.is_null() => tuple.push((k.clone(), v.clone())),
169                    _ => {
170                        complete = false;
171                        break;
172                    }
173                }
174            }
175            if !complete {
176                continue;
177            }
178            if self.keys.len() >= max_keys {
179                self.overflowed = true;
180                self.keys.clear(); // free the buffer; the cleanup will refuse
181                return;
182            }
183            self.keys.push(KeyTuple(tuple));
184        }
185    }
186
187    /// Whether the ceiling was breached, which makes the cleanup unsafe to run.
188    pub fn overflowed(&self) -> bool {
189        self.overflowed
190    }
191
192    pub fn len(&self) -> usize {
193        self.keys.len()
194    }
195
196    pub fn is_empty(&self) -> bool {
197        self.keys.is_empty()
198    }
199
200    pub fn keys(&self) -> &[KeyTuple] {
201        &self.keys
202    }
203
204    /// The typed error a breached ceiling produces. Separate from the accumulator
205    /// so the caller decides whether to fail the run or log — but never to
206    /// delete.
207    pub fn overflow_error(&self, max_keys: usize) -> FaucetError {
208        FaucetError::Config(format!(
209            "cleanup: this invocation wrote more than {max_keys} rows in the claimed scope, \
210             so the set of written keys could not be tracked. Nothing was deleted — a \
211             partial delete would remove rows the run actually wrote. Narrow the scope \
212             (a smaller `complete_for`), or raise the ceiling if the destination can take \
213             a delete of this size"
214        ))
215    }
216}
217
218/// A sink wrapper that records the key tuples written through it (#478).
219///
220/// This is how scoped cleanup tracks "what this run wrote" **without** adding a
221/// field to [`RunStreamOptions`](crate::RunStreamOptions), which is an
222/// externally-constructible struct whose shape is part of the public API. It is
223/// also the more honest home for the bookkeeping: the thing that writes the rows
224/// is the thing that knows which rows were written, and it composes with the
225/// existing sink-decorator pattern (`InstrumentedSink`) rather than threading a
226/// second concern through the page loop.
227///
228/// Every write path is counted, including [`write_batch_partial`](Sink::write_batch_partial).
229/// That is deliberate: a row handed to the sink that fails and lands in the DLQ
230/// is still a record the source claimed present, so it must count as seen or the
231/// cleanup would delete its destination row.
232///
233/// Records that never reach the sink at all — quarantined by a quality, contract,
234/// or drift policy — are consequently *not* counted, which is why those
235/// combinations are rejected at config-load time rather than silently deleting
236/// the quarantined rows' destination counterparts.
237pub struct CleanupTracker<'a, S: Sink + ?Sized> {
238    inner: &'a S,
239    key: Vec<String>,
240    max_keys: usize,
241    seen: std::sync::Mutex<SeenKeys>,
242}
243
244impl<'a, S: Sink + ?Sized> CleanupTracker<'a, S> {
245    pub fn new(inner: &'a S, policy: &CleanupPolicy) -> Self {
246        Self {
247            inner,
248            key: policy.key.clone(),
249            max_keys: policy.max_keys,
250            seen: std::sync::Mutex::new(SeenKeys::new()),
251        }
252    }
253
254    fn record(&self, records: &[Value]) {
255        if let Ok(mut seen) = self.seen.lock() {
256            seen.record_page(records, &self.key, self.max_keys);
257        }
258    }
259
260    /// Run the scoped delete against the wrapped sink. Call **only** after a
261    /// fully successful, uncancelled run — see the module docs.
262    pub async fn finish(&self, policy: &CleanupPolicy) -> Result<u64, FaucetError> {
263        // Take the set out and drop the guard *before* awaiting: holding a
264        // `MutexGuard` across an await makes the whole run future non-`Send`,
265        // which the executor's `JoinSet` requires.
266        let seen = {
267            let mut guard = self
268                .seen
269                .lock()
270                .map_err(|_| FaucetError::Sink("cleanup: key tracker poisoned".into()))?;
271            if guard.overflowed() {
272                // Refuse rather than partially delete: the tracked set is
273                // incomplete, so a delete would remove rows the run wrote.
274                return Err(guard.overflow_error(policy.max_keys));
275            }
276            std::mem::take(&mut *guard)
277        };
278        self.inner.cleanup_scope(&policy.scope, &seen).await
279    }
280
281    /// Number of keys tracked so far (for logging).
282    pub fn tracked(&self) -> usize {
283        self.seen.lock().map(|g| g.len()).unwrap_or(0)
284    }
285}
286
287#[async_trait::async_trait]
288impl<S: Sink + ?Sized> Sink for CleanupTracker<'_, S> {
289    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
290        let n = self.inner.write_batch(records).await?;
291        self.record(records);
292        Ok(n)
293    }
294
295    async fn write_batch_partial(
296        &self,
297        records: &[Value],
298    ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
299        let out = self.inner.write_batch_partial(records).await?;
300        // Count every row handed to the sink, including the ones that failed and
301        // will be routed to the DLQ — see the type docs.
302        self.record(records);
303        Ok(out)
304    }
305
306    async fn write_batch_idempotent(
307        &self,
308        records: &[Value],
309        scope: &str,
310        token: &str,
311    ) -> Result<usize, FaucetError> {
312        let n = self
313            .inner
314            .write_batch_idempotent(records, scope, token)
315            .await?;
316        self.record(records);
317        Ok(n)
318    }
319
320    async fn flush(&self) -> Result<(), FaucetError> {
321        self.inner.flush().await
322    }
323
324    // ── Pure forwarding below ────────────────────────────────────────────────
325    fn supports_cleanup(&self) -> bool {
326        self.inner.supports_cleanup()
327    }
328    async fn cleanup_scope(
329        &self,
330        scope: &BTreeMap<String, Value>,
331        seen: &SeenKeys,
332    ) -> Result<u64, FaucetError> {
333        self.inner.cleanup_scope(scope, seen).await
334    }
335    fn supports_idempotent_writes(&self) -> bool {
336        self.inner.supports_idempotent_writes()
337    }
338    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
339        self.inner.last_committed_token(scope).await
340    }
341    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
342        self.inner.supported_write_modes()
343    }
344    fn dedups_by_key(&self) -> bool {
345        self.inner.dedups_by_key()
346    }
347    fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
348        self.inner.sink_guarantee()
349    }
350    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
351        self.inner.current_schema().await
352    }
353    fn supports_schema_evolution(&self) -> bool {
354        self.inner.supports_schema_evolution()
355    }
356    async fn evolve_schema(
357        &self,
358        evolution: &crate::drift::SchemaEvolution,
359    ) -> Result<(), FaucetError> {
360        self.inner.evolve_schema(evolution).await
361    }
362    fn config_schema(&self) -> Value {
363        self.inner.config_schema()
364    }
365    fn connector_name(&self) -> &'static str {
366        self.inner.connector_name()
367    }
368    fn dataset_uri(&self) -> String {
369        self.inner.dataset_uri()
370    }
371    fn is_overwrite(&self) -> bool {
372        self.inner.is_overwrite()
373    }
374    async fn begin_overwrite(&self) -> Result<(), FaucetError> {
375        self.inner.begin_overwrite().await
376    }
377    async fn commit_overwrite(&self) -> Result<(), FaucetError> {
378        self.inner.commit_overwrite().await
379    }
380    async fn abort_overwrite(&self) -> Result<(), FaucetError> {
381        self.inner.abort_overwrite().await
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use serde_json::json;
389
390    fn scope() -> BTreeMap<String, Value> {
391        BTreeMap::from([("contact_id".to_string(), json!(123))])
392    }
393
394    #[test]
395    fn policy_requires_a_non_empty_scope() {
396        // An empty scope is a truncate, not a cleanup.
397        let err = CleanupPolicy::new(BTreeMap::new(), vec!["id".into()], 10)
398            .expect_err("empty scope must be refused");
399        assert!(err.to_string().contains("at least one"), "{err}");
400    }
401
402    #[test]
403    fn policy_requires_a_key() {
404        let err = CleanupPolicy::new(scope(), vec![], 10).expect_err("no key must be refused");
405        assert!(err.to_string().contains("`key`"), "{err}");
406    }
407
408    #[test]
409    fn policy_refuses_a_null_scope_value() {
410        // An unresolved `${parent.id}` would land here as null and delete the
411        // wrong rows.
412        let s = BTreeMap::from([("contact_id".to_string(), Value::Null)]);
413        let err = CleanupPolicy::new(s, vec!["id".into()], 10).expect_err("null must be refused");
414        assert!(err.to_string().contains("null"), "{err}");
415    }
416
417    #[test]
418    fn policy_floors_max_keys_at_one() {
419        let p = CleanupPolicy::new(scope(), vec!["id".into()], 0).unwrap();
420        assert_eq!(p.max_keys, 1);
421    }
422
423    #[tokio::test]
424    async fn cleanup_tracker_forwards_overwrite_lifecycle() {
425        // The tracker must forward the overwrite lifecycle to the inner sink so
426        // an overwrite run still stages/swaps when scoped cleanup wraps it.
427        struct OvwSink {
428            log: std::sync::Mutex<Vec<&'static str>>,
429        }
430        #[async_trait::async_trait]
431        impl Sink for OvwSink {
432            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
433                Ok(r.len())
434            }
435            fn is_overwrite(&self) -> bool {
436                true
437            }
438            async fn begin_overwrite(&self) -> Result<(), FaucetError> {
439                self.log.lock().unwrap().push("begin");
440                Ok(())
441            }
442            async fn commit_overwrite(&self) -> Result<(), FaucetError> {
443                self.log.lock().unwrap().push("commit");
444                Ok(())
445            }
446            async fn abort_overwrite(&self) -> Result<(), FaucetError> {
447                self.log.lock().unwrap().push("abort");
448                Ok(())
449            }
450        }
451        let inner = OvwSink {
452            log: std::sync::Mutex::new(Vec::new()),
453        };
454        let policy = CleanupPolicy::new(scope(), vec!["id".into()], 10).unwrap();
455        let tracker = CleanupTracker::new(&inner, &policy);
456        assert!(tracker.is_overwrite());
457        // A write through the tracker must forward to the inner sink too.
458        assert_eq!(tracker.write_batch(&[json!({"id": 1})]).await.unwrap(), 1);
459        tracker.begin_overwrite().await.unwrap();
460        tracker.commit_overwrite().await.unwrap();
461        tracker.abort_overwrite().await.unwrap();
462        assert_eq!(*inner.log.lock().unwrap(), vec!["begin", "commit", "abort"]);
463    }
464
465    #[test]
466    fn accumulates_keys_across_pages() {
467        let mut seen = SeenKeys::new();
468        let key = vec!["id".to_string()];
469        seen.record_page(&[json!({"id": 1}), json!({"id": 2})], &key, 100);
470        seen.record_page(&[json!({"id": 3})], &key, 100);
471        assert_eq!(seen.len(), 3);
472        assert!(!seen.overflowed());
473    }
474
475    #[test]
476    fn accumulates_composite_keys_in_declared_order() {
477        let mut seen = SeenKeys::new();
478        let key = vec!["a".to_string(), "b".to_string()];
479        seen.record_page(&[json!({"b": 2, "a": 1})], &key, 100);
480        assert_eq!(seen.len(), 1);
481        let t = &seen.keys()[0].0;
482        assert_eq!(
483            t[0].0, "a",
484            "key order follows the declared `key`, not the record"
485        );
486        assert_eq!(t[1].0, "b");
487    }
488
489    #[test]
490    fn skips_rows_with_a_missing_or_null_key() {
491        let mut seen = SeenKeys::new();
492        let key = vec!["id".to_string()];
493        seen.record_page(
494            &[
495                json!({"id": 1}),
496                json!({"other": 9}), // missing key
497                json!({"id": null}), // null key
498                json!("not an object"),
499            ],
500            &key,
501            100,
502        );
503        assert_eq!(seen.len(), 1, "only the well-keyed row is tracked");
504    }
505
506    #[test]
507    fn overflow_is_sticky_and_frees_the_buffer() {
508        let mut seen = SeenKeys::new();
509        let key = vec!["id".to_string()];
510        let page: Vec<Value> = (0..5).map(|i| json!({"id": i})).collect();
511        seen.record_page(&page, &key, 3);
512        assert!(seen.overflowed(), "ceiling of 3 must trip on a 5-row page");
513        assert!(seen.is_empty(), "buffer is freed — the cleanup will refuse");
514        // Sticky: a later page cannot un-overflow it.
515        seen.record_page(&[json!({"id": 99})], &key, 3);
516        assert!(seen.overflowed());
517        assert!(seen.is_empty());
518    }
519
520    #[test]
521    fn overflow_error_explains_that_nothing_was_deleted() {
522        let seen = SeenKeys::new();
523        let msg = seen.overflow_error(50).to_string();
524        assert!(msg.contains("Nothing was deleted"), "{msg}");
525        assert!(msg.contains("50"), "{msg}");
526    }
527}