a2a_protocol_server/store/retention/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Tom F. <tomf@tomtomtech.net> (https://github.com/tomtom215)
3
4//! Retention for the persistent task stores.
5//!
6//! # The settled policy
7//!
8//! **A persistent store deletes nothing unless the operator asks it to.**
9//!
10//! That is a decision, not an omission, and it is the opposite of what the
11//! in-memory store does. [`TaskStoreConfig`](super::TaskStoreConfig) defaults
12//! to a one-hour TTL and a 10,000-task cap, so the default in-process
13//! deployment forgets a task an hour after it finishes. `SqliteTaskStore` and
14//! `PostgresTaskStore` never read that config — they took a URL and nothing
15//! else — so the durable deployment kept every task forever. Two opposite
16//! behaviours, neither written down where an operator would look, and the
17//! divergence was the actual defect: not that the table grows, but that
18//! nothing said it would.
19//!
20//! Forgetting is right for a cache and wrong for a database. A library that
21//! quietly deleted rows from an operator's `PostgreSQL` would be a far worse
22//! surprise than one that grows, and "how long do we keep completed work" is a
23//! question with legal answers, not just engineering ones — retention
24//! schedules, audit obligations, and the customer's own contracts all have a
25//! say. So the default stays "keep everything", and this module is the
26//! mechanism for any other answer.
27//!
28//! # Using it
29//!
30//! `purge_expired` on each persistent store deletes terminal
31//! tasks older than [`RetentionPolicy::terminal_max_age`], in batches, and
32//! reports what it did. Call it from whatever already schedules work —
33//! a cron, a Kubernetes `CronJob`, a `tokio::spawn` loop. It is deliberately
34//! not wired to a timer inside the store: a sweep that fires on its own is a
35//! sweep that fires during your traffic peak, and the store does not know when
36//! that is.
37//!
38//! The example is gated on `sqlite` because the type it needs is: this crate
39//! has no default features, so a doctest naming `SqliteTaskStore`
40//! unconditionally fails to compile in every build that does not ask for a
41//! backend — which is most of the CI matrix.
42//!
43//! ```no_run
44//! # #[cfg(feature = "sqlite")]
45//! # mod example {
46//! use a2a_protocol_server::store::{RetentionPolicy, SqliteTaskStore};
47//! use a2a_protocol_types::error::A2aResult;
48//! use std::time::Duration;
49//!
50//! pub async fn sweep(store: &SqliteTaskStore) -> A2aResult<()> {
51//! let policy = RetentionPolicy::new(Duration::from_secs(30 * 24 * 3600));
52//! let report = store.purge_expired(&policy).await?;
53//! println!("purged {} task(s)", report.tasks_deleted);
54//! Ok(())
55//! }
56//! # }
57//! ```
58//!
59//! # What it will not delete
60//!
61//! Only terminal tasks — `Completed`, `Failed`, `Canceled`, `Rejected`. A task
62//! that is still `Working`, or parked in `InputRequired` waiting for a human,
63//! is never eligible however old it is. The in-memory store does evict
64//! non-terminal tasks as a last resort under capacity pressure, because RAM is
65//! a hard bound; disk is not, and an unbounded-age `InputRequired` task is a
66//! workflow waiting on someone, not a leak.
67
68#[cfg(feature = "postgres")]
69pub(crate) mod postgres;
70#[cfg(feature = "sqlite")]
71pub(crate) mod sqlite;
72
73use std::time::Duration;
74
75use a2a_protocol_types::task::TaskState;
76
77/// The states a purge is allowed to delete.
78///
79/// Listed here rather than derived from `TaskState::ALL`, and that is a
80/// packaging constraint rather than a preference. `cargo package` verifies the
81/// server tarball against `a2a-protocol-types` **from crates.io** — the path
82/// dependency is stripped, and the published 0.9.0 has no `ALL` — so
83/// referencing it from library code fails the packaging gate on every PR until
84/// a version bump makes the local copy the only candidate.
85///
86/// The guard therefore lives in the test below, which *does* see the local
87/// crate: `cargo package` builds the library and not the tests, so a
88/// `#[cfg(test)]` reference to `TaskState::ALL` costs nothing at packaging
89/// time and still fails the moment this list stops agreeing with
90/// [`TaskState::is_terminal`] across every variant the protocol defines.
91const TERMINAL_STATES: [TaskState; 4] = [
92 TaskState::Completed,
93 TaskState::Failed,
94 TaskState::Canceled,
95 TaskState::Rejected,
96];
97
98/// The states a purge is allowed to delete.
99#[must_use]
100pub fn terminal_states() -> Vec<TaskState> {
101 TERMINAL_STATES.to_vec()
102}
103
104/// How long terminal tasks are kept, and how aggressively they are removed.
105#[derive(Debug, Clone)]
106pub struct RetentionPolicy {
107 /// Terminal tasks whose last update is older than this are eligible.
108 ///
109 /// Measured against `updated_at`, which the store maintains, and evaluated
110 /// by the database rather than the caller — an application clock that runs
111 /// fast would otherwise delete work that is younger than it looks.
112 pub terminal_max_age: Duration,
113
114 /// Rows per `DELETE`. Default 1,000.
115 ///
116 /// The point of batching is the lock, not the throughput. One statement
117 /// deleting a million rows holds locks and grows a transaction for as long
118 /// as it takes; a thousand statements deleting a thousand rows each let
119 /// every other query through in between.
120 pub batch_size: u32,
121
122 /// Stop after this many batches, leaving the rest for the next call.
123 /// `None` runs until nothing is left.
124 ///
125 /// Set it to bound how long one sweep can run when the first sweep after
126 /// switching retention on has years of backlog to work through.
127 pub max_batches: Option<u32>,
128}
129
130impl RetentionPolicy {
131 /// A policy keeping terminal tasks for `terminal_max_age`, with default
132 /// batching.
133 #[must_use]
134 pub const fn new(terminal_max_age: Duration) -> Self {
135 Self {
136 terminal_max_age,
137 batch_size: 1_000,
138 max_batches: None,
139 }
140 }
141
142 /// Sets the rows-per-`DELETE` batch size. Zero is treated as one.
143 #[must_use]
144 pub const fn with_batch_size(mut self, batch_size: u32) -> Self {
145 self.batch_size = batch_size;
146 self
147 }
148
149 /// Bounds how many batches a single sweep runs.
150 #[must_use]
151 pub const fn with_max_batches(mut self, max_batches: u32) -> Self {
152 self.max_batches = Some(max_batches);
153 self
154 }
155
156 /// The batch size actually used, never zero — a zero-size batch would
157 /// delete nothing forever while reporting progress.
158 // Feature-gated because its only callers are the two backends. Without
159 // either, `-D warnings` makes it dead code -- and RUSTFLAGS applies to
160 // this crate even when it is built as a dependency of something else,
161 // which is why a policy type with no backend compiled broke fifteen CI
162 // jobs that never mention retention.
163 #[cfg(any(feature = "sqlite", feature = "postgres"))]
164 #[must_use]
165 pub(crate) const fn effective_batch_size(&self) -> u32 {
166 if self.batch_size == 0 {
167 1
168 } else {
169 self.batch_size
170 }
171 }
172}
173
174/// What one call to `purge_expired` did.
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub struct PurgeReport {
177 /// Task rows deleted.
178 pub tasks_deleted: u64,
179 /// Artifact-journal rows this sweep had to reclaim itself.
180 ///
181 /// Normally **zero**, and that is the healthy reading: the journal has an
182 /// `ON DELETE CASCADE`, so on a pool with `foreign_keys=ON` — which
183 /// `SqliteTaskStore::new` sets — the rows go with the task and the sweep
184 /// finds nothing left to do. A non-zero count means rows had outlived
185 /// their task, which happens when `from_pool` was handed a pool without
186 /// the pragma. Always zero on `PostgreSQL`, which has no journal table.
187 pub journal_orphans_deleted: u64,
188 /// Batches executed.
189 pub batches: u32,
190 /// `false` when [`RetentionPolicy::max_batches`] stopped the sweep with
191 /// work still to do, so a caller can tell "nothing left" from "ran out of
192 /// budget" instead of inferring it from a count.
193 pub complete: bool,
194}
195
196/// The `state` column values a purge matches.
197///
198/// Built from [`TaskState`]'s own `Display`, not from string literals: the
199/// column holds whatever `to_string()` produced at write time, and a purge
200/// filtering on a hand-copied spelling would match nothing while looking
201/// correct.
202#[cfg(any(feature = "sqlite", feature = "postgres"))]
203pub(crate) fn terminal_state_labels() -> Vec<String> {
204 terminal_states().iter().map(TaskState::to_string).collect()
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn terminal_states_matches_is_terminal_for_every_variant() {
213 // `TaskState::ALL` is only reachable here, in test code compiled
214 // against the workspace copy of a2a-protocol-types. That is the point:
215 // it gives this crate an enumeration of a `#[non_exhaustive]` foreign
216 // enum without the library depending on an API the published version
217 // does not have yet.
218 let purgeable = terminal_states();
219 for state in TaskState::ALL {
220 assert_eq!(
221 purgeable.contains(&state),
222 state.is_terminal(),
223 "{state} must be purgeable exactly when it is terminal"
224 );
225 }
226 assert_eq!(
227 purgeable.len(),
228 TaskState::ALL.iter().filter(|s| s.is_terminal()).count(),
229 "TERMINAL_STATES has drifted from is_terminal(); a new terminal \
230 state would otherwise never be purged"
231 );
232 }
233
234 #[test]
235 #[cfg(any(feature = "sqlite", feature = "postgres"))]
236 fn labels_are_the_stored_spellings() {
237 let labels = terminal_state_labels();
238 assert!(labels.contains(&"TASK_STATE_COMPLETED".to_string()));
239 assert!(labels.contains(&"TASK_STATE_REJECTED".to_string()));
240 assert_eq!(labels.len(), terminal_states().len());
241 // The store writes `task.status.state.to_string()`; if that ever stops
242 // agreeing with what a purge looks for, every sweep silently becomes a
243 // no-op.
244 assert_eq!(labels[0], TaskState::Completed.to_string());
245 }
246
247 #[test]
248 #[cfg(any(feature = "sqlite", feature = "postgres"))]
249 fn zero_batch_size_cannot_stall_a_sweep() {
250 let policy = RetentionPolicy::new(Duration::from_secs(1)).with_batch_size(0);
251 assert_eq!(
252 policy.effective_batch_size(),
253 1,
254 "a zero batch size would delete nothing while looping forever"
255 );
256 }
257
258 #[test]
259 fn builders_compose() {
260 let policy = RetentionPolicy::new(Duration::from_secs(60))
261 .with_batch_size(50)
262 .with_max_batches(3);
263 assert_eq!(policy.terminal_max_age, Duration::from_secs(60));
264 assert_eq!(policy.batch_size, 50);
265 assert_eq!(policy.max_batches, Some(3));
266 }
267
268 #[test]
269 fn report_defaults_to_nothing_done_but_complete() {
270 let report = PurgeReport::default();
271 assert_eq!(report.tasks_deleted, 0);
272 assert!(!report.complete, "default must not claim a completed sweep");
273 }
274}