Skip to main content

a3s_flow/store/
retention.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3#[cfg(any(feature = "postgres", feature = "sqlite"))]
4use sha2::{Digest, Sha256};
5use std::collections::{BTreeMap, BTreeSet};
6#[cfg(any(feature = "postgres", feature = "sqlite"))]
7use uuid::Uuid;
8
9use crate::error::{FlowError, Result};
10use crate::model::{project_run, FlowEvent, FlowEventEnvelope, WorkflowContinuation, WorkflowSpec};
11
12struct LinkedWorkflowStart {
13    run_id: String,
14    spec: WorkflowSpec,
15    input: serde_json::Value,
16}
17
18/// Bounded policy for deleting complete terminal histories.
19///
20/// Flow never rewrites or partially compacts an event stream. A retention scan
21/// removes an entire terminal history only when it is older than
22/// `terminal_before`, has no durable audit hold, and belongs to a linked-run
23/// component whose other histories are eligible in the same scan.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct FlowHistoryRetentionPolicy {
27    /// Delete only histories whose terminal event predates this instant.
28    pub terminal_before: DateTime<Utc>,
29    /// Optional allowlist of run IDs to consider for deletion.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub run_ids: Option<BTreeSet<String>>,
32}
33
34impl FlowHistoryRetentionPolicy {
35    /// Create an unrestricted policy with the supplied terminal cutoff.
36    pub fn new(terminal_before: DateTime<Utc>) -> Self {
37        Self {
38            terminal_before,
39            run_ids: None,
40        }
41    }
42
43    /// Restrict deletion candidates to an explicit set of run IDs.
44    ///
45    /// Linked histories outside this set remain protected and therefore also
46    /// protect candidate histories connected to them.
47    #[cfg(any(feature = "postgres", feature = "sqlite"))]
48    pub fn with_run_ids<I, S>(mut self, run_ids: I) -> Self
49    where
50        I: IntoIterator<Item = S>,
51        S: Into<String>,
52    {
53        self.run_ids = Some(run_ids.into_iter().map(Into::into).collect());
54        self
55    }
56
57    pub(crate) fn includes(&self, run_id: &str) -> bool {
58        self.run_ids
59            .as_ref()
60            .is_none_or(|run_ids| run_ids.contains(run_id))
61    }
62}
63
64/// Persistent reason that prevents a run history from being pruned.
65#[cfg(any(feature = "postgres", feature = "sqlite"))]
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct FlowHistoryHold {
69    /// Run whose history is protected.
70    pub run_id: String,
71    /// Caller-defined identifier for this hold.
72    pub hold_id: String,
73    /// Operator-facing reason for retaining the history.
74    pub reason: String,
75    /// Time at which the hold was created.
76    pub created_at: DateTime<Utc>,
77}
78
79/// Minimal audit record retained after a complete event history is deleted.
80#[cfg(any(feature = "postgres", feature = "sqlite"))]
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct FlowHistoryTombstone {
84    /// Run whose event history was deleted.
85    pub run_id: String,
86    /// Time at which deletion completed.
87    pub deleted_at: DateTime<Utc>,
88    /// Sequence number of the deleted history's terminal event.
89    pub terminal_sequence: u64,
90    /// Identifier of the deleted history's terminal event.
91    pub terminal_event_id: Uuid,
92    /// Event key of the deleted history's terminal event.
93    pub terminal_event_key: String,
94    /// SHA-256 digest of the serialized event history before deletion.
95    pub history_sha256: String,
96}
97
98/// Detailed result of a terminal-history retention scan.
99#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
100#[non_exhaustive]
101pub struct FlowHistoryRetentionReport {
102    /// Histories deleted by this scan.
103    pub deleted_run_ids: Vec<String>,
104    /// Eligible histories retained because they have an audit hold.
105    pub held_run_ids: Vec<String>,
106    /// Eligible histories retained because another linked history is ineligible.
107    pub referenced_run_ids: Vec<String>,
108    /// Candidate histories retained because their runs are not terminal.
109    pub non_terminal_run_ids: Vec<String>,
110    /// Terminal candidate histories that have not reached the cutoff.
111    pub recent_terminal_run_ids: Vec<String>,
112}
113
114pub(crate) struct FlowHistoryRetentionPlan {
115    pub(crate) deletable_run_ids: BTreeSet<String>,
116    pub(crate) report: FlowHistoryRetentionReport,
117}
118
119/// Apply the backend-independent retention rules to one consistent history view.
120///
121/// Storage adapters own locking, transaction, and deletion details, while this
122/// function is the single source of truth for terminal eligibility and
123/// linked-component protection.
124pub(crate) fn plan_history_retention(
125    histories: &BTreeMap<String, Vec<FlowEventEnvelope>>,
126    hold_run_ids: &BTreeSet<String>,
127    policy: &FlowHistoryRetentionPolicy,
128    storage_name: &str,
129) -> Result<FlowHistoryRetentionPlan> {
130    let mut report = FlowHistoryRetentionReport::default();
131    let mut eligible = BTreeSet::new();
132    for (run_id, history) in histories {
133        if !policy.includes(run_id) {
134            continue;
135        }
136        let snapshot = project_run(run_id, history)?;
137        if !snapshot.status.is_terminal() {
138            report.non_terminal_run_ids.push(run_id.clone());
139            continue;
140        }
141        let terminal = history.last().ok_or_else(|| {
142            FlowError::Store(format!(
143                "{storage_name} history for {run_id} is unexpectedly empty"
144            ))
145        })?;
146        if terminal.timestamp >= policy.terminal_before {
147            report.recent_terminal_run_ids.push(run_id.clone());
148            continue;
149        }
150        if hold_run_ids.contains(run_id) {
151            report.held_run_ids.push(run_id.clone());
152            continue;
153        }
154        eligible.insert(run_id.clone());
155    }
156
157    let mut adjacency = histories
158        .keys()
159        .map(|run_id| (run_id.clone(), BTreeSet::<String>::new()))
160        .collect::<BTreeMap<_, _>>();
161    let mut dangling_reference_runs = BTreeSet::new();
162    let mut continuations = BTreeMap::new();
163    let mut child_workflows = BTreeMap::<String, Vec<LinkedWorkflowStart>>::new();
164    for (parent_run_id, history) in histories {
165        for envelope in history {
166            let Some(child_run_id) = linked_flow_run_id(&envelope.event) else {
167                continue;
168            };
169            if let FlowEvent::RunContinuedAsNew {
170                successor_run_id,
171                input,
172            } = &envelope.event
173            {
174                continuations.insert(
175                    parent_run_id.clone(),
176                    WorkflowContinuation {
177                        successor_run_id: successor_run_id.clone(),
178                        input: input.clone(),
179                    },
180                );
181            }
182            if let FlowEvent::ChildWorkflowRequested {
183                child_run_id,
184                spec,
185                input,
186                ..
187            } = &envelope.event
188            {
189                child_workflows
190                    .entry(parent_run_id.clone())
191                    .or_default()
192                    .push(LinkedWorkflowStart {
193                        run_id: child_run_id.clone(),
194                        spec: spec.clone(),
195                        input: input.clone(),
196                    });
197            }
198            if !histories.contains_key(child_run_id) {
199                dangling_reference_runs.insert(parent_run_id.clone());
200                continue;
201            }
202            adjacency
203                .entry(parent_run_id.clone())
204                .or_default()
205                .insert(child_run_id.to_string());
206            adjacency
207                .entry(child_run_id.to_string())
208                .or_default()
209                .insert(parent_run_id.clone());
210        }
211    }
212
213    let mut visited = BTreeSet::new();
214    let mut deletable = BTreeSet::new();
215    let mut referenced = BTreeSet::new();
216    for start in &eligible {
217        if visited.contains(start) {
218            continue;
219        }
220        let mut component = BTreeSet::new();
221        let mut pending = vec![start.clone()];
222        while let Some(run_id) = pending.pop() {
223            if !component.insert(run_id.clone()) {
224                continue;
225            }
226            if let Some(neighbors) = adjacency.get(&run_id) {
227                pending.extend(neighbors.iter().cloned());
228            }
229        }
230        visited.extend(component.iter().cloned());
231        validate_linked_workflow_component(
232            &component,
233            histories,
234            &continuations,
235            &child_workflows,
236            storage_name,
237        )?;
238        let component_is_deletable = component.iter().all(|run_id| eligible.contains(run_id))
239            && component
240                .iter()
241                .all(|run_id| !dangling_reference_runs.contains(run_id));
242        if component_is_deletable {
243            deletable.extend(component);
244        } else {
245            referenced.extend(
246                component
247                    .into_iter()
248                    .filter(|run_id| eligible.contains(run_id)),
249            );
250        }
251    }
252
253    report.referenced_run_ids = referenced.into_iter().collect();
254    report.held_run_ids.sort();
255    report.non_terminal_run_ids.sort();
256    report.recent_terminal_run_ids.sort();
257    Ok(FlowHistoryRetentionPlan {
258        deletable_run_ids: deletable,
259        report,
260    })
261}
262
263fn validate_linked_workflow_component(
264    component: &BTreeSet<String>,
265    histories: &BTreeMap<String, Vec<FlowEventEnvelope>>,
266    continuations: &BTreeMap<String, WorkflowContinuation>,
267    child_workflows: &BTreeMap<String, Vec<LinkedWorkflowStart>>,
268    storage_name: &str,
269) -> Result<()> {
270    for start in component {
271        let mut path = BTreeSet::new();
272        let mut current = start.as_str();
273        while component.contains(current) {
274            let Some(continuation) = continuations.get(current) else {
275                break;
276            };
277            if !path.insert(current.to_string()) {
278                return Err(FlowError::ContinueAsNewCycle(current.to_string()));
279            }
280            current = &continuation.successor_run_id;
281        }
282    }
283
284    validate_child_workflow_cycles(component, continuations, child_workflows)?;
285
286    for (predecessor_run_id, continuation) in continuations {
287        if !component.contains(predecessor_run_id) {
288            continue;
289        }
290        let Some(successor_history) = histories.get(&continuation.successor_run_id) else {
291            continue;
292        };
293        let predecessor_history = histories.get(predecessor_run_id).ok_or_else(|| {
294            FlowError::Store(format!(
295                "{storage_name} continuation predecessor {predecessor_run_id} disappeared during retention"
296            ))
297        })?;
298        let predecessor = project_run(predecessor_run_id, predecessor_history)?;
299        let successor = project_run(&continuation.successor_run_id, successor_history)?;
300        if successor.spec != predecessor.spec {
301            return Err(FlowError::RunConflict {
302                run_id: continuation.successor_run_id.clone(),
303                reason: "continue-as-new successor workflow spec differs".to_string(),
304            });
305        }
306        if successor.input != continuation.input {
307            return Err(FlowError::RunConflict {
308                run_id: continuation.successor_run_id.clone(),
309                reason: "continue-as-new successor input differs".to_string(),
310            });
311        }
312    }
313    for (parent_run_id, children) in child_workflows {
314        if !component.contains(parent_run_id) {
315            continue;
316        }
317        for child in children {
318            let Some(child_history) = histories.get(&child.run_id) else {
319                continue;
320            };
321            let child_snapshot = project_run(&child.run_id, child_history)?;
322            if child_snapshot.spec != child.spec {
323                return Err(FlowError::RunConflict {
324                    run_id: child.run_id.clone(),
325                    reason: "child workflow spec differs from parent request".to_string(),
326                });
327            }
328            if child_snapshot.input != child.input {
329                return Err(FlowError::RunConflict {
330                    run_id: child.run_id.clone(),
331                    reason: "child workflow input differs from parent request".to_string(),
332                });
333            }
334        }
335    }
336    Ok(())
337}
338
339fn validate_child_workflow_cycles(
340    component: &BTreeSet<String>,
341    continuations: &BTreeMap<String, WorkflowContinuation>,
342    child_workflows: &BTreeMap<String, Vec<LinkedWorkflowStart>>,
343) -> Result<()> {
344    let mut outgoing = component
345        .iter()
346        .map(|run_id| (run_id.clone(), BTreeSet::new()))
347        .collect::<BTreeMap<_, _>>();
348    let mut indegree = component
349        .iter()
350        .map(|run_id| (run_id.clone(), 0_usize))
351        .collect::<BTreeMap<_, _>>();
352    for (source, continuation) in continuations {
353        add_owned_edge(
354            component,
355            &mut outgoing,
356            &mut indegree,
357            source,
358            &continuation.successor_run_id,
359        );
360    }
361    for (source, children) in child_workflows {
362        for child in children {
363            add_owned_edge(
364                component,
365                &mut outgoing,
366                &mut indegree,
367                source,
368                &child.run_id,
369            );
370        }
371    }
372
373    let mut ready = indegree
374        .iter()
375        .filter(|(_, degree)| **degree == 0)
376        .map(|(run_id, _)| run_id.clone())
377        .collect::<Vec<_>>();
378    let mut removed = 0_usize;
379    while let Some(run_id) = ready.pop() {
380        removed += 1;
381        if let Some(targets) = outgoing.get(&run_id) {
382            for target in targets {
383                if let Some(degree) = indegree.get_mut(target) {
384                    *degree -= 1;
385                    if *degree == 0 {
386                        ready.push(target.clone());
387                    }
388                }
389            }
390        }
391    }
392    if removed != component.len() {
393        let run_id = indegree
394            .into_iter()
395            .find(|(_, degree)| *degree > 0)
396            .map(|(run_id, _)| run_id)
397            .unwrap_or_else(|| "unknown".to_string());
398        return Err(FlowError::ChildWorkflowCycle(run_id));
399    }
400    Ok(())
401}
402
403fn add_owned_edge(
404    component: &BTreeSet<String>,
405    outgoing: &mut BTreeMap<String, BTreeSet<String>>,
406    indegree: &mut BTreeMap<String, usize>,
407    source: &str,
408    target: &str,
409) {
410    if !component.contains(source) || !component.contains(target) {
411        return;
412    }
413    if outgoing
414        .entry(source.to_string())
415        .or_default()
416        .insert(target.to_string())
417    {
418        *indegree.entry(target.to_string()).or_default() += 1;
419    }
420}
421
422pub(crate) fn linked_flow_run_id(event: &FlowEvent) -> Option<&str> {
423    match event {
424        FlowEvent::ChildOperationLinked { child } => child.flow_run_id.as_deref(),
425        FlowEvent::RunContinuedAsNew {
426            successor_run_id, ..
427        } => Some(successor_run_id),
428        FlowEvent::ChildWorkflowRequested { child_run_id, .. } => Some(child_run_id),
429        _ => None,
430    }
431}
432
433/// Return a reference that must already exist when its event is appended.
434///
435/// Continue-as-new intentionally commits the predecessor link first so a
436/// replacement worker can recover a missing successor. Child-operation links
437/// retain their stronger same-store existence invariant.
438pub(crate) fn required_linked_flow_run_id(event: &FlowEvent) -> Option<&str> {
439    match event {
440        FlowEvent::ChildOperationLinked { child } => child.flow_run_id.as_deref(),
441        _ => None,
442    }
443}
444
445#[cfg(any(feature = "postgres", feature = "sqlite"))]
446pub(crate) fn history_checksum(history: &[FlowEventEnvelope]) -> Result<String> {
447    let digest = Sha256::digest(serde_json::to_vec(history)?);
448    Ok(format!("{digest:x}"))
449}
450
451#[cfg(any(feature = "postgres", feature = "sqlite"))]
452pub(crate) fn validate_history_hold(run_id: &str, hold_id: &str, reason: &str) -> Result<()> {
453    if run_id.trim().is_empty() || hold_id.trim().is_empty() || reason.trim().is_empty() {
454        return Err(FlowError::InvalidTransition(
455            "history hold run id, hold id, and reason must not be empty".to_string(),
456        ));
457    }
458    Ok(())
459}