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}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use serde_json::json;
377
378 fn scope() -> BTreeMap<String, Value> {
379 BTreeMap::from([("contact_id".to_string(), json!(123))])
380 }
381
382 #[test]
383 fn policy_requires_a_non_empty_scope() {
384 // An empty scope is a truncate, not a cleanup.
385 let err = CleanupPolicy::new(BTreeMap::new(), vec!["id".into()], 10)
386 .expect_err("empty scope must be refused");
387 assert!(err.to_string().contains("at least one"), "{err}");
388 }
389
390 #[test]
391 fn policy_requires_a_key() {
392 let err = CleanupPolicy::new(scope(), vec![], 10).expect_err("no key must be refused");
393 assert!(err.to_string().contains("`key`"), "{err}");
394 }
395
396 #[test]
397 fn policy_refuses_a_null_scope_value() {
398 // An unresolved `${parent.id}` would land here as null and delete the
399 // wrong rows.
400 let s = BTreeMap::from([("contact_id".to_string(), Value::Null)]);
401 let err = CleanupPolicy::new(s, vec!["id".into()], 10).expect_err("null must be refused");
402 assert!(err.to_string().contains("null"), "{err}");
403 }
404
405 #[test]
406 fn policy_floors_max_keys_at_one() {
407 let p = CleanupPolicy::new(scope(), vec!["id".into()], 0).unwrap();
408 assert_eq!(p.max_keys, 1);
409 }
410
411 #[test]
412 fn accumulates_keys_across_pages() {
413 let mut seen = SeenKeys::new();
414 let key = vec!["id".to_string()];
415 seen.record_page(&[json!({"id": 1}), json!({"id": 2})], &key, 100);
416 seen.record_page(&[json!({"id": 3})], &key, 100);
417 assert_eq!(seen.len(), 3);
418 assert!(!seen.overflowed());
419 }
420
421 #[test]
422 fn accumulates_composite_keys_in_declared_order() {
423 let mut seen = SeenKeys::new();
424 let key = vec!["a".to_string(), "b".to_string()];
425 seen.record_page(&[json!({"b": 2, "a": 1})], &key, 100);
426 assert_eq!(seen.len(), 1);
427 let t = &seen.keys()[0].0;
428 assert_eq!(
429 t[0].0, "a",
430 "key order follows the declared `key`, not the record"
431 );
432 assert_eq!(t[1].0, "b");
433 }
434
435 #[test]
436 fn skips_rows_with_a_missing_or_null_key() {
437 let mut seen = SeenKeys::new();
438 let key = vec!["id".to_string()];
439 seen.record_page(
440 &[
441 json!({"id": 1}),
442 json!({"other": 9}), // missing key
443 json!({"id": null}), // null key
444 json!("not an object"),
445 ],
446 &key,
447 100,
448 );
449 assert_eq!(seen.len(), 1, "only the well-keyed row is tracked");
450 }
451
452 #[test]
453 fn overflow_is_sticky_and_frees_the_buffer() {
454 let mut seen = SeenKeys::new();
455 let key = vec!["id".to_string()];
456 let page: Vec<Value> = (0..5).map(|i| json!({"id": i})).collect();
457 seen.record_page(&page, &key, 3);
458 assert!(seen.overflowed(), "ceiling of 3 must trip on a 5-row page");
459 assert!(seen.is_empty(), "buffer is freed — the cleanup will refuse");
460 // Sticky: a later page cannot un-overflow it.
461 seen.record_page(&[json!({"id": 99})], &key, 3);
462 assert!(seen.overflowed());
463 assert!(seen.is_empty());
464 }
465
466 #[test]
467 fn overflow_error_explains_that_nothing_was_deleted() {
468 let seen = SeenKeys::new();
469 let msg = seen.overflow_error(50).to_string();
470 assert!(msg.contains("Nothing was deleted"), "{msg}");
471 assert!(msg.contains("50"), "{msg}");
472 }
473}