1use 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
62pub const DEFAULT_MAX_KEYS: usize = 100_000;
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
74#[serde(rename_all = "snake_case")]
75pub enum CleanupMode {
76 DeleteMissing,
78}
79
80#[derive(Debug, Clone)]
82pub struct CleanupPolicy {
83 pub scope: BTreeMap<String, Value>,
91 pub key: Vec<String>,
93 pub max_keys: usize,
95}
96
97impl CleanupPolicy {
98 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 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#[derive(Debug, Default)]
141pub struct SeenKeys {
142 keys: Vec<KeyTuple>,
143 overflowed: bool,
146}
147
148impl SeenKeys {
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 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(); return;
181 }
182 self.keys.push(KeyTuple(tuple));
183 }
184 }
185
186 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 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
217pub 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 pub async fn finish(&self, policy: &CleanupPolicy) -> Result<u64, FaucetError> {
262 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 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 pub fn overflowed(&self) -> bool {
283 self.seen.lock().map(|g| g.overflowed()).unwrap_or(true)
284 }
285
286 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 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 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 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 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 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 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}), json!({"id": null}), 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 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}