Skip to main content

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}