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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct FlowHistoryRetentionPolicy {
27 pub terminal_before: DateTime<Utc>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub run_ids: Option<BTreeSet<String>>,
32}
33
34impl FlowHistoryRetentionPolicy {
35 pub fn new(terminal_before: DateTime<Utc>) -> Self {
37 Self {
38 terminal_before,
39 run_ids: None,
40 }
41 }
42
43 #[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#[cfg(any(feature = "postgres", feature = "sqlite"))]
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct FlowHistoryHold {
69 pub run_id: String,
71 pub hold_id: String,
73 pub reason: String,
75 pub created_at: DateTime<Utc>,
77}
78
79#[cfg(any(feature = "postgres", feature = "sqlite"))]
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[non_exhaustive]
83pub struct FlowHistoryTombstone {
84 pub run_id: String,
86 pub deleted_at: DateTime<Utc>,
88 pub terminal_sequence: u64,
90 pub terminal_event_id: Uuid,
92 pub terminal_event_key: String,
94 pub history_sha256: String,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
100#[non_exhaustive]
101pub struct FlowHistoryRetentionReport {
102 pub deleted_run_ids: Vec<String>,
104 pub held_run_ids: Vec<String>,
106 pub referenced_run_ids: Vec<String>,
108 pub non_terminal_run_ids: Vec<String>,
110 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
119pub(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
433pub(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}