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 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 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 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 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 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}