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