1use 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
20const AUDIT_RING_CAP: usize = 10_000;
23
24struct IdemEntry {
25 run_id: String,
26 fingerprint: String,
27 claimed_at: DateTime<Utc>,
28}
29
30#[derive(Default)]
34struct CatalogState {
35 datasets: std::collections::HashMap<String, CatalogDataset>,
36 schema_versions: std::collections::HashMap<String, Vec<CatalogSchemaVersion>>,
38 stats: std::collections::HashMap<String, Vec<CatalogStatsPoint>>,
40 edges: std::collections::HashMap<(String, String), CatalogLineageEdge>,
42 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 audit: Mutex<VecDeque<AuditEntry>>,
51 catalog: Mutex<CatalogState>,
53 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
69fn 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 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 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 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 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 self.idem
195 .retain(|_, e| !is_expired(e.claimed_at, now, self.idem_retention));
196 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 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 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(); 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 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 assert_eq!(
421 h.claim_idempotency("k", "fp1", "run2", w).await.unwrap(),
422 Claim::Replay("run1".into())
423 );
424 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 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 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 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 let h = MemoryHistory::new(Duration::from_secs(3600));
495 h.claim_idempotency("k", "fp", "r1", Duration::from_secs(3600))
496 .await
497 .unwrap();
498 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 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 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 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 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 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 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 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 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 h.catalog_record(&catalog_update(
769 "csv://./in.csv",
770 "jsonl://./out.jsonl",
771 Some(schema_v1),
772 ))
773 .await
774 .unwrap();
775 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 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 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}