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}
43
44pub struct MemoryHistory {
45 runs: DashMap<String, RunRecord>,
46 idem: DashMap<String, IdemEntry>,
47 audit: Mutex<VecDeque<AuditEntry>>,
49 catalog: Mutex<CatalogState>,
51 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
67fn 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 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 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 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 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 self.idem
193 .retain(|_, e| !is_expired(e.claimed_at, now, self.idem_retention));
194 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 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 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(); 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 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 assert_eq!(
395 h.claim_idempotency("k", "fp1", "run2", w).await.unwrap(),
396 Claim::Replay("run1".into())
397 );
398 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 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 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 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 let h = MemoryHistory::new(Duration::from_secs(3600));
469 h.claim_idempotency("k", "fp", "r1", Duration::from_secs(3600))
470 .await
471 .unwrap();
472 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 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 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 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 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 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 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 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 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 h.catalog_record(&catalog_update(
715 "csv://./in.csv",
716 "jsonl://./out.jsonl",
717 Some(schema_v1),
718 ))
719 .await
720 .unwrap();
721 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 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 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}