Skip to main content

faucet_cli/serve/history/
memory.rs

1//! `DashMap`-backed run history (default backend). Lost on restart; that is the
2//! documented memory-backend trade-off. Idempotency claims live in a second map
3//! and are pruned both lazily (on re-claim) and by `purge_expired`.
4
5use super::catalog::{
6    self, CatalogDataset, CatalogDatasetDetail, CatalogDatasetPage, CatalogLineageEdge,
7    CatalogListFilter, CatalogSchemaVersion, CatalogStatsPoint, CatalogUpdate,
8};
9use super::{
10    AuditEntry, AuditFilter, Claim, DeleteOutcome, HistoryError, ListFilter, ListPage, RunHistory,
11    RunRecord,
12};
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use dashmap::DashMap;
16use std::collections::VecDeque;
17use std::sync::Mutex;
18use std::time::Duration;
19
20/// Cap on in-memory audit records (oldest dropped past this). The memory backend
21/// is ephemeral anyway; this just bounds growth for a long-lived process.
22const AUDIT_RING_CAP: usize = 10_000;
23
24struct IdemEntry {
25    run_id: String,
26    fingerprint: String,
27    claimed_at: DateTime<Utc>,
28}
29
30/// In-memory Data Movement Catalog state (#279). One `Mutex` guards the whole
31/// catalog so a `catalog_record` (a read-modify-write across three maps) is
32/// atomic without per-map lock ordering.
33#[derive(Default)]
34struct CatalogState {
35    datasets: std::collections::HashMap<String, CatalogDataset>,
36    /// dataset id → timeline, oldest first.
37    schema_versions: std::collections::HashMap<String, Vec<CatalogSchemaVersion>>,
38    /// dataset id → volume points, oldest first, capped at `STATS_RETAIN`.
39    stats: std::collections::HashMap<String, Vec<CatalogStatsPoint>>,
40    /// (src id, dst id) → edge.
41    edges: std::collections::HashMap<(String, String), CatalogLineageEdge>,
42    /// pipeline name → latest config snapshot (#374). Latest-wins.
43    config_snapshots: std::collections::HashMap<String, super::catalog::ConfigSnapshot>,
44}
45
46pub struct MemoryHistory {
47    runs: DashMap<String, RunRecord>,
48    idem: DashMap<String, IdemEntry>,
49    /// Bounded, newest-at-back ring of audit records (RBAC, #205).
50    audit: Mutex<VecDeque<AuditEntry>>,
51    /// Data Movement Catalog (#279). Ephemeral like everything else here.
52    catalog: Mutex<CatalogState>,
53    /// Retention window for idempotency claims (separate from run retention).
54    idem_retention: Duration,
55}
56
57impl MemoryHistory {
58    pub fn new(idem_retention: Duration) -> Self {
59        Self {
60            runs: DashMap::new(),
61            idem: DashMap::new(),
62            audit: Mutex::new(VecDeque::new()),
63            catalog: Mutex::new(CatalogState::default()),
64            idem_retention,
65        }
66    }
67}
68
69/// True when `claimed_at` is older than `window` relative to `now`. A claim
70/// timestamped in the future (clock skew) is treated as *not* expired.
71fn is_expired(claimed_at: DateTime<Utc>, now: DateTime<Utc>, window: Duration) -> bool {
72    now.signed_duration_since(claimed_at)
73        .to_std()
74        .map(|age| age >= window)
75        .unwrap_or(false)
76}
77
78#[async_trait]
79impl RunHistory for MemoryHistory {
80    async fn claim_idempotency(
81        &self,
82        key: &str,
83        fingerprint: &str,
84        run_id: &str,
85        window: Duration,
86    ) -> Result<Claim, HistoryError> {
87        use dashmap::mapref::entry::Entry;
88        let now = Utc::now();
89        // Holding the entry locks the shard, so claim is atomic under contention.
90        match self.idem.entry(key.to_string()) {
91            Entry::Occupied(mut e) => {
92                let expired = is_expired(e.get().claimed_at, now, window);
93                if expired {
94                    e.insert(IdemEntry {
95                        run_id: run_id.to_string(),
96                        fingerprint: fingerprint.to_string(),
97                        claimed_at: now,
98                    });
99                    Ok(Claim::Fresh)
100                } else if e.get().fingerprint == fingerprint {
101                    Ok(Claim::Replay(e.get().run_id.clone()))
102                } else {
103                    Ok(Claim::Conflict)
104                }
105            }
106            Entry::Vacant(v) => {
107                v.insert(IdemEntry {
108                    run_id: run_id.to_string(),
109                    fingerprint: fingerprint.to_string(),
110                    claimed_at: now,
111                });
112                Ok(Claim::Fresh)
113            }
114        }
115    }
116
117    async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError> {
118        self.runs.insert(rec.run_id.clone(), rec.clone());
119        Ok(())
120    }
121
122    async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError> {
123        Ok(self.runs.get(id).map(|r| r.clone()))
124    }
125
126    async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError> {
127        let mut rows: Vec<RunRecord> = self
128            .runs
129            .iter()
130            .map(|r| r.clone())
131            .filter(|r| filter.status.is_none_or(|s| r.status == s))
132            .filter(|r| {
133                filter
134                    .name
135                    .as_deref()
136                    .is_none_or(|n| r.name.as_deref() == Some(n))
137            })
138            .filter(|r| filter.since.is_none_or(|t| r.submitted_at >= t))
139            .filter(|r| filter.until.is_none_or(|t| r.submitted_at <= t))
140            .collect();
141        // (submitted_at DESC, run_id DESC)
142        rows.sort_by(|a, b| {
143            b.submitted_at
144                .cmp(&a.submitted_at)
145                .then_with(|| b.run_id.cmp(&a.run_id))
146        });
147        // Cursor = last run_id seen on the previous page; skip past it.
148        if let Some(cursor) = &filter.cursor
149            && let Some(pos) = rows.iter().position(|r| &r.run_id == cursor)
150        {
151            rows.drain(..=pos);
152        }
153        let limit = filter.limit.max(1);
154        let next_cursor = if rows.len() > limit {
155            Some(rows[limit - 1].run_id.clone())
156        } else {
157            None
158        };
159        rows.truncate(limit);
160        Ok(ListPage {
161            runs: rows,
162            next_cursor,
163        })
164    }
165
166    async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError> {
167        let Some(rec) = self.runs.get(id).map(|r| r.clone()) else {
168            return Ok(DeleteOutcome::NotFound);
169        };
170        if !rec.status.is_terminal() {
171            return Ok(DeleteOutcome::StillRunning);
172        }
173        self.runs.remove(id);
174        // Also drop this run's idempotency claim so a replay of the key starts a
175        // fresh run instead of 404-ing on the now-deleted record until the claim
176        // self-expires (#146 M8). Only remove it if the claim still points at
177        // THIS run — a newer run may have re-claimed the key after expiry.
178        if let Some(key) = rec.idempotency_key.as_deref() {
179            self.idem.remove_if(key, |_, e| e.run_id == id);
180        }
181        Ok(DeleteOutcome::Deleted)
182    }
183
184    async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError> {
185        let now = Utc::now();
186        let before = self.runs.len();
187        self.runs.retain(|_, r| {
188            !r.status.is_terminal()
189                || r.finished_at
190                    .map(|f| !is_expired(f, now, retain_for))
191                    .unwrap_or(true)
192        });
193        // Also drop stale idempotency claims so the map stays bounded.
194        self.idem
195            .retain(|_, e| !is_expired(e.claimed_at, now, self.idem_retention));
196        // Trim audit records older than the run-retention window.
197        if let Ok(mut ring) = self.audit.lock() {
198            ring.retain(|e| !is_expired(e.timestamp, now, retain_for));
199        }
200        Ok(before.saturating_sub(self.runs.len()))
201    }
202
203    async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
204        let mut ring = self
205            .audit
206            .lock()
207            .map_err(|_| HistoryError::Backend("audit ring lock poisoned".into()))?;
208        ring.push_back(entry.clone());
209        while ring.len() > AUDIT_RING_CAP {
210            ring.pop_front();
211        }
212        Ok(())
213    }
214
215    async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
216        let ring = self
217            .audit
218            .lock()
219            .map_err(|_| HistoryError::Backend("audit ring lock poisoned".into()))?;
220        let mut rows: Vec<AuditEntry> = ring
221            .iter()
222            .filter(|e| filter.principal.as_deref().is_none_or(|p| e.principal == p))
223            .filter(|e| filter.action.as_deref().is_none_or(|a| e.action == a))
224            .filter(|e| filter.since.is_none_or(|t| e.timestamp >= t))
225            .filter(|e| filter.until.is_none_or(|t| e.timestamp <= t))
226            .cloned()
227            .collect();
228        // Newest first (timestamp DESC, id DESC).
229        rows.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| b.id.cmp(&a.id)));
230        rows.truncate(filter.limit.max(1));
231        Ok(rows)
232    }
233
234    async fn recover_orphans(&self) -> Result<usize, HistoryError> {
235        Ok(0)
236    }
237
238    async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
239        use crate::serve::history::RunStatus;
240        if let Some(mut r) = self.runs.get_mut(run_id)
241            && r.status == RunStatus::Pending
242        {
243            r.status = RunStatus::Cancelled;
244            r.finished_at = Some(Utc::now());
245            return Ok(true);
246        }
247        Ok(false)
248    }
249
250    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
251
252    async fn catalog_record(&self, update: &CatalogUpdate) -> Result<(), HistoryError> {
253        let lock_err = |_| HistoryError::Backend("catalog lock poisoned".into());
254        let mut cat = self.catalog.lock().map_err(lock_err)?;
255        for obs in [&update.source, &update.sink] {
256            let id = catalog::dataset_id(&obs.uri);
257            let (ds, new_version) = catalog::apply_observation(
258                cat.datasets.get(&id),
259                obs,
260                &update.run_id,
261                &update.pipeline,
262                &update.row,
263                update.recorded_at,
264            );
265            if let Some(v) = new_version {
266                cat.schema_versions.entry(id.clone()).or_default().push(v);
267            }
268            let points = cat.stats.entry(id.clone()).or_default();
269            points.push(CatalogStatsPoint {
270                recorded_at: update.recorded_at,
271                run_id: update.run_id.clone(),
272                records: obs.records,
273            });
274            if points.len() > catalog::STATS_RETAIN {
275                let drop_n = points.len() - catalog::STATS_RETAIN;
276                points.drain(..drop_n);
277            }
278            cat.datasets.insert(id, ds);
279        }
280        let key = (
281            catalog::dataset_id(&update.source.uri),
282            catalog::dataset_id(&update.sink.uri),
283        );
284        let edge = catalog::apply_edge(cat.edges.get(&key), update);
285        cat.edges.insert(key, edge);
286        Ok(())
287    }
288
289    async fn catalog_list_datasets(
290        &self,
291        filter: &CatalogListFilter,
292    ) -> Result<CatalogDatasetPage, HistoryError> {
293        let cat = self
294            .catalog
295            .lock()
296            .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
297        Ok(catalog::filter_datasets(
298            cat.datasets.values().cloned().collect(),
299            filter,
300        ))
301    }
302
303    async fn catalog_get_dataset(
304        &self,
305        id: &str,
306    ) -> Result<Option<CatalogDatasetDetail>, HistoryError> {
307        let cat = self
308            .catalog
309            .lock()
310            .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
311        let Some(dataset) = cat.datasets.get(id).cloned() else {
312            return Ok(None);
313        };
314        let schema_timeline = cat.schema_versions.get(id).cloned().unwrap_or_default();
315        let mut stats: Vec<CatalogStatsPoint> = cat.stats.get(id).cloned().unwrap_or_default();
316        stats.reverse(); // newest first
317        stats.truncate(catalog::STATS_DETAIL_LIMIT);
318        let upstream = cat
319            .edges
320            .values()
321            .filter(|e| e.dst_id == id)
322            .cloned()
323            .collect();
324        let downstream = cat
325            .edges
326            .values()
327            .filter(|e| e.src_id == id)
328            .cloned()
329            .collect();
330        Ok(Some(CatalogDatasetDetail {
331            dataset,
332            schema_timeline,
333            stats,
334            upstream,
335            downstream,
336        }))
337    }
338
339    async fn catalog_lineage(
340        &self,
341        root: Option<&str>,
342        depth: u32,
343    ) -> Result<Vec<CatalogLineageEdge>, HistoryError> {
344        let cat = self
345            .catalog
346            .lock()
347            .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
348        let mut edges: Vec<CatalogLineageEdge> = cat.edges.values().cloned().collect();
349        // Stable order for pagination-free consumers (newest activity first).
350        edges.sort_by(|a, b| {
351            b.last_seen
352                .cmp(&a.last_seen)
353                .then_with(|| (&a.src_id, &a.dst_id).cmp(&(&b.src_id, &b.dst_id)))
354        });
355        Ok(catalog::lineage_slice(edges, root, depth))
356    }
357
358    async fn catalog_record_config_snapshot(
359        &self,
360        snapshot: &catalog::ConfigSnapshot,
361    ) -> Result<(), HistoryError> {
362        let mut cat = self
363            .catalog
364            .lock()
365            .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
366        cat.config_snapshots
367            .insert(snapshot.pipeline.clone(), snapshot.clone());
368        Ok(())
369    }
370
371    async fn catalog_last_config_snapshot(
372        &self,
373        pipeline: &str,
374    ) -> Result<Option<catalog::ConfigSnapshot>, HistoryError> {
375        let cat = self
376            .catalog
377            .lock()
378            .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
379        Ok(cat.config_snapshots.get(pipeline).cloned())
380    }
381
382    fn degraded(&self) -> bool {
383        false
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::serve::history::RunStatus;
391    use std::collections::BTreeMap;
392
393    fn rec(id: &str, status: RunStatus, submitted: DateTime<Utc>) -> RunRecord {
394        let mut r = RunRecord::queued(id.into(), None, BTreeMap::new(), None, submitted);
395        r.status = status;
396        if status.is_terminal() {
397            r.finished_at = Some(submitted);
398        }
399        r
400    }
401
402    #[tokio::test]
403    async fn upsert_then_get_roundtrips() {
404        let h = MemoryHistory::new(Duration::from_secs(60));
405        let r = rec("a", RunStatus::Queued, Utc::now());
406        h.upsert(&r).await.unwrap();
407        assert_eq!(h.get("a").await.unwrap().unwrap().run_id, "a");
408        assert!(h.get("missing").await.unwrap().is_none());
409    }
410
411    #[tokio::test]
412    async fn idempotency_fresh_replay_conflict() {
413        let h = MemoryHistory::new(Duration::from_secs(60));
414        let w = Duration::from_secs(60);
415        assert_eq!(
416            h.claim_idempotency("k", "fp1", "run1", w).await.unwrap(),
417            Claim::Fresh
418        );
419        // Same key + same fingerprint → replay the first run id.
420        assert_eq!(
421            h.claim_idempotency("k", "fp1", "run2", w).await.unwrap(),
422            Claim::Replay("run1".into())
423        );
424        // Same key + different fingerprint → conflict.
425        assert_eq!(
426            h.claim_idempotency("k", "fp2", "run3", w).await.unwrap(),
427            Claim::Conflict
428        );
429    }
430
431    #[tokio::test]
432    async fn expired_claim_is_reclaimable() {
433        let h = MemoryHistory::new(Duration::from_secs(60));
434        // Zero window → any prior claim is immediately expired.
435        let w = Duration::ZERO;
436        assert_eq!(
437            h.claim_idempotency("k", "fp1", "run1", w).await.unwrap(),
438            Claim::Fresh
439        );
440        assert_eq!(
441            h.claim_idempotency("k", "fp2", "run2", w).await.unwrap(),
442            Claim::Fresh
443        );
444    }
445
446    #[tokio::test]
447    async fn delete_respects_terminal_state() {
448        let h = MemoryHistory::new(Duration::from_secs(60));
449        h.upsert(&rec("run", RunStatus::Running, Utc::now()))
450            .await
451            .unwrap();
452        assert_eq!(h.delete("run").await.unwrap(), DeleteOutcome::StillRunning);
453        assert_eq!(h.delete("nope").await.unwrap(), DeleteOutcome::NotFound);
454        h.upsert(&rec("run", RunStatus::Completed, Utc::now()))
455            .await
456            .unwrap();
457        assert_eq!(h.delete("run").await.unwrap(), DeleteOutcome::Deleted);
458        assert!(h.get("run").await.unwrap().is_none());
459    }
460
461    #[tokio::test]
462    async fn delete_also_removes_matching_idem_claim() {
463        // M8 (#146): deleting a run must drop its idempotency claim, so a later
464        // replay of the key starts a fresh run instead of 404-ing on the
465        // now-missing record until the claim self-expires.
466        let h = MemoryHistory::new(Duration::from_secs(3600));
467        let w = Duration::from_secs(3600);
468        assert_eq!(
469            h.claim_idempotency("k", "fp", "r1", w).await.unwrap(),
470            Claim::Fresh
471        );
472        let mut r = RunRecord::queued(
473            "r1".into(),
474            None,
475            BTreeMap::new(),
476            Some("k".into()),
477            Utc::now(),
478        );
479        r.status = RunStatus::Completed;
480        r.finished_at = Some(Utc::now());
481        h.upsert(&r).await.unwrap();
482
483        assert_eq!(h.delete("r1").await.unwrap(), DeleteOutcome::Deleted);
484        // The key is free again → fresh run, not a replay of the deleted one.
485        assert_eq!(
486            h.claim_idempotency("k", "fp", "r2", w).await.unwrap(),
487            Claim::Fresh
488        );
489    }
490
491    #[tokio::test]
492    async fn delete_keeps_claim_owned_by_a_newer_run() {
493        // Guard: deleting an OLD run must not remove a claim a NEWER run owns.
494        let h = MemoryHistory::new(Duration::from_secs(3600));
495        h.claim_idempotency("k", "fp", "r1", Duration::from_secs(3600))
496            .await
497            .unwrap();
498        // r2 re-claims the key (force the prior claim stale with a zero window).
499        assert_eq!(
500            h.claim_idempotency("k", "fp", "r2", Duration::ZERO)
501                .await
502                .unwrap(),
503            Claim::Fresh
504        );
505        let mut r1 = RunRecord::queued(
506            "r1".into(),
507            None,
508            BTreeMap::new(),
509            Some("k".into()),
510            Utc::now(),
511        );
512        r1.status = RunStatus::Completed;
513        r1.finished_at = Some(Utc::now());
514        h.upsert(&r1).await.unwrap();
515        assert_eq!(h.delete("r1").await.unwrap(), DeleteOutcome::Deleted);
516        // The claim still belongs to r2.
517        assert_eq!(
518            h.claim_idempotency("k", "fp", "r3", Duration::from_secs(3600))
519                .await
520                .unwrap(),
521            Claim::Replay("r2".into())
522        );
523    }
524
525    #[tokio::test]
526    async fn list_orders_desc_and_paginates() {
527        let h = MemoryHistory::new(Duration::from_secs(60));
528        let t0 = Utc::now();
529        for (i, id) in ["a", "b", "c"].iter().enumerate() {
530            h.upsert(&rec(
531                id,
532                RunStatus::Completed,
533                t0 + chrono::Duration::seconds(i as i64),
534            ))
535            .await
536            .unwrap();
537        }
538        // Newest first → c, b, a. Page size 2.
539        let page = h
540            .list(&ListFilter {
541                limit: 2,
542                ..Default::default()
543            })
544            .await
545            .unwrap();
546        assert_eq!(
547            page.runs
548                .iter()
549                .map(|r| r.run_id.clone())
550                .collect::<Vec<_>>(),
551            vec!["c", "b"]
552        );
553        assert_eq!(page.next_cursor.as_deref(), Some("b"));
554        // Next page from the cursor → a.
555        let page2 = h
556            .list(&ListFilter {
557                limit: 2,
558                cursor: Some("b".into()),
559                ..Default::default()
560            })
561            .await
562            .unwrap();
563        assert_eq!(
564            page2
565                .runs
566                .iter()
567                .map(|r| r.run_id.clone())
568                .collect::<Vec<_>>(),
569            vec!["a"]
570        );
571        assert!(page2.next_cursor.is_none());
572    }
573
574    #[tokio::test]
575    async fn list_filters_by_status_and_name() {
576        let h = MemoryHistory::new(Duration::from_secs(60));
577        let mut r = rec("x", RunStatus::Failed, Utc::now());
578        r.name = Some("nightly".into());
579        h.upsert(&r).await.unwrap();
580        h.upsert(&rec("y", RunStatus::Completed, Utc::now()))
581            .await
582            .unwrap();
583        let only_failed = h
584            .list(&ListFilter {
585                status: Some(RunStatus::Failed),
586                limit: 50,
587                ..Default::default()
588            })
589            .await
590            .unwrap();
591        assert_eq!(only_failed.runs.len(), 1);
592        assert_eq!(only_failed.runs[0].run_id, "x");
593        // Name filter also works.
594        let by_name = h
595            .list(&ListFilter {
596                name: Some("nightly".into()),
597                limit: 50,
598                ..Default::default()
599            })
600            .await
601            .unwrap();
602        assert_eq!(by_name.runs.len(), 1);
603        assert_eq!(by_name.runs[0].run_id, "x");
604    }
605
606    #[tokio::test]
607    async fn audit_record_list_filter_and_purge() {
608        use crate::serve::history::{AuditEntry, AuditFilter};
609        let h = MemoryHistory::new(Duration::from_secs(60));
610        let now = Utc::now();
611        let entry =
612            |id: &str, principal: &str, action: &str, result: &str, ts: DateTime<Utc>| AuditEntry {
613                id: id.into(),
614                timestamp: ts,
615                principal: principal.into(),
616                role: "admin".into(),
617                action: action.into(),
618                run_id: None,
619                config_fingerprint: None,
620                source_ip: None,
621                result: result.into(),
622            };
623        h.record_audit(&entry(
624            "1",
625            "alice",
626            "run.submit",
627            "ok",
628            now - chrono::Duration::seconds(2),
629        ))
630        .await
631        .unwrap();
632        h.record_audit(&entry(
633            "2",
634            "bob",
635            "run.submit",
636            "denied",
637            now - chrono::Duration::seconds(1),
638        ))
639        .await
640        .unwrap();
641        h.record_audit(&entry("3", "alice", "run.cancel", "ok", now))
642            .await
643            .unwrap();
644
645        // Newest first, no filter.
646        let all = h
647            .list_audit(&AuditFilter {
648                limit: 50,
649                ..Default::default()
650            })
651            .await
652            .unwrap();
653        assert_eq!(all.len(), 3);
654        assert_eq!(all[0].id, "3", "newest first");
655
656        // Filter by principal + action.
657        let alice = h
658            .list_audit(&AuditFilter {
659                principal: Some("alice".into()),
660                limit: 50,
661                ..Default::default()
662            })
663            .await
664            .unwrap();
665        assert_eq!(alice.len(), 2);
666        assert!(alice.iter().all(|e| e.principal == "alice"));
667
668        let denied = h
669            .list_audit(&AuditFilter {
670                action: Some("run.submit".into()),
671                limit: 50,
672                ..Default::default()
673            })
674            .await
675            .unwrap();
676        assert_eq!(denied.len(), 2);
677
678        // Limit is honoured.
679        let one = h
680            .list_audit(&AuditFilter {
681                limit: 1,
682                ..Default::default()
683            })
684            .await
685            .unwrap();
686        assert_eq!(one.len(), 1);
687
688        // purge_expired(0) drops all audit records (every ts is "expired").
689        h.purge_expired(Duration::ZERO).await.unwrap();
690        let after = h
691            .list_audit(&AuditFilter {
692                limit: 50,
693                ..Default::default()
694            })
695            .await
696            .unwrap();
697        assert!(after.is_empty(), "audit purge should clear expired entries");
698    }
699
700    fn catalog_update(src: &str, dst: &str, schema: Option<serde_json::Value>) -> CatalogUpdate {
701        use crate::serve::history::catalog::{DatasetObservation, DatasetRole};
702        CatalogUpdate {
703            run_id: "r1".into(),
704            pipeline: "p".into(),
705            row: "default".into(),
706            recorded_at: Utc::now(),
707            source: DatasetObservation {
708                uri: src.into(),
709                kind: "csv".into(),
710                role: DatasetRole::Source,
711                schema: schema.clone(),
712                records: 10,
713            },
714            sink: DatasetObservation {
715                uri: dst.into(),
716                kind: "jsonl".into(),
717                role: DatasetRole::Sink,
718                schema,
719                records: 10,
720            },
721            column_lineage: None,
722        }
723    }
724
725    #[tokio::test]
726    async fn config_snapshot_roundtrips_latest_wins() {
727        use crate::serve::history::catalog::ConfigSnapshot;
728        use std::collections::BTreeMap;
729        let h = MemoryHistory::new(Duration::from_secs(60));
730        assert!(
731            h.catalog_last_config_snapshot("p").await.unwrap().is_none(),
732            "no snapshot before any record"
733        );
734        let mk = |ver: &str| ConfigSnapshot {
735            pipeline: "p".into(),
736            recorded_at: Utc::now(),
737            faucet_version: ver.into(),
738            rows: BTreeMap::new(),
739        };
740        h.catalog_record_config_snapshot(&mk("1")).await.unwrap();
741        h.catalog_record_config_snapshot(&mk("2")).await.unwrap();
742        let got = h.catalog_last_config_snapshot("p").await.unwrap().unwrap();
743        assert_eq!(got.faucet_version, "2", "latest-wins upsert");
744        assert!(
745            h.catalog_last_config_snapshot("other")
746                .await
747                .unwrap()
748                .is_none(),
749            "snapshots are keyed per pipeline"
750        );
751    }
752
753    #[tokio::test]
754    async fn catalog_record_accumulates_datasets_edges_and_timeline() {
755        use serde_json::json;
756        let h = MemoryHistory::new(Duration::from_secs(60));
757        let schema_v1 = json!({"type": "object", "properties": {"id": {"type": "integer"}}});
758        let schema_v2 = json!({"type": "object", "properties": {"id": {"type": "integer"}, "email": {"type": "string"}}});
759
760        h.catalog_record(&catalog_update(
761            "csv://./in.csv",
762            "jsonl://./out.jsonl",
763            Some(schema_v1.clone()),
764        ))
765        .await
766        .unwrap();
767        // Same schema again → no new version.
768        h.catalog_record(&catalog_update(
769            "csv://./in.csv",
770            "jsonl://./out.jsonl",
771            Some(schema_v1),
772        ))
773        .await
774        .unwrap();
775        // Changed schema → second version with a diff.
776        h.catalog_record(&catalog_update(
777            "csv://./in.csv",
778            "jsonl://./out.jsonl",
779            Some(schema_v2),
780        ))
781        .await
782        .unwrap();
783
784        let page = h
785            .catalog_list_datasets(&CatalogListFilter {
786                limit: 10,
787                ..Default::default()
788            })
789            .await
790            .unwrap();
791        assert_eq!(page.datasets.len(), 2, "source + sink datasets");
792
793        let src_id = catalog::dataset_id("csv://./in.csv");
794        let detail = h.catalog_get_dataset(&src_id).await.unwrap().unwrap();
795        assert_eq!(detail.dataset.runs, 3);
796        assert_eq!(detail.dataset.total_records, 30);
797        assert_eq!(
798            detail.schema_timeline.len(),
799            2,
800            "identical schema deduped; change appended"
801        );
802        assert!(detail.schema_timeline[0].diff.is_none());
803        assert!(detail.schema_timeline[1].diff.is_some());
804        assert_eq!(detail.stats.len(), 3);
805        assert_eq!(detail.downstream.len(), 1);
806        assert!(detail.upstream.is_empty());
807        assert_eq!(detail.downstream[0].runs, 3);
808
809        // Lineage: one edge, whole graph == rooted graph.
810        let all = h.catalog_lineage(None, 5).await.unwrap();
811        assert_eq!(all.len(), 1);
812        let rooted = h.catalog_lineage(Some(&src_id), 3).await.unwrap();
813        assert_eq!(rooted.len(), 1);
814        assert!(
815            h.catalog_lineage(Some("missing"), 3)
816                .await
817                .unwrap()
818                .is_empty()
819        );
820        assert!(h.catalog_get_dataset("missing").await.unwrap().is_none());
821    }
822
823    #[tokio::test]
824    async fn purge_drops_expired_terminal_runs() {
825        let h = MemoryHistory::new(Duration::from_secs(60));
826        h.upsert(&rec(
827            "old",
828            RunStatus::Completed,
829            Utc::now() - chrono::Duration::seconds(10),
830        ))
831        .await
832        .unwrap();
833        h.upsert(&rec("live", RunStatus::Running, Utc::now()))
834            .await
835            .unwrap();
836        // retain_for = 0 → every terminal record is expired; running is kept.
837        let removed = h.purge_expired(Duration::ZERO).await.unwrap();
838        assert_eq!(removed, 1);
839        assert!(h.get("old").await.unwrap().is_none());
840        assert!(h.get("live").await.unwrap().is_some());
841    }
842}