Skip to main content

faucet_cli/serve/history/
memory.rs

1//! `DashMap`-backed run history (default backend). Lost on restart; that is the
2//! documented memory-backend trade-off. Idempotency claims live in a second map
3//! and are pruned both lazily (on re-claim) and by `purge_expired`.
4
5use super::catalog::{
6    self, CatalogDataset, CatalogDatasetDetail, CatalogDatasetPage, CatalogLineageEdge,
7    CatalogListFilter, CatalogSchemaVersion, CatalogStatsPoint, CatalogUpdate,
8};
9use super::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
21/// Cap on in-memory audit records (oldest dropped past this). The memory backend
22/// is ephemeral anyway; this just bounds growth for a long-lived process.
23const AUDIT_RING_CAP: usize = 10_000;
24
25struct IdemEntry {
26    run_id: String,
27    fingerprint: String,
28    claimed_at: DateTime<Utc>,
29}
30
31/// In-memory Data Movement Catalog state (#279). One `Mutex` guards the whole
32/// catalog so a `catalog_record` (a read-modify-write across three maps) is
33/// atomic without per-map lock ordering.
34#[derive(Default)]
35struct CatalogState {
36    datasets: std::collections::HashMap<String, CatalogDataset>,
37    /// dataset id → timeline, oldest first.
38    schema_versions: std::collections::HashMap<String, Vec<CatalogSchemaVersion>>,
39    /// dataset id → volume points, oldest first, capped at `STATS_RETAIN`.
40    stats: std::collections::HashMap<String, Vec<CatalogStatsPoint>>,
41    /// (src id, dst id) → edge.
42    edges: std::collections::HashMap<(String, String), CatalogLineageEdge>,
43    /// pipeline name → latest config snapshot (#374). Latest-wins.
44    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    /// Bounded, newest-at-back ring of audit records (RBAC, #205).
51    audit: Mutex<VecDeque<AuditEntry>>,
52    /// Data Movement Catalog (#279). Ephemeral like everything else here.
53    catalog: Mutex<CatalogState>,
54    /// Pipeline-template registry (#444): id → version → record. One `Mutex`
55    /// keeps version assignment atomic (read-max-then-insert).
56    templates: Mutex<std::collections::HashMap<String, BTreeMap<u32, templates::TemplateRecord>>>,
57    /// Named channel pointers (#444): template id → tag → version. Guarded by
58    /// the same lock as `templates` would be if it mattered; a separate `Mutex` is
59    /// fine because a tag is only ever written after its version exists.
60    template_tags: Mutex<std::collections::HashMap<String, BTreeMap<String, u32>>>,
61    /// Append-only launch log per template, newest first. Source of truth for
62    /// `stable` / `previous` and for the derived template status.
63    template_launches: Mutex<std::collections::HashMap<String, Vec<templates::LaunchRecord>>>,
64    /// Deprecation markers — the only *stored* part of the lifecycle status.
65    template_deprecations: Mutex<std::collections::HashMap<String, templates::DeprecationRecord>>,
66    /// Retention window for idempotency claims (separate from run retention).
67    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
86/// True when `claimed_at` is older than `window` relative to `now`. A claim
87/// timestamped in the future (clock skew) is treated as *not* expired.
88fn 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        // Holding the entry locks the shard, so claim is atomic under contention.
107        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        // (submitted_at DESC, run_id DESC)
159        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        // Cursor = last run_id seen on the previous page; skip past it.
165        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        // Also drop this run's idempotency claim so a replay of the key starts a
192        // fresh run instead of 404-ing on the now-deleted record until the claim
193        // self-expires (#146 M8). Only remove it if the claim still points at
194        // THIS run — a newer run may have re-claimed the key after expiry.
195        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        // Also drop stale idempotency claims so the map stays bounded.
211        self.idem
212            .retain(|_, e| !is_expired(e.claimed_at, now, self.idem_retention));
213        // Trim audit records older than the run-retention window.
214        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        // Newest first (timestamp DESC, id DESC).
246        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    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
268
269    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        // A dataset id may appear twice in one update — e.g. a source whose URI
273        // canonicalizes to the sink's. The SQL backends dedup a stats point on
274        // its `(dataset_id, recorded_at)` PK, so record each id at most once per
275        // update here too, or the two backends' volume timelines diverge (#466 L2).
276        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        // One edge per input dataset — a merge/join sink has several (#459). Read
305        // every edge's prior state from a pre-loop snapshot, matching the SQL
306        // backends' single `catalog_all_edges()` read: two source nodes sharing a
307        // URI collapse to one edge that must be advanced *once*, not once per node
308        // (#466 L2).
309        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(); // newest first
349        stats.truncate(catalog::STATS_DETAIL_LIMIT);
350        // Match the SQL backends exactly: order all edges deterministically
351        // (`last_seen DESC, src_id, dst_id`), then partition by `src_id == id` so
352        // downstream owns any self-loop and upstream is the rest touching `id`.
353        // A `HashMap`-order scan with two independent filters instead put a
354        // self-loop in *both* lists and returned edges in nondeterministic order
355        // (#466 L2).
356        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        // Stable order for pagination-free consumers (newest activity first).
385        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    // ── Pipeline-template registry (#444) ────────────────────────────────────
418
419    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                        // A channel must never dangle at a deleted version.
531                        t.retain(|_, pointed| *pointed != v);
532                        if t.is_empty() {
533                            tags.remove(id);
534                        }
535                    }
536                    // Likewise the launch log: a `stable` / `previous` pointer must
537                    // never resolve to a version that no longer exists.
538                    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        // Re-launching what is already stable is a no-op: appending would make
601        // `previous` a duplicate of `stable` and destroy the rollback target.
602        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        // Same key + same fingerprint → replay the first run id.
698        assert_eq!(
699            h.claim_idempotency("k", "fp1", "run2", w).await.unwrap(),
700            Claim::Replay("run1".into())
701        );
702        // Same key + different fingerprint → conflict.
703        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        // Zero window → any prior claim is immediately expired.
713        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        // M8 (#146): deleting a run must drop its idempotency claim, so a later
742        // replay of the key starts a fresh run instead of 404-ing on the
743        // now-missing record until the claim self-expires.
744        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        // The key is free again → fresh run, not a replay of the deleted one.
763        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        // Guard: deleting an OLD run must not remove a claim a NEWER run owns.
772        let h = MemoryHistory::new(Duration::from_secs(3600));
773        h.claim_idempotency("k", "fp", "r1", Duration::from_secs(3600))
774            .await
775            .unwrap();
776        // r2 re-claims the key (force the prior claim stale with a zero window).
777        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        // The claim still belongs to r2.
795        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        // Newest first → c, b, a. Page size 2.
817        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        // Next page from the cursor → a.
833        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        // Name filter also works.
872        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        // Newest first, no filter.
924        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        // Filter by principal + action.
935        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        // Limit is honoured.
957        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        // purge_expired(0) drops all audit records (every ts is "expired").
967        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        // #466 L2: when a source URI canonicalizes to the sink's, the memory
1006        // backend must behave like the SQL backends — one stats point per
1007        // (id, recorded_at), and the resulting self-loop edge in `downstream`
1008        // only, not both lists.
1009        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        // Same schema again → no new version.
1072        h.catalog_record(&catalog_update(
1073            "csv://./in.csv",
1074            "jsonl://./out.jsonl",
1075            Some(schema_v1),
1076        ))
1077        .await
1078        .unwrap();
1079        // Changed schema → second version with a diff.
1080        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        // Lineage: one edge, whole graph == rooted graph.
1114        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        // retain_for = 0 → every terminal record is expired; running is kept.
1141        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}