Skip to main content

a3s_flow/store/postgres/
retention.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use a3s_orm::{sql_query, PostgresTransaction};
4use chrono::Utc;
5
6use crate::error::{FlowError, Result};
7use crate::model::FlowEventEnvelope;
8
9use super::{
10    execute_postgres, fetch_all_postgres, fetch_optional_postgres, latest_postgres_sequence,
11    lock_postgres_retention_guard_exclusive, lock_postgres_run, map_postgres_transaction,
12    row_to_envelope, PostgresEventStore,
13};
14use crate::store::retention::{history_checksum, plan_history_retention, validate_history_hold};
15use crate::store::{
16    FlowHistoryHold, FlowHistoryRetentionPolicy, FlowHistoryRetentionReport, FlowHistoryTombstone,
17};
18
19pub(super) use crate::store::retention::required_linked_flow_run_id;
20
21const RETENTION_GUARD_LOCK_ID: &str = "a3s-flow-history-retention-guard";
22
23impl PostgresEventStore {
24    /// Persist an audit hold that prevents a run history from being pruned.
25    ///
26    /// Repeating the same `(run_id, hold_id, reason)` is idempotent. Reusing a
27    /// hold ID with another reason returns a conflict so audit intent cannot be
28    /// silently replaced.
29    pub async fn hold_history(&self, run_id: &str, hold_id: &str, reason: &str) -> Result<()> {
30        validate_history_hold(run_id, hold_id, reason)?;
31        let run_id = run_id.to_string();
32        let hold_id = hold_id.to_string();
33        let reason = reason.to_string();
34        let result = self
35            .executor
36            .transaction(|transaction| {
37                Box::pin(async move {
38                    lock_postgres_run(transaction, &run_id).await?;
39                    ensure_postgres_history_not_tombstoned(transaction, &run_id).await?;
40                    if latest_postgres_sequence(transaction, &run_id).await? == 0 {
41                        return Err(FlowError::RunNotFound(run_id));
42                    }
43                    let existing = fetch_optional_postgres(
44                        transaction,
45                        sql_query::<String>(
46                            "SELECT reason FROM flow_history_holds WHERE run_id = ",
47                        )
48                        .bind(run_id.clone())
49                        .append(" AND hold_id = ")
50                        .bind(hold_id.clone()),
51                    )
52                    .await?;
53                    match existing {
54                        Some(existing) if existing == reason => return Ok(()),
55                        Some(_) => {
56                            return Err(FlowError::RunConflict {
57                                run_id,
58                                reason: format!(
59                                    "history hold {hold_id:?} differs from the durable hold"
60                                ),
61                            })
62                        }
63                        None => {}
64                    }
65                    execute_postgres(
66                        transaction,
67                        sql_query::<()>(
68                            "INSERT INTO flow_history_holds (run_id, hold_id, reason, created_at) VALUES (",
69                        )
70                        .bind(run_id)
71                        .append(", ")
72                        .bind(hold_id)
73                        .append(", ")
74                        .bind(reason)
75                        .append(", ")
76                        .bind(Utc::now().to_rfc3339())
77                        .append(")"),
78                    )
79                    .await?;
80                    Ok(())
81                })
82            })
83            .await;
84        map_postgres_transaction(result)
85    }
86
87    /// Release one audit hold. Returns false when the hold did not exist.
88    pub async fn release_history_hold(&self, run_id: &str, hold_id: &str) -> Result<bool> {
89        if run_id.trim().is_empty() || hold_id.trim().is_empty() {
90            return Err(FlowError::InvalidTransition(
91                "history hold run id and hold id must not be empty".to_string(),
92            ));
93        }
94        let run_id = run_id.to_string();
95        let hold_id = hold_id.to_string();
96        let result = self
97            .executor
98            .transaction(|transaction| {
99                Box::pin(async move {
100                    lock_postgres_run(transaction, &run_id).await?;
101                    let rows = execute_postgres(
102                        transaction,
103                        sql_query::<()>("DELETE FROM flow_history_holds WHERE run_id = ")
104                            .bind(run_id)
105                            .append(" AND hold_id = ")
106                            .bind(hold_id),
107                    )
108                    .await?;
109                    Ok(rows > 0)
110                })
111            })
112            .await;
113        map_postgres_transaction(result)
114    }
115
116    /// List durable audit holds for one run in stable hold-ID order.
117    pub async fn history_holds(&self, run_id: &str) -> Result<Vec<FlowHistoryHold>> {
118        let rows = fetch_all_postgres(
119            &self.executor,
120            sql_query::<(String, String, String, String)>(
121                "SELECT run_id, hold_id, reason, created_at FROM flow_history_holds WHERE run_id = ",
122            )
123            .bind(run_id)
124            .append(" ORDER BY hold_id ASC"),
125        )
126        .await?;
127        rows.into_iter().map(history_hold_row).collect()
128    }
129
130    /// Read the minimal audit tombstone retained after history deletion.
131    pub async fn history_tombstone(&self, run_id: &str) -> Result<Option<FlowHistoryTombstone>> {
132        fetch_optional_postgres(
133            &self.executor,
134            sql_query::<(String, String, i64, String, String, String)>(
135                "SELECT run_id, deleted_at, terminal_sequence, terminal_event_id, terminal_event_key, history_sha256 FROM flow_history_tombstones WHERE run_id = ",
136            )
137            .bind(run_id),
138        )
139        .await?
140        .map(history_tombstone_row)
141        .transpose()
142    }
143
144    /// Delete complete eligible terminal histories in one consistent scan.
145    ///
146    /// The scan takes an exclusive retention guard, locks existing run streams
147    /// in stable order, preserves durable holds and linked components, writes a
148    /// checksum tombstone, and only then deletes event rows. It never performs
149    /// partial stream compaction.
150    pub async fn prune_terminal_history(
151        &self,
152        policy: FlowHistoryRetentionPolicy,
153    ) -> Result<FlowHistoryRetentionReport> {
154        let result = self
155            .executor
156            .transaction(|transaction| {
157                Box::pin(async move { prune_postgres_history(transaction, &policy).await })
158            })
159            .await;
160        map_postgres_transaction(result)
161    }
162}
163
164async fn prune_postgres_history(
165    transaction: &PostgresTransaction,
166    policy: &FlowHistoryRetentionPolicy,
167) -> Result<FlowHistoryRetentionReport> {
168    lock_postgres_retention_guard_exclusive(transaction, RETENTION_GUARD_LOCK_ID).await?;
169    let mut run_ids = fetch_all_postgres(
170        transaction,
171        sql_query::<String>("SELECT DISTINCT run_id FROM flow_events ORDER BY run_id ASC"),
172    )
173    .await?;
174    run_ids.sort();
175    run_ids.dedup();
176    for run_id in &run_ids {
177        lock_postgres_run(transaction, run_id).await?;
178    }
179
180    let rows = fetch_all_postgres(
181        transaction,
182        sql_query::<(String, i64, String, String, String)>(
183            "SELECT run_id, sequence, event_id, timestamp, event_json FROM flow_events ORDER BY run_id ASC, sequence ASC",
184        ),
185    )
186    .await?;
187    let mut histories = BTreeMap::<String, Vec<FlowEventEnvelope>>::new();
188    for row in rows {
189        let envelope = row_to_envelope(row)?;
190        histories
191            .entry(envelope.run_id.clone())
192            .or_default()
193            .push(envelope);
194    }
195
196    let hold_run_ids = fetch_all_postgres(
197        transaction,
198        sql_query::<String>("SELECT DISTINCT run_id FROM flow_history_holds"),
199    )
200    .await?
201    .into_iter()
202    .collect::<BTreeSet<_>>();
203
204    let mut plan = plan_history_retention(&histories, &hold_run_ids, policy, "PostgreSQL")?;
205
206    for run_id in &plan.deletable_run_ids {
207        let history = histories.get(run_id).ok_or_else(|| {
208            FlowError::Store(format!("retention lost PostgreSQL history for {run_id}"))
209        })?;
210        let terminal = history.last().ok_or_else(|| {
211            FlowError::Store(format!(
212                "retention found empty PostgreSQL history for {run_id}"
213            ))
214        })?;
215        let terminal_sequence = i64::try_from(terminal.sequence).map_err(|error| {
216            FlowError::Store(format!(
217                "terminal sequence {} for {run_id} exceeds PostgreSQL bigint: {error}",
218                terminal.sequence
219            ))
220        })?;
221        let history_sha256 = history_checksum(history)?;
222        execute_postgres(
223            transaction,
224            sql_query::<()>(
225                "INSERT INTO flow_history_tombstones (run_id, deleted_at, terminal_sequence, terminal_event_id, terminal_event_key, history_sha256) VALUES (",
226            )
227            .bind(run_id.clone())
228            .append(", ")
229            .bind(Utc::now().to_rfc3339())
230            .append(", ")
231            .bind(terminal_sequence)
232            .append(", ")
233            .bind(terminal.event_id.to_string())
234            .append(", ")
235            .bind(terminal.event.event_key())
236            .append(", ")
237            .bind(history_sha256)
238            .append(")"),
239        )
240        .await?;
241        execute_postgres(
242            transaction,
243            sql_query::<()>("DELETE FROM flow_events WHERE run_id = ").bind(run_id.clone()),
244        )
245        .await?;
246    }
247
248    plan.report.deleted_run_ids = plan.deletable_run_ids.into_iter().collect();
249    Ok(plan.report)
250}
251
252fn history_hold_row(
253    (run_id, hold_id, reason, created_at): (String, String, String, String),
254) -> Result<FlowHistoryHold> {
255    Ok(FlowHistoryHold {
256        run_id,
257        hold_id,
258        reason,
259        created_at: created_at.parse().map_err(|error| {
260            FlowError::Store(format!(
261                "invalid PostgreSQL history hold timestamp {created_at}: {error}"
262            ))
263        })?,
264    })
265}
266
267fn history_tombstone_row(
268    (
269        run_id,
270        deleted_at,
271        terminal_sequence,
272        terminal_event_id,
273        terminal_event_key,
274        history_sha256,
275    ): (String, String, i64, String, String, String),
276) -> Result<FlowHistoryTombstone> {
277    Ok(FlowHistoryTombstone {
278        run_id,
279        deleted_at: deleted_at.parse().map_err(|error| {
280            FlowError::Store(format!(
281                "invalid PostgreSQL history tombstone timestamp {deleted_at}: {error}"
282            ))
283        })?,
284        terminal_sequence: u64::try_from(terminal_sequence).map_err(|error| {
285            FlowError::Store(format!(
286                "invalid PostgreSQL tombstone terminal sequence {terminal_sequence}: {error}"
287            ))
288        })?,
289        terminal_event_id: terminal_event_id.parse().map_err(|error| {
290            FlowError::Store(format!(
291                "invalid PostgreSQL tombstone event id {terminal_event_id}: {error}"
292            ))
293        })?,
294        terminal_event_key,
295        history_sha256,
296    })
297}
298
299pub(super) async fn lock_postgres_retention_guard_shared(
300    transaction: &PostgresTransaction,
301) -> Result<()> {
302    super::lock_postgres_retention_guard_shared(transaction, RETENTION_GUARD_LOCK_ID).await
303}
304
305pub(super) async fn ensure_postgres_history_not_tombstoned(
306    transaction: &PostgresTransaction,
307    run_id: &str,
308) -> Result<()> {
309    let tombstoned = fetch_optional_postgres(
310        transaction,
311        sql_query::<String>("SELECT run_id FROM flow_history_tombstones WHERE run_id = ")
312            .bind(run_id),
313    )
314    .await?
315    .is_some();
316    if tombstoned {
317        return Err(FlowError::RunConflict {
318            run_id: run_id.to_string(),
319            reason: "history was pruned and its run ID is tombstoned".to_string(),
320        });
321    }
322    Ok(())
323}