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