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/// Opted into on the **source**, through its `complete_for.on_missing: delete`
72/// claim; no sink config field selects it.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
74#[serde(rename_all = "snake_case")]
75pub enum CleanupMode {
76    /// Delete rows in the scope whose key was not written by this invocation.
77    DeleteMissing,
78}
79
80/// A compiled cleanup instruction for one invocation.
81#[derive(Debug, Clone)]
82pub struct CleanupPolicy {
83    /// The completeness claim, in **destination column** terms: the rows this
84    /// invocation is authoritative for. Every entry is an equality predicate,
85    /// AND-ed together.
86    ///
87    /// Destination terms rather than source terms because the `DELETE` executes
88    /// against destination columns, and a transform chain may rename fields
89    /// between the two.
90    pub scope: BTreeMap<String, Value>,
91    /// Key columns identifying a row — mirrors the sink's `key`.
92    pub key: Vec<String>,
93    /// Ceiling on accumulated keys before the cleanup refuses (see module docs).
94    pub max_keys: usize,
95}
96
97impl CleanupPolicy {
98    /// Build a policy, validating it is actionable.
99    pub fn new(
100        scope: BTreeMap<String, Value>,
101        key: Vec<String>,
102        max_keys: usize,
103    ) -> Result<Self, FaucetError> {
104        if scope.is_empty() {
105            // An empty scope is an every-row predicate. Refusing here is the
106            // difference between "delete this contact's stale rows" and
107            // "truncate the table".
108            return Err(FaucetError::Config(
109                "cleanup: the completeness claim (`complete_for`) must name at least one \
110                 column — an empty scope would match every row in the destination"
111                    .into(),
112            ));
113        }
114        if key.is_empty() {
115            return Err(FaucetError::Config(
116                "cleanup: requires a non-empty `key` so a written row can be told apart \
117                 from a stale one"
118                    .into(),
119            ));
120        }
121        if scope.values().any(Value::is_null) {
122            return Err(FaucetError::Config(
123                "cleanup: the completeness claim contains a null value — an unresolved \
124                 scope token would delete the wrong rows"
125                    .into(),
126            ));
127        }
128        Ok(Self {
129            scope,
130            key,
131            max_keys: max_keys.max(1),
132        })
133    }
134}
135
136/// The set of key tuples an invocation wrote, accumulated across pages.
137///
138/// Deliberately stores only keys, not records: memory is O(rows in scope × key
139/// width) rather than O(payload).
140#[derive(Debug, Default)]
141pub struct SeenKeys {
142    keys: Vec<KeyTuple>,
143    /// Set once the ceiling is breached. Sticky: a cleanup that lost track of
144    /// even one key must not run at all.
145    overflowed: bool,
146}
147
148impl SeenKeys {
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Record the keys written for one page. Rows missing a key column, or
154    /// carrying a null there, are ignored — they cannot be matched by a keyed
155    /// delete anyway, and the write path already routes them to the DLQ or fails
156    /// the batch.
157    pub fn record_page(&mut self, page: &[Value], key: &[String], max_keys: usize) {
158        if self.overflowed {
159            return;
160        }
161        for rec in page {
162            let Some(obj) = rec.as_object() else { continue };
163            let mut tuple = Vec::with_capacity(key.len());
164            let mut complete = true;
165            for k in key {
166                match obj.get(k) {
167                    Some(v) if !v.is_null() => tuple.push((k.clone(), v.clone())),
168                    _ => {
169                        complete = false;
170                        break;
171                    }
172                }
173            }
174            if !complete {
175                continue;
176            }
177            if self.keys.len() >= max_keys {
178                self.overflowed = true;
179                self.keys.clear(); // free the buffer; the cleanup will refuse
180                return;
181            }
182            self.keys.push(KeyTuple(tuple));
183        }
184    }
185
186    /// Whether the ceiling was breached, which makes the cleanup unsafe to run.
187    pub fn overflowed(&self) -> bool {
188        self.overflowed
189    }
190
191    pub fn len(&self) -> usize {
192        self.keys.len()
193    }
194
195    pub fn is_empty(&self) -> bool {
196        self.keys.is_empty()
197    }
198
199    pub fn keys(&self) -> &[KeyTuple] {
200        &self.keys
201    }
202
203    /// The typed error a breached ceiling produces. Separate from the accumulator
204    /// so the caller decides whether to fail the run or log — but never to
205    /// delete.
206    pub fn overflow_error(&self, max_keys: usize) -> FaucetError {
207        FaucetError::Config(format!(
208            "cleanup: this invocation wrote more than {max_keys} rows in the claimed scope, \
209             so the set of written keys could not be tracked. Nothing was deleted — a \
210             partial delete would remove rows the run actually wrote. Narrow the scope \
211             (a smaller `complete_for`), or raise the ceiling if the destination can take \
212             a delete of this size"
213        ))
214    }
215}
216
217/// A sink wrapper that records the key tuples written through it (#478).
218///
219/// This is how scoped cleanup tracks "what this run wrote" **without** adding a
220/// field to [`RunStreamOptions`](crate::RunStreamOptions), which is an
221/// externally-constructible struct whose shape is part of the public API. It is
222/// also the more honest home for the bookkeeping: the thing that writes the rows
223/// is the thing that knows which rows were written, and it composes with the
224/// existing sink-decorator pattern (`InstrumentedSink`) rather than threading a
225/// second concern through the page loop.
226///
227/// Every write path is counted, including [`write_batch_partial`](Sink::write_batch_partial).
228/// That is deliberate: a row handed to the sink that fails and lands in the DLQ
229/// is still a record the source claimed present, so it must count as seen or the
230/// cleanup would delete its destination row.
231///
232/// Records that never reach the sink at all — quarantined by a quality, contract,
233/// or drift policy — are consequently *not* counted, which is why those
234/// combinations are rejected at config-load time rather than silently deleting
235/// the quarantined rows' destination counterparts.
236pub struct CleanupTracker<'a, S: Sink + ?Sized> {
237    inner: &'a S,
238    key: Vec<String>,
239    max_keys: usize,
240    seen: std::sync::Mutex<SeenKeys>,
241}
242
243impl<'a, S: Sink + ?Sized> CleanupTracker<'a, S> {
244    pub fn new(inner: &'a S, policy: &CleanupPolicy) -> Self {
245        Self {
246            inner,
247            key: policy.key.clone(),
248            max_keys: policy.max_keys,
249            seen: std::sync::Mutex::new(SeenKeys::new()),
250        }
251    }
252
253    fn record(&self, records: &[Value]) {
254        if let Ok(mut seen) = self.seen.lock() {
255            seen.record_page(records, &self.key, self.max_keys);
256        }
257    }
258
259    /// Run the scoped delete against the wrapped sink. Call **only** after a
260    /// fully successful, uncancelled run — see the module docs.
261    pub async fn finish(&self, policy: &CleanupPolicy) -> Result<u64, FaucetError> {
262        // Take the set out and drop the guard *before* awaiting: holding a
263        // `MutexGuard` across an await makes the whole run future non-`Send`,
264        // which the executor's `JoinSet` requires.
265        let seen = {
266            let mut guard = self
267                .seen
268                .lock()
269                .map_err(|_| FaucetError::Sink("cleanup: key tracker poisoned".into()))?;
270            if guard.overflowed() {
271                // Refuse rather than partially delete: the tracked set is
272                // incomplete, so a delete would remove rows the run wrote.
273                return Err(guard.overflow_error(policy.max_keys));
274            }
275            std::mem::take(&mut *guard)
276        };
277        self.inner.cleanup_scope(&policy.scope, &seen).await
278    }
279
280    /// Whether more keys were written than the policy's ceiling, so the
281    /// cleanup was (or will be) refused rather than run.
282    pub fn overflowed(&self) -> bool {
283        self.seen.lock().map(|g| g.overflowed()).unwrap_or(true)
284    }
285
286    /// Number of keys tracked so far (for logging).
287    pub fn tracked(&self) -> usize {
288        self.seen.lock().map(|g| g.len()).unwrap_or(0)
289    }
290}
291
292#[async_trait::async_trait]
293impl<S: Sink + ?Sized> Sink for CleanupTracker<'_, S> {
294    async fn admit_page(&self, records: &[Value]) -> Result<(), FaucetError> {
295        self.inner.admit_page(records).await
296    }
297    async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
298        let n = self.inner.write_batch(records).await?;
299        self.record(records);
300        Ok(n)
301    }
302
303    async fn write_batch_partial(
304        &self,
305        records: &[Value],
306    ) -> Result<Vec<crate::traits::RowOutcome>, FaucetError> {
307        let out = self.inner.write_batch_partial(records).await;
308        // Count every row handed to the sink, including the ones that failed —
309        // per row or as a whole batch under `on_batch_error: dlq_all` — and will
310        // be routed to the DLQ. See the type docs.
311        self.record(records);
312        out
313    }
314
315    async fn write_batch_idempotent(
316        &self,
317        records: &[Value],
318        scope: &str,
319        token: &str,
320    ) -> Result<usize, FaucetError> {
321        let n = self
322            .inner
323            .write_batch_idempotent(records, scope, token)
324            .await?;
325        self.record(records);
326        Ok(n)
327    }
328
329    async fn flush(&self) -> Result<(), FaucetError> {
330        self.inner.flush().await
331    }
332
333    // ── Pure forwarding below ────────────────────────────────────────────────
334    fn supports_cleanup(&self) -> bool {
335        self.inner.supports_cleanup()
336    }
337    async fn cleanup_scope(
338        &self,
339        scope: &BTreeMap<String, Value>,
340        seen: &SeenKeys,
341    ) -> Result<u64, FaucetError> {
342        self.inner.cleanup_scope(scope, seen).await
343    }
344    fn supports_idempotent_writes(&self) -> bool {
345        self.inner.supports_idempotent_writes()
346    }
347    async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
348        self.inner.last_committed_token(scope).await
349    }
350    fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
351        self.inner.supported_write_modes()
352    }
353    fn dedups_by_key(&self) -> bool {
354        self.inner.dedups_by_key()
355    }
356    fn batch_atomicity(&self) -> crate::dlq::BatchAtomicity {
357        self.inner.batch_atomicity()
358    }
359    fn sink_guarantee(&self) -> crate::idempotency::SinkGuarantee {
360        self.inner.sink_guarantee()
361    }
362    async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
363        self.inner.current_schema().await
364    }
365    fn supports_schema_evolution(&self) -> bool {
366        self.inner.supports_schema_evolution()
367    }
368    async fn evolve_schema(
369        &self,
370        evolution: &crate::drift::SchemaEvolution,
371    ) -> Result<(), FaucetError> {
372        self.inner.evolve_schema(evolution).await
373    }
374    fn config_schema(&self) -> Value {
375        self.inner.config_schema()
376    }
377    fn connector_name(&self) -> &'static str {
378        self.inner.connector_name()
379    }
380    fn dataset_uri(&self) -> String {
381        self.inner.dataset_uri()
382    }
383    async fn local_outputs(&self) -> Vec<crate::local_outputs::LocalOutput> {
384        self.inner.local_outputs().await
385    }
386    fn is_overwrite(&self) -> bool {
387        self.inner.is_overwrite()
388    }
389    async fn begin_overwrite(&self) -> Result<(), FaucetError> {
390        self.inner.begin_overwrite().await
391    }
392    async fn commit_overwrite(&self) -> Result<(), FaucetError> {
393        self.inner.commit_overwrite().await
394    }
395    async fn abort_overwrite(&self) -> Result<(), FaucetError> {
396        self.inner.abort_overwrite().await
397    }
398    async fn complete_run(&self) -> Result<(), FaucetError> {
399        self.inner.complete_run().await
400    }
401    fn write_batch_is_replay_safe(&self) -> bool {
402        self.inner.write_batch_is_replay_safe()
403    }
404    fn supports_staged_load(&self) -> bool {
405        self.inner.supports_staged_load()
406    }
407    async fn overwrite_staging_exists(&self) -> Result<Option<bool>, FaucetError> {
408        self.inner.overwrite_staging_exists().await
409    }
410    fn supports_rollback(&self) -> bool {
411        self.inner.supports_rollback()
412    }
413    async fn rollback_run(
414        &self,
415        run_id: &str,
416        opts: &crate::rollback::RollbackOptions,
417    ) -> Result<crate::rollback::RollbackOutcome, FaucetError> {
418        self.inner.rollback_run(run_id, opts).await
419    }
420    async fn forget_run(&self, run_id: &str) -> Result<(), FaucetError> {
421        self.inner.forget_run(run_id).await
422    }
423    async fn rewind_commit_token(
424        &self,
425        scope: &str,
426        token: Option<&str>,
427    ) -> Result<(), FaucetError> {
428        self.inner.rewind_commit_token(scope, token).await
429    }
430    fn readback_source(&self) -> Option<(String, Value)> {
431        self.inner.readback_source()
432    }
433    fn set_roundtrip_recorder(
434        &self,
435        recorder: std::sync::Arc<crate::observability::RoundtripRecorder>,
436    ) {
437        self.inner.set_roundtrip_recorder(recorder);
438    }
439    async fn check(
440        &self,
441        ctx: &crate::check::CheckContext,
442    ) -> Result<crate::check::CheckReport, FaucetError> {
443        self.inner.check(ctx).await
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use serde_json::json;
451
452    fn scope() -> BTreeMap<String, Value> {
453        BTreeMap::from([("contact_id".to_string(), json!(123))])
454    }
455
456    #[test]
457    fn policy_requires_a_non_empty_scope() {
458        // An empty scope is a truncate, not a cleanup.
459        let err = CleanupPolicy::new(BTreeMap::new(), vec!["id".into()], 10)
460            .expect_err("empty scope must be refused");
461        assert!(err.to_string().contains("at least one"), "{err}");
462    }
463
464    #[test]
465    fn policy_requires_a_key() {
466        let err = CleanupPolicy::new(scope(), vec![], 10).expect_err("no key must be refused");
467        assert!(err.to_string().contains("`key`"), "{err}");
468    }
469
470    #[test]
471    fn policy_refuses_a_null_scope_value() {
472        // An unresolved `${parent.id}` would land here as null and delete the
473        // wrong rows.
474        let s = BTreeMap::from([("contact_id".to_string(), Value::Null)]);
475        let err = CleanupPolicy::new(s, vec!["id".into()], 10).expect_err("null must be refused");
476        assert!(err.to_string().contains("null"), "{err}");
477    }
478
479    #[test]
480    fn policy_floors_max_keys_at_one() {
481        let p = CleanupPolicy::new(scope(), vec!["id".into()], 0).unwrap();
482        assert_eq!(p.max_keys, 1);
483    }
484
485    #[tokio::test]
486    async fn cleanup_tracker_forwards_overwrite_lifecycle() {
487        // The tracker must forward the overwrite lifecycle to the inner sink so
488        // an overwrite run still stages/swaps when scoped cleanup wraps it.
489        struct OvwSink {
490            log: std::sync::Mutex<Vec<&'static str>>,
491        }
492        #[async_trait::async_trait]
493        impl Sink for OvwSink {
494            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
495                Ok(r.len())
496            }
497            fn is_overwrite(&self) -> bool {
498                true
499            }
500            async fn begin_overwrite(&self) -> Result<(), FaucetError> {
501                self.log.lock().unwrap().push("begin");
502                Ok(())
503            }
504            async fn commit_overwrite(&self) -> Result<(), FaucetError> {
505                self.log.lock().unwrap().push("commit");
506                Ok(())
507            }
508            async fn abort_overwrite(&self) -> Result<(), FaucetError> {
509                self.log.lock().unwrap().push("abort");
510                Ok(())
511            }
512        }
513        let inner = OvwSink {
514            log: std::sync::Mutex::new(Vec::new()),
515        };
516        let policy = CleanupPolicy::new(scope(), vec!["id".into()], 10).unwrap();
517        let tracker = CleanupTracker::new(&inner, &policy);
518        assert!(tracker.is_overwrite());
519        assert_eq!(
520            tracker.batch_atomicity(),
521            crate::dlq::BatchAtomicity::BestEffort
522        );
523        // A write through the tracker must forward to the inner sink too.
524        assert_eq!(tracker.write_batch(&[json!({"id": 1})]).await.unwrap(), 1);
525        tracker.begin_overwrite().await.unwrap();
526        tracker.commit_overwrite().await.unwrap();
527        tracker.abort_overwrite().await.unwrap();
528        tracker.complete_run().await.unwrap();
529        assert_eq!(*inner.log.lock().unwrap(), vec!["begin", "commit", "abort"]);
530    }
531
532    #[test]
533    fn accumulates_keys_across_pages() {
534        let mut seen = SeenKeys::new();
535        let key = vec!["id".to_string()];
536        seen.record_page(&[json!({"id": 1}), json!({"id": 2})], &key, 100);
537        seen.record_page(&[json!({"id": 3})], &key, 100);
538        assert_eq!(seen.len(), 3);
539        assert!(!seen.overflowed());
540    }
541
542    #[test]
543    fn accumulates_composite_keys_in_declared_order() {
544        let mut seen = SeenKeys::new();
545        let key = vec!["a".to_string(), "b".to_string()];
546        seen.record_page(&[json!({"b": 2, "a": 1})], &key, 100);
547        assert_eq!(seen.len(), 1);
548        let t = &seen.keys()[0].0;
549        assert_eq!(
550            t[0].0, "a",
551            "key order follows the declared `key`, not the record"
552        );
553        assert_eq!(t[1].0, "b");
554    }
555
556    #[test]
557    fn skips_rows_with_a_missing_or_null_key() {
558        let mut seen = SeenKeys::new();
559        let key = vec!["id".to_string()];
560        seen.record_page(
561            &[
562                json!({"id": 1}),
563                json!({"other": 9}), // missing key
564                json!({"id": null}), // null key
565                json!("not an object"),
566            ],
567            &key,
568            100,
569        );
570        assert_eq!(seen.len(), 1, "only the well-keyed row is tracked");
571    }
572
573    #[test]
574    fn overflow_is_sticky_and_frees_the_buffer() {
575        let mut seen = SeenKeys::new();
576        let key = vec!["id".to_string()];
577        let page: Vec<Value> = (0..5).map(|i| json!({"id": i})).collect();
578        seen.record_page(&page, &key, 3);
579        assert!(seen.overflowed(), "ceiling of 3 must trip on a 5-row page");
580        assert!(seen.is_empty(), "buffer is freed — the cleanup will refuse");
581        // Sticky: a later page cannot un-overflow it.
582        seen.record_page(&[json!({"id": 99})], &key, 3);
583        assert!(seen.overflowed());
584        assert!(seen.is_empty());
585    }
586
587    #[test]
588    fn overflow_error_explains_that_nothing_was_deleted() {
589        let seen = SeenKeys::new();
590        let msg = seen.overflow_error(50).to_string();
591        assert!(msg.contains("Nothing was deleted"), "{msg}");
592        assert!(msg.contains("50"), "{msg}");
593    }
594
595    #[tokio::test]
596    async fn cleanup_tracker_forwards_every_hook() {
597        let probe = crate::sink_forwarding::HookSink::default();
598        let policy = CleanupPolicy::new(scope(), vec!["id".into()], 10).unwrap();
599        let t = CleanupTracker::new(&probe, &policy);
600        crate::sink_forwarding::assert_forwards_every_hook(
601            &t,
602            &probe,
603            &[
604                "supports_columnar",
605                "write_batch_columnar",
606                "native_load_capabilities",
607                "load_native",
608            ],
609        )
610        .await;
611    }
612
613    #[tokio::test]
614    async fn overflowed_tells_a_refusal_from_a_backend_failure() {
615        struct Fails;
616        #[async_trait::async_trait]
617        impl Sink for Fails {
618            async fn write_batch(&self, r: &[Value]) -> Result<usize, FaucetError> {
619                Ok(r.len())
620            }
621            fn supports_cleanup(&self) -> bool {
622                true
623            }
624            async fn cleanup_scope(
625                &self,
626                _: &BTreeMap<String, Value>,
627                _: &SeenKeys,
628            ) -> Result<u64, FaucetError> {
629                Err(FaucetError::Sink("db down".into()))
630            }
631        }
632        let policy = CleanupPolicy::new(scope(), vec!["id".into()], 1).unwrap();
633        let t = CleanupTracker::new(&Fails, &policy);
634        t.write_batch(&[json!({"id": 1})]).await.unwrap();
635        assert!(t.finish(&policy).await.is_err());
636        assert!(!t.overflowed());
637        let t = CleanupTracker::new(&Fails, &policy);
638        t.write_batch(&[json!({"id": 1}), json!({"id": 2})])
639            .await
640            .unwrap();
641        assert!(t.finish(&policy).await.is_err());
642        assert!(t.overflowed());
643    }
644}