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,
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
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    /// Persistent run logs (#529): run_id → lines (append order == seq order).
67    run_logs: Mutex<std::collections::HashMap<String, Vec<RunLogLine>>>,
68    /// Retention window for idempotency claims (separate from run retention).
69    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
89/// True when `claimed_at` is older than `window` relative to `now`. A claim
90/// timestamped in the future (clock skew) is treated as *not* expired.
91fn 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        // Holding the entry locks the shard, so claim is atomic under contention.
110        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        // (submitted_at DESC, run_id DESC)
162        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        // Cursor = last run_id seen on the previous page; skip past it.
168        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        // Also drop this run's idempotency claim so a replay of the key starts a
195        // fresh run instead of 404-ing on the now-deleted record until the claim
196        // self-expires (#146 M8). Only remove it if the claim still points at
197        // THIS run — a newer run may have re-claimed the key after expiry.
198        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        // Also drop stale idempotency claims so the map stays bounded.
214        self.idem
215            .retain(|_, e| !is_expired(e.claimed_at, now, self.idem_retention));
216        // Trim audit records older than the run-retention window.
217        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        // Newest first (timestamp DESC, id DESC).
249        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    // ── Persistent run logs (#529) ────────────────────────────────────────────
255
256    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            // ts is RFC3339; parse to an instant (offsets aren't lexically
309            // comparable). An unparseable ts is kept rather than silently dropped.
310            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    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
338
339    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        // A dataset id may appear twice in one update — e.g. a source whose URI
343        // canonicalizes to the sink's. The SQL backends dedup a stats point on
344        // its `(dataset_id, recorded_at)` PK, so record each id at most once per
345        // update here too, or the two backends' volume timelines diverge (#466 L2).
346        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        // One edge per input dataset — a merge/join sink has several (#459). Read
375        // every edge's prior state from a pre-loop snapshot, matching the SQL
376        // backends' single `catalog_all_edges()` read: two source nodes sharing a
377        // URI collapse to one edge that must be advanced *once*, not once per node
378        // (#466 L2).
379        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(); // newest first
419        stats.truncate(catalog::STATS_DETAIL_LIMIT);
420        // Match the SQL backends exactly: order all edges deterministically
421        // (`last_seen DESC, src_id, dst_id`), then partition by `src_id == id` so
422        // downstream owns any self-loop and upstream is the rest touching `id`.
423        // A `HashMap`-order scan with two independent filters instead put a
424        // self-loop in *both* lists and returned edges in nondeterministic order
425        // (#466 L2).
426        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        // Stable order for pagination-free consumers (newest activity first).
455        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    // ── Pipeline-template registry (#444) ────────────────────────────────────
488
489    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                        // A channel must never dangle at a deleted version.
601                        t.retain(|_, pointed| *pointed != v);
602                        if t.is_empty() {
603                            tags.remove(id);
604                        }
605                    }
606                    // Likewise the launch log: a `stable` / `previous` pointer must
607                    // never resolve to a version that no longer exists.
608                    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        // Re-launching what is already stable is a no-op: appending would make
671        // `previous` a duplicate of `stable` and destroy the rollback target.
672        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        // Same key + same fingerprint → replay the first run id.
768        assert_eq!(
769            h.claim_idempotency("k", "fp1", "run2", w).await.unwrap(),
770            Claim::Replay("run1".into())
771        );
772        // Same key + different fingerprint → conflict.
773        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        // Zero window → any prior claim is immediately expired.
783        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        // M8 (#146): deleting a run must drop its idempotency claim, so a later
812        // replay of the key starts a fresh run instead of 404-ing on the
813        // now-missing record until the claim self-expires.
814        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        // The key is free again → fresh run, not a replay of the deleted one.
833        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        // Guard: deleting an OLD run must not remove a claim a NEWER run owns.
842        let h = MemoryHistory::new(Duration::from_secs(3600));
843        h.claim_idempotency("k", "fp", "r1", Duration::from_secs(3600))
844            .await
845            .unwrap();
846        // r2 re-claims the key (force the prior claim stale with a zero window).
847        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        // The claim still belongs to r2.
865        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        // Newest first → c, b, a. Page size 2.
887        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        // Next page from the cursor → a.
903        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        // Name filter also works.
942        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        // Newest first, no filter.
994        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        // Filter by principal + action.
1005        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        // Limit is honoured.
1027        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        // purge_expired(0) drops all audit records (every ts is "expired").
1037        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        // #466 L2: when a source URI canonicalizes to the sink's, the memory
1076        // backend must behave like the SQL backends — one stats point per
1077        // (id, recorded_at), and the resulting self-loop edge in `downstream`
1078        // only, not both lists.
1079        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        // Same schema again → no new version.
1142        h.catalog_record(&catalog_update(
1143            "csv://./in.csv",
1144            "jsonl://./out.jsonl",
1145            Some(schema_v1),
1146        ))
1147        .await
1148        .unwrap();
1149        // Changed schema → second version with a diff.
1150        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        // Lineage: one edge, whole graph == rooted graph.
1184        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        // retain_for = 0 → every terminal record is expired; running is kept.
1211        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}