1use super::catalog::{
6 self, CatalogDataset, CatalogDatasetDetail, CatalogDatasetPage, CatalogLineageEdge,
7 CatalogListFilter, CatalogSchemaVersion, CatalogStatsPoint, CatalogUpdate,
8};
9use super::templates;
10use super::{
11 AuditEntry, AuditFilter, Claim, DeleteOutcome, HistoryError, ListFilter, ListPage, RunHistory,
12 RunRecord,
13};
14use async_trait::async_trait;
15use chrono::{DateTime, Utc};
16use dashmap::DashMap;
17use std::collections::{BTreeMap, VecDeque};
18use std::sync::Mutex;
19use std::time::Duration;
20
21const AUDIT_RING_CAP: usize = 10_000;
24
25struct IdemEntry {
26 run_id: String,
27 fingerprint: String,
28 claimed_at: DateTime<Utc>,
29}
30
31#[derive(Default)]
35struct CatalogState {
36 datasets: std::collections::HashMap<String, CatalogDataset>,
37 schema_versions: std::collections::HashMap<String, Vec<CatalogSchemaVersion>>,
39 stats: std::collections::HashMap<String, Vec<CatalogStatsPoint>>,
41 edges: std::collections::HashMap<(String, String), CatalogLineageEdge>,
43 config_snapshots: std::collections::HashMap<String, super::catalog::ConfigSnapshot>,
45}
46
47pub struct MemoryHistory {
48 runs: DashMap<String, RunRecord>,
49 idem: DashMap<String, IdemEntry>,
50 audit: Mutex<VecDeque<AuditEntry>>,
52 catalog: Mutex<CatalogState>,
54 templates: Mutex<std::collections::HashMap<String, BTreeMap<u32, templates::TemplateRecord>>>,
57 template_tags: Mutex<std::collections::HashMap<String, BTreeMap<String, u32>>>,
61 template_launches: Mutex<std::collections::HashMap<String, Vec<templates::LaunchRecord>>>,
64 template_deprecations: Mutex<std::collections::HashMap<String, templates::DeprecationRecord>>,
66 idem_retention: Duration,
68}
69
70impl MemoryHistory {
71 pub fn new(idem_retention: Duration) -> Self {
72 Self {
73 runs: DashMap::new(),
74 idem: DashMap::new(),
75 audit: Mutex::new(VecDeque::new()),
76 catalog: Mutex::new(CatalogState::default()),
77 templates: Mutex::new(std::collections::HashMap::new()),
78 template_tags: Mutex::new(std::collections::HashMap::new()),
79 template_launches: Mutex::new(std::collections::HashMap::new()),
80 template_deprecations: Mutex::new(std::collections::HashMap::new()),
81 idem_retention,
82 }
83 }
84}
85
86fn is_expired(claimed_at: DateTime<Utc>, now: DateTime<Utc>, window: Duration) -> bool {
89 now.signed_duration_since(claimed_at)
90 .to_std()
91 .map(|age| age >= window)
92 .unwrap_or(false)
93}
94
95#[async_trait]
96impl RunHistory for MemoryHistory {
97 async fn claim_idempotency(
98 &self,
99 key: &str,
100 fingerprint: &str,
101 run_id: &str,
102 window: Duration,
103 ) -> Result<Claim, HistoryError> {
104 use dashmap::mapref::entry::Entry;
105 let now = Utc::now();
106 match self.idem.entry(key.to_string()) {
108 Entry::Occupied(mut e) => {
109 let expired = is_expired(e.get().claimed_at, now, window);
110 if expired {
111 e.insert(IdemEntry {
112 run_id: run_id.to_string(),
113 fingerprint: fingerprint.to_string(),
114 claimed_at: now,
115 });
116 Ok(Claim::Fresh)
117 } else if e.get().fingerprint == fingerprint {
118 Ok(Claim::Replay(e.get().run_id.clone()))
119 } else {
120 Ok(Claim::Conflict)
121 }
122 }
123 Entry::Vacant(v) => {
124 v.insert(IdemEntry {
125 run_id: run_id.to_string(),
126 fingerprint: fingerprint.to_string(),
127 claimed_at: now,
128 });
129 Ok(Claim::Fresh)
130 }
131 }
132 }
133
134 async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError> {
135 self.runs.insert(rec.run_id.clone(), rec.clone());
136 Ok(())
137 }
138
139 async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError> {
140 Ok(self.runs.get(id).map(|r| r.clone()))
141 }
142
143 async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError> {
144 let mut rows: Vec<RunRecord> = self
145 .runs
146 .iter()
147 .map(|r| r.clone())
148 .filter(|r| filter.status.is_none_or(|s| r.status == s))
149 .filter(|r| {
150 filter
151 .name
152 .as_deref()
153 .is_none_or(|n| r.name.as_deref() == Some(n))
154 })
155 .filter(|r| filter.since.is_none_or(|t| r.submitted_at >= t))
156 .filter(|r| filter.until.is_none_or(|t| r.submitted_at <= t))
157 .collect();
158 rows.sort_by(|a, b| {
160 b.submitted_at
161 .cmp(&a.submitted_at)
162 .then_with(|| b.run_id.cmp(&a.run_id))
163 });
164 if let Some(cursor) = &filter.cursor
166 && let Some(pos) = rows.iter().position(|r| &r.run_id == cursor)
167 {
168 rows.drain(..=pos);
169 }
170 let limit = filter.limit.max(1);
171 let next_cursor = if rows.len() > limit {
172 Some(rows[limit - 1].run_id.clone())
173 } else {
174 None
175 };
176 rows.truncate(limit);
177 Ok(ListPage {
178 runs: rows,
179 next_cursor,
180 })
181 }
182
183 async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError> {
184 let Some(rec) = self.runs.get(id).map(|r| r.clone()) else {
185 return Ok(DeleteOutcome::NotFound);
186 };
187 if !rec.status.is_terminal() {
188 return Ok(DeleteOutcome::StillRunning);
189 }
190 self.runs.remove(id);
191 if let Some(key) = rec.idempotency_key.as_deref() {
196 self.idem.remove_if(key, |_, e| e.run_id == id);
197 }
198 Ok(DeleteOutcome::Deleted)
199 }
200
201 async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError> {
202 let now = Utc::now();
203 let before = self.runs.len();
204 self.runs.retain(|_, r| {
205 !r.status.is_terminal()
206 || r.finished_at
207 .map(|f| !is_expired(f, now, retain_for))
208 .unwrap_or(true)
209 });
210 self.idem
212 .retain(|_, e| !is_expired(e.claimed_at, now, self.idem_retention));
213 if let Ok(mut ring) = self.audit.lock() {
215 ring.retain(|e| !is_expired(e.timestamp, now, retain_for));
216 }
217 Ok(before.saturating_sub(self.runs.len()))
218 }
219
220 async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
221 let mut ring = self
222 .audit
223 .lock()
224 .map_err(|_| HistoryError::Backend("audit ring lock poisoned".into()))?;
225 ring.push_back(entry.clone());
226 while ring.len() > AUDIT_RING_CAP {
227 ring.pop_front();
228 }
229 Ok(())
230 }
231
232 async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
233 let ring = self
234 .audit
235 .lock()
236 .map_err(|_| HistoryError::Backend("audit ring lock poisoned".into()))?;
237 let mut rows: Vec<AuditEntry> = ring
238 .iter()
239 .filter(|e| filter.principal.as_deref().is_none_or(|p| e.principal == p))
240 .filter(|e| filter.action.as_deref().is_none_or(|a| e.action == a))
241 .filter(|e| filter.since.is_none_or(|t| e.timestamp >= t))
242 .filter(|e| filter.until.is_none_or(|t| e.timestamp <= t))
243 .cloned()
244 .collect();
245 rows.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| b.id.cmp(&a.id)));
247 rows.truncate(filter.limit.max(1));
248 Ok(rows)
249 }
250
251 async fn recover_orphans(&self) -> Result<usize, HistoryError> {
252 Ok(0)
253 }
254
255 async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
256 use crate::serve::history::RunStatus;
257 if let Some(mut r) = self.runs.get_mut(run_id)
258 && r.status == RunStatus::Pending
259 {
260 r.status = RunStatus::Cancelled;
261 r.finished_at = Some(Utc::now());
262 return Ok(true);
263 }
264 Ok(false)
265 }
266
267 async fn catalog_record(&self, update: &CatalogUpdate) -> Result<(), HistoryError> {
270 let lock_err = |_| HistoryError::Backend("catalog lock poisoned".into());
271 let mut cat = self.catalog.lock().map_err(lock_err)?;
272 let mut stat_ids_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
277 for obs in update.sources.iter().chain(std::iter::once(&update.sink)) {
278 let id = catalog::dataset_id(&obs.uri);
279 let (ds, new_version) = catalog::apply_observation(
280 cat.datasets.get(&id),
281 obs,
282 &update.run_id,
283 &update.pipeline,
284 &update.row,
285 update.recorded_at,
286 );
287 if let Some(v) = new_version {
288 cat.schema_versions.entry(id.clone()).or_default().push(v);
289 }
290 if stat_ids_seen.insert(id.clone()) {
291 let points = cat.stats.entry(id.clone()).or_default();
292 points.push(CatalogStatsPoint {
293 recorded_at: update.recorded_at,
294 run_id: update.run_id.clone(),
295 records: obs.records,
296 });
297 if points.len() > catalog::STATS_RETAIN {
298 let drop_n = points.len() - catalog::STATS_RETAIN;
299 points.drain(..drop_n);
300 }
301 }
302 cat.datasets.insert(id, ds);
303 }
304 let edges_before = cat.edges.clone();
310 for source in &update.sources {
311 let key = (
312 catalog::dataset_id(&source.uri),
313 catalog::dataset_id(&update.sink.uri),
314 );
315 let edge = catalog::apply_edge(edges_before.get(&key), update, source);
316 cat.edges.insert(key, edge);
317 }
318 Ok(())
319 }
320
321 async fn catalog_list_datasets(
322 &self,
323 filter: &CatalogListFilter,
324 ) -> Result<CatalogDatasetPage, HistoryError> {
325 let cat = self
326 .catalog
327 .lock()
328 .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
329 Ok(catalog::filter_datasets(
330 cat.datasets.values().cloned().collect(),
331 filter,
332 ))
333 }
334
335 async fn catalog_get_dataset(
336 &self,
337 id: &str,
338 ) -> Result<Option<CatalogDatasetDetail>, HistoryError> {
339 let cat = self
340 .catalog
341 .lock()
342 .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
343 let Some(dataset) = cat.datasets.get(id).cloned() else {
344 return Ok(None);
345 };
346 let schema_timeline = cat.schema_versions.get(id).cloned().unwrap_or_default();
347 let mut stats: Vec<CatalogStatsPoint> = cat.stats.get(id).cloned().unwrap_or_default();
348 stats.reverse(); stats.truncate(catalog::STATS_DETAIL_LIMIT);
350 let mut all: Vec<CatalogLineageEdge> = cat.edges.values().cloned().collect();
357 all.sort_by(|a, b| {
358 b.last_seen
359 .cmp(&a.last_seen)
360 .then_with(|| a.src_id.cmp(&b.src_id))
361 .then_with(|| a.dst_id.cmp(&b.dst_id))
362 });
363 let (downstream, rest): (Vec<_>, Vec<_>) = all.into_iter().partition(|e| e.src_id == id);
364 let upstream = rest.into_iter().filter(|e| e.dst_id == id).collect();
365 Ok(Some(CatalogDatasetDetail {
366 dataset,
367 schema_timeline,
368 stats,
369 upstream,
370 downstream,
371 }))
372 }
373
374 async fn catalog_lineage(
375 &self,
376 root: Option<&str>,
377 depth: u32,
378 ) -> Result<Vec<CatalogLineageEdge>, HistoryError> {
379 let cat = self
380 .catalog
381 .lock()
382 .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
383 let mut edges: Vec<CatalogLineageEdge> = cat.edges.values().cloned().collect();
384 edges.sort_by(|a, b| {
386 b.last_seen
387 .cmp(&a.last_seen)
388 .then_with(|| (&a.src_id, &a.dst_id).cmp(&(&b.src_id, &b.dst_id)))
389 });
390 Ok(catalog::lineage_slice(edges, root, depth))
391 }
392
393 async fn catalog_record_config_snapshot(
394 &self,
395 snapshot: &catalog::ConfigSnapshot,
396 ) -> Result<(), HistoryError> {
397 let mut cat = self
398 .catalog
399 .lock()
400 .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
401 cat.config_snapshots
402 .insert(snapshot.pipeline.clone(), snapshot.clone());
403 Ok(())
404 }
405
406 async fn catalog_last_config_snapshot(
407 &self,
408 pipeline: &str,
409 ) -> Result<Option<catalog::ConfigSnapshot>, HistoryError> {
410 let cat = self
411 .catalog
412 .lock()
413 .map_err(|_| HistoryError::Backend("catalog lock poisoned".into()))?;
414 Ok(cat.config_snapshots.get(pipeline).cloned())
415 }
416
417 async fn template_register(
420 &self,
421 draft: &templates::TemplateDraft,
422 ) -> Result<templates::TemplateRecord, HistoryError> {
423 let mut store = self
424 .templates
425 .lock()
426 .map_err(|_| HistoryError::Backend("template lock poisoned".into()))?;
427 let id = draft.id.to_string();
428 let versions = store.entry(id.clone()).or_default();
429 let next = versions.keys().copied().max().unwrap_or(0) + 1;
430 let record = templates::TemplateRecord {
431 id,
432 version: next,
433 name: draft.name.clone(),
434 description: draft.description.clone(),
435 body: draft.body.clone(),
436 format: draft.format,
437 params: draft.params.clone(),
438 created_at: Utc::now(),
439 created_by: draft.created_by.clone(),
440 };
441 versions.insert(next, record.clone());
442 for stale in templates::versions_to_prune(versions.keys().copied().collect()) {
443 versions.remove(&stale);
444 }
445 Ok(record)
446 }
447
448 async fn template_get(
449 &self,
450 id: &str,
451 version: Option<u32>,
452 ) -> Result<Option<templates::TemplateRecord>, HistoryError> {
453 let store = self
454 .templates
455 .lock()
456 .map_err(|_| HistoryError::Backend("template lock poisoned".into()))?;
457 let Some(versions) = store.get(id) else {
458 return Ok(None);
459 };
460 let picked = match version {
461 Some(v) => versions.get(&v),
462 None => versions
463 .keys()
464 .copied()
465 .max()
466 .and_then(|v| versions.get(&v)),
467 };
468 Ok(picked.cloned())
469 }
470
471 async fn template_list(&self) -> Result<Vec<templates::TemplateSummary>, HistoryError> {
472 let store = self
473 .templates
474 .lock()
475 .map_err(|_| HistoryError::Backend("template lock poisoned".into()))?;
476 let all: Vec<templates::TemplateRecord> =
477 store.values().flat_map(|v| v.values().cloned()).collect();
478 Ok(templates::latest_per_id(all))
479 }
480
481 async fn template_versions(&self, id: &str) -> Result<Vec<u32>, HistoryError> {
482 let store = self
483 .templates
484 .lock()
485 .map_err(|_| HistoryError::Backend("template lock poisoned".into()))?;
486 let mut versions: Vec<u32> = store
487 .get(id)
488 .map(|v| v.keys().copied().collect())
489 .unwrap_or_default();
490 versions.sort_unstable_by(|a, b| b.cmp(a));
491 Ok(versions)
492 }
493
494 async fn template_delete(&self, id: &str, version: Option<u32>) -> Result<usize, HistoryError> {
495 let mut store = self
496 .templates
497 .lock()
498 .map_err(|_| HistoryError::Backend("template lock poisoned".into()))?;
499 let mut tags = self
500 .template_tags
501 .lock()
502 .map_err(|_| HistoryError::Backend("template tag lock poisoned".into()))?;
503 let mut launches = self
504 .template_launches
505 .lock()
506 .map_err(|_| HistoryError::Backend("template launch lock poisoned".into()))?;
507 match version {
508 None => {
509 tags.remove(id);
510 launches.remove(id);
511 self.template_deprecations
512 .lock()
513 .map_err(|_| {
514 HistoryError::Backend("template deprecation lock poisoned".into())
515 })?
516 .remove(id);
517 Ok(store.remove(id).map(|v| v.len()).unwrap_or(0))
518 }
519 Some(v) => {
520 let Some(versions) = store.get_mut(id) else {
521 return Ok(0);
522 };
523 let removed = versions.remove(&v).is_some() as usize;
524 if versions.is_empty() {
525 store.remove(id);
526 tags.remove(id);
527 launches.remove(id);
528 } else {
529 if let Some(t) = tags.get_mut(id) {
530 t.retain(|_, pointed| *pointed != v);
532 if t.is_empty() {
533 tags.remove(id);
534 }
535 }
536 if let Some(log) = launches.get_mut(id) {
539 log.retain(|l| l.version != v);
540 if log.is_empty() {
541 launches.remove(id);
542 }
543 }
544 }
545 Ok(removed)
546 }
547 }
548 }
549
550 async fn template_set_tag(
551 &self,
552 id: &str,
553 tag: &str,
554 version: u32,
555 ) -> Result<(), HistoryError> {
556 let mut tags = self
557 .template_tags
558 .lock()
559 .map_err(|_| HistoryError::Backend("template tag lock poisoned".into()))?;
560 tags.entry(id.to_string())
561 .or_default()
562 .insert(tag.to_string(), version);
563 Ok(())
564 }
565
566 async fn template_tags(&self, id: &str) -> Result<BTreeMap<String, u32>, HistoryError> {
567 let tags = self
568 .template_tags
569 .lock()
570 .map_err(|_| HistoryError::Backend("template tag lock poisoned".into()))?;
571 Ok(tags.get(id).cloned().unwrap_or_default())
572 }
573
574 async fn template_delete_tag(&self, id: &str, tag: &str) -> Result<bool, HistoryError> {
575 let mut tags = self
576 .template_tags
577 .lock()
578 .map_err(|_| HistoryError::Backend("template tag lock poisoned".into()))?;
579 let Some(t) = tags.get_mut(id) else {
580 return Ok(false);
581 };
582 let existed = t.remove(tag).is_some();
583 if t.is_empty() {
584 tags.remove(id);
585 }
586 Ok(existed)
587 }
588
589 async fn template_launch(
590 &self,
591 id: &str,
592 version: u32,
593 launched_by: Option<&str>,
594 ) -> Result<Option<u32>, HistoryError> {
595 let mut launches = self
596 .template_launches
597 .lock()
598 .map_err(|_| HistoryError::Backend("template launch lock poisoned".into()))?;
599 let log = launches.entry(id.to_string()).or_default();
600 if templates::stable_version(log) == Some(version) {
603 return Ok(None);
604 }
605 let seq = log.first().map(|l| l.seq).unwrap_or(0) + 1;
606 log.insert(
607 0,
608 templates::LaunchRecord {
609 seq,
610 version,
611 launched_at: Utc::now(),
612 launched_by: launched_by.map(str::to_string),
613 },
614 );
615 Ok(Some(seq))
616 }
617
618 async fn template_launches(
619 &self,
620 id: &str,
621 ) -> Result<Vec<templates::LaunchRecord>, HistoryError> {
622 let launches = self
623 .template_launches
624 .lock()
625 .map_err(|_| HistoryError::Backend("template launch lock poisoned".into()))?;
626 Ok(launches.get(id).cloned().unwrap_or_default())
627 }
628
629 async fn template_set_deprecation(
630 &self,
631 id: &str,
632 record: Option<&templates::DeprecationRecord>,
633 ) -> Result<(), HistoryError> {
634 let mut deprecations = self
635 .template_deprecations
636 .lock()
637 .map_err(|_| HistoryError::Backend("template deprecation lock poisoned".into()))?;
638 match record {
639 Some(r) => {
640 deprecations.insert(id.to_string(), r.clone());
641 }
642 None => {
643 deprecations.remove(id);
644 }
645 }
646 Ok(())
647 }
648
649 async fn template_deprecation(
650 &self,
651 id: &str,
652 ) -> Result<Option<templates::DeprecationRecord>, HistoryError> {
653 let deprecations = self
654 .template_deprecations
655 .lock()
656 .map_err(|_| HistoryError::Backend("template deprecation lock poisoned".into()))?;
657 Ok(deprecations.get(id).cloned())
658 }
659
660 fn degraded(&self) -> bool {
661 false
662 }
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668 use crate::serve::history::RunStatus;
669 use std::collections::BTreeMap;
670
671 fn rec(id: &str, status: RunStatus, submitted: DateTime<Utc>) -> RunRecord {
672 let mut r = RunRecord::queued(id.into(), None, BTreeMap::new(), None, submitted);
673 r.status = status;
674 if status.is_terminal() {
675 r.finished_at = Some(submitted);
676 }
677 r
678 }
679
680 #[tokio::test]
681 async fn upsert_then_get_roundtrips() {
682 let h = MemoryHistory::new(Duration::from_secs(60));
683 let r = rec("a", RunStatus::Queued, Utc::now());
684 h.upsert(&r).await.unwrap();
685 assert_eq!(h.get("a").await.unwrap().unwrap().run_id, "a");
686 assert!(h.get("missing").await.unwrap().is_none());
687 }
688
689 #[tokio::test]
690 async fn idempotency_fresh_replay_conflict() {
691 let h = MemoryHistory::new(Duration::from_secs(60));
692 let w = Duration::from_secs(60);
693 assert_eq!(
694 h.claim_idempotency("k", "fp1", "run1", w).await.unwrap(),
695 Claim::Fresh
696 );
697 assert_eq!(
699 h.claim_idempotency("k", "fp1", "run2", w).await.unwrap(),
700 Claim::Replay("run1".into())
701 );
702 assert_eq!(
704 h.claim_idempotency("k", "fp2", "run3", w).await.unwrap(),
705 Claim::Conflict
706 );
707 }
708
709 #[tokio::test]
710 async fn expired_claim_is_reclaimable() {
711 let h = MemoryHistory::new(Duration::from_secs(60));
712 let w = Duration::ZERO;
714 assert_eq!(
715 h.claim_idempotency("k", "fp1", "run1", w).await.unwrap(),
716 Claim::Fresh
717 );
718 assert_eq!(
719 h.claim_idempotency("k", "fp2", "run2", w).await.unwrap(),
720 Claim::Fresh
721 );
722 }
723
724 #[tokio::test]
725 async fn delete_respects_terminal_state() {
726 let h = MemoryHistory::new(Duration::from_secs(60));
727 h.upsert(&rec("run", RunStatus::Running, Utc::now()))
728 .await
729 .unwrap();
730 assert_eq!(h.delete("run").await.unwrap(), DeleteOutcome::StillRunning);
731 assert_eq!(h.delete("nope").await.unwrap(), DeleteOutcome::NotFound);
732 h.upsert(&rec("run", RunStatus::Completed, Utc::now()))
733 .await
734 .unwrap();
735 assert_eq!(h.delete("run").await.unwrap(), DeleteOutcome::Deleted);
736 assert!(h.get("run").await.unwrap().is_none());
737 }
738
739 #[tokio::test]
740 async fn delete_also_removes_matching_idem_claim() {
741 let h = MemoryHistory::new(Duration::from_secs(3600));
745 let w = Duration::from_secs(3600);
746 assert_eq!(
747 h.claim_idempotency("k", "fp", "r1", w).await.unwrap(),
748 Claim::Fresh
749 );
750 let mut r = RunRecord::queued(
751 "r1".into(),
752 None,
753 BTreeMap::new(),
754 Some("k".into()),
755 Utc::now(),
756 );
757 r.status = RunStatus::Completed;
758 r.finished_at = Some(Utc::now());
759 h.upsert(&r).await.unwrap();
760
761 assert_eq!(h.delete("r1").await.unwrap(), DeleteOutcome::Deleted);
762 assert_eq!(
764 h.claim_idempotency("k", "fp", "r2", w).await.unwrap(),
765 Claim::Fresh
766 );
767 }
768
769 #[tokio::test]
770 async fn delete_keeps_claim_owned_by_a_newer_run() {
771 let h = MemoryHistory::new(Duration::from_secs(3600));
773 h.claim_idempotency("k", "fp", "r1", Duration::from_secs(3600))
774 .await
775 .unwrap();
776 assert_eq!(
778 h.claim_idempotency("k", "fp", "r2", Duration::ZERO)
779 .await
780 .unwrap(),
781 Claim::Fresh
782 );
783 let mut r1 = RunRecord::queued(
784 "r1".into(),
785 None,
786 BTreeMap::new(),
787 Some("k".into()),
788 Utc::now(),
789 );
790 r1.status = RunStatus::Completed;
791 r1.finished_at = Some(Utc::now());
792 h.upsert(&r1).await.unwrap();
793 assert_eq!(h.delete("r1").await.unwrap(), DeleteOutcome::Deleted);
794 assert_eq!(
796 h.claim_idempotency("k", "fp", "r3", Duration::from_secs(3600))
797 .await
798 .unwrap(),
799 Claim::Replay("r2".into())
800 );
801 }
802
803 #[tokio::test]
804 async fn list_orders_desc_and_paginates() {
805 let h = MemoryHistory::new(Duration::from_secs(60));
806 let t0 = Utc::now();
807 for (i, id) in ["a", "b", "c"].iter().enumerate() {
808 h.upsert(&rec(
809 id,
810 RunStatus::Completed,
811 t0 + chrono::Duration::seconds(i as i64),
812 ))
813 .await
814 .unwrap();
815 }
816 let page = h
818 .list(&ListFilter {
819 limit: 2,
820 ..Default::default()
821 })
822 .await
823 .unwrap();
824 assert_eq!(
825 page.runs
826 .iter()
827 .map(|r| r.run_id.clone())
828 .collect::<Vec<_>>(),
829 vec!["c", "b"]
830 );
831 assert_eq!(page.next_cursor.as_deref(), Some("b"));
832 let page2 = h
834 .list(&ListFilter {
835 limit: 2,
836 cursor: Some("b".into()),
837 ..Default::default()
838 })
839 .await
840 .unwrap();
841 assert_eq!(
842 page2
843 .runs
844 .iter()
845 .map(|r| r.run_id.clone())
846 .collect::<Vec<_>>(),
847 vec!["a"]
848 );
849 assert!(page2.next_cursor.is_none());
850 }
851
852 #[tokio::test]
853 async fn list_filters_by_status_and_name() {
854 let h = MemoryHistory::new(Duration::from_secs(60));
855 let mut r = rec("x", RunStatus::Failed, Utc::now());
856 r.name = Some("nightly".into());
857 h.upsert(&r).await.unwrap();
858 h.upsert(&rec("y", RunStatus::Completed, Utc::now()))
859 .await
860 .unwrap();
861 let only_failed = h
862 .list(&ListFilter {
863 status: Some(RunStatus::Failed),
864 limit: 50,
865 ..Default::default()
866 })
867 .await
868 .unwrap();
869 assert_eq!(only_failed.runs.len(), 1);
870 assert_eq!(only_failed.runs[0].run_id, "x");
871 let by_name = h
873 .list(&ListFilter {
874 name: Some("nightly".into()),
875 limit: 50,
876 ..Default::default()
877 })
878 .await
879 .unwrap();
880 assert_eq!(by_name.runs.len(), 1);
881 assert_eq!(by_name.runs[0].run_id, "x");
882 }
883
884 #[tokio::test]
885 async fn audit_record_list_filter_and_purge() {
886 use crate::serve::history::{AuditEntry, AuditFilter};
887 let h = MemoryHistory::new(Duration::from_secs(60));
888 let now = Utc::now();
889 let entry =
890 |id: &str, principal: &str, action: &str, result: &str, ts: DateTime<Utc>| AuditEntry {
891 id: id.into(),
892 timestamp: ts,
893 principal: principal.into(),
894 role: "admin".into(),
895 action: action.into(),
896 run_id: None,
897 config_fingerprint: None,
898 source_ip: None,
899 result: result.into(),
900 };
901 h.record_audit(&entry(
902 "1",
903 "alice",
904 "run.submit",
905 "ok",
906 now - chrono::Duration::seconds(2),
907 ))
908 .await
909 .unwrap();
910 h.record_audit(&entry(
911 "2",
912 "bob",
913 "run.submit",
914 "denied",
915 now - chrono::Duration::seconds(1),
916 ))
917 .await
918 .unwrap();
919 h.record_audit(&entry("3", "alice", "run.cancel", "ok", now))
920 .await
921 .unwrap();
922
923 let all = h
925 .list_audit(&AuditFilter {
926 limit: 50,
927 ..Default::default()
928 })
929 .await
930 .unwrap();
931 assert_eq!(all.len(), 3);
932 assert_eq!(all[0].id, "3", "newest first");
933
934 let alice = h
936 .list_audit(&AuditFilter {
937 principal: Some("alice".into()),
938 limit: 50,
939 ..Default::default()
940 })
941 .await
942 .unwrap();
943 assert_eq!(alice.len(), 2);
944 assert!(alice.iter().all(|e| e.principal == "alice"));
945
946 let denied = h
947 .list_audit(&AuditFilter {
948 action: Some("run.submit".into()),
949 limit: 50,
950 ..Default::default()
951 })
952 .await
953 .unwrap();
954 assert_eq!(denied.len(), 2);
955
956 let one = h
958 .list_audit(&AuditFilter {
959 limit: 1,
960 ..Default::default()
961 })
962 .await
963 .unwrap();
964 assert_eq!(one.len(), 1);
965
966 h.purge_expired(Duration::ZERO).await.unwrap();
968 let after = h
969 .list_audit(&AuditFilter {
970 limit: 50,
971 ..Default::default()
972 })
973 .await
974 .unwrap();
975 assert!(after.is_empty(), "audit purge should clear expired entries");
976 }
977
978 fn catalog_update(src: &str, dst: &str, schema: Option<serde_json::Value>) -> CatalogUpdate {
979 use crate::serve::history::catalog::{DatasetObservation, DatasetRole};
980 CatalogUpdate {
981 run_id: "r1".into(),
982 pipeline: "p".into(),
983 row: "default".into(),
984 recorded_at: Utc::now(),
985 sources: vec![DatasetObservation {
986 uri: src.into(),
987 kind: "csv".into(),
988 role: DatasetRole::Source,
989 schema: schema.clone(),
990 records: 10,
991 }],
992 sink: DatasetObservation {
993 uri: dst.into(),
994 kind: "jsonl".into(),
995 role: DatasetRole::Sink,
996 schema,
997 records: 10,
998 },
999 column_lineage: None,
1000 }
1001 }
1002
1003 #[tokio::test]
1004 async fn catalog_source_equal_sink_dedups_stats_and_self_loop() {
1005 let h = MemoryHistory::new(Duration::from_secs(3600));
1010 let uri = "file:///same/path.jsonl";
1011 h.catalog_record(&catalog_update(uri, uri, None))
1012 .await
1013 .unwrap();
1014
1015 let id = crate::serve::history::catalog::dataset_id(uri);
1016 let detail = h.catalog_get_dataset(&id).await.unwrap().expect("dataset");
1017 assert_eq!(
1018 detail.stats.len(),
1019 1,
1020 "source==sink id must record one stats point, not two"
1021 );
1022 assert_eq!(detail.downstream.len(), 1, "self-loop edge is downstream");
1023 assert!(
1024 detail.upstream.is_empty(),
1025 "a self-loop must not also appear as upstream"
1026 );
1027 }
1028
1029 #[tokio::test]
1030 async fn config_snapshot_roundtrips_latest_wins() {
1031 use crate::serve::history::catalog::ConfigSnapshot;
1032 use std::collections::BTreeMap;
1033 let h = MemoryHistory::new(Duration::from_secs(60));
1034 assert!(
1035 h.catalog_last_config_snapshot("p").await.unwrap().is_none(),
1036 "no snapshot before any record"
1037 );
1038 let mk = |ver: &str| ConfigSnapshot {
1039 pipeline: "p".into(),
1040 recorded_at: Utc::now(),
1041 faucet_version: ver.into(),
1042 rows: BTreeMap::new(),
1043 };
1044 h.catalog_record_config_snapshot(&mk("1")).await.unwrap();
1045 h.catalog_record_config_snapshot(&mk("2")).await.unwrap();
1046 let got = h.catalog_last_config_snapshot("p").await.unwrap().unwrap();
1047 assert_eq!(got.faucet_version, "2", "latest-wins upsert");
1048 assert!(
1049 h.catalog_last_config_snapshot("other")
1050 .await
1051 .unwrap()
1052 .is_none(),
1053 "snapshots are keyed per pipeline"
1054 );
1055 }
1056
1057 #[tokio::test]
1058 async fn catalog_record_accumulates_datasets_edges_and_timeline() {
1059 use serde_json::json;
1060 let h = MemoryHistory::new(Duration::from_secs(60));
1061 let schema_v1 = json!({"type": "object", "properties": {"id": {"type": "integer"}}});
1062 let schema_v2 = json!({"type": "object", "properties": {"id": {"type": "integer"}, "email": {"type": "string"}}});
1063
1064 h.catalog_record(&catalog_update(
1065 "csv://./in.csv",
1066 "jsonl://./out.jsonl",
1067 Some(schema_v1.clone()),
1068 ))
1069 .await
1070 .unwrap();
1071 h.catalog_record(&catalog_update(
1073 "csv://./in.csv",
1074 "jsonl://./out.jsonl",
1075 Some(schema_v1),
1076 ))
1077 .await
1078 .unwrap();
1079 h.catalog_record(&catalog_update(
1081 "csv://./in.csv",
1082 "jsonl://./out.jsonl",
1083 Some(schema_v2),
1084 ))
1085 .await
1086 .unwrap();
1087
1088 let page = h
1089 .catalog_list_datasets(&CatalogListFilter {
1090 limit: 10,
1091 ..Default::default()
1092 })
1093 .await
1094 .unwrap();
1095 assert_eq!(page.datasets.len(), 2, "source + sink datasets");
1096
1097 let src_id = catalog::dataset_id("csv://./in.csv");
1098 let detail = h.catalog_get_dataset(&src_id).await.unwrap().unwrap();
1099 assert_eq!(detail.dataset.runs, 3);
1100 assert_eq!(detail.dataset.total_records, 30);
1101 assert_eq!(
1102 detail.schema_timeline.len(),
1103 2,
1104 "identical schema deduped; change appended"
1105 );
1106 assert!(detail.schema_timeline[0].diff.is_none());
1107 assert!(detail.schema_timeline[1].diff.is_some());
1108 assert_eq!(detail.stats.len(), 3);
1109 assert_eq!(detail.downstream.len(), 1);
1110 assert!(detail.upstream.is_empty());
1111 assert_eq!(detail.downstream[0].runs, 3);
1112
1113 let all = h.catalog_lineage(None, 5).await.unwrap();
1115 assert_eq!(all.len(), 1);
1116 let rooted = h.catalog_lineage(Some(&src_id), 3).await.unwrap();
1117 assert_eq!(rooted.len(), 1);
1118 assert!(
1119 h.catalog_lineage(Some("missing"), 3)
1120 .await
1121 .unwrap()
1122 .is_empty()
1123 );
1124 assert!(h.catalog_get_dataset("missing").await.unwrap().is_none());
1125 }
1126
1127 #[tokio::test]
1128 async fn purge_drops_expired_terminal_runs() {
1129 let h = MemoryHistory::new(Duration::from_secs(60));
1130 h.upsert(&rec(
1131 "old",
1132 RunStatus::Completed,
1133 Utc::now() - chrono::Duration::seconds(10),
1134 ))
1135 .await
1136 .unwrap();
1137 h.upsert(&rec("live", RunStatus::Running, Utc::now()))
1138 .await
1139 .unwrap();
1140 let removed = h.purge_expired(Duration::ZERO).await.unwrap();
1142 assert_eq!(removed, 1);
1143 assert!(h.get("old").await.unwrap().is_none());
1144 assert!(h.get("live").await.unwrap().is_some());
1145 }
1146}