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 an idempotency key is kept when nothing else is asked for.
105///
106/// One day. The number has to exceed the longest window in which a client
107/// might still retry a send and expect a replay rather than a second
108/// execution, and clients retry in seconds — `RetryPolicy`'s own schedule is
109/// bounded in the low tens of seconds. A day is the figure the wider industry
110/// settled on for the same trade, and it is far enough above any retry budget
111/// this SDK ships that the choice is not delicate.
112///
113/// See [`RetentionPolicy::idempotency_key_max_age`] for what expiring a key
114/// costs.
115pub const DEFAULT_IDEMPOTENCY_KEY_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
116
117/// How long terminal tasks are kept, and how aggressively they are removed.
118///
119/// `#[non_exhaustive]`: build it with [`new`](Self::new) and the `with_*`
120/// setters, which cover every field. `STABILITY.md` §4 lists the
121/// configuration structs that carry this marking so a new option on any of
122/// them is additive; this one was missed by the 0.12.0 conversion that
123/// introduced the rule, and adding
124/// [`idempotency_key_max_age`](Self::idempotency_key_max_age) is what found
125/// it.
126#[derive(Debug, Clone)]
127#[non_exhaustive]
128pub struct RetentionPolicy {
129    /// Terminal tasks whose last update is older than this are eligible.
130    ///
131    /// Measured against `updated_at`, which the store maintains, and evaluated
132    /// by the database rather than the caller — an application clock that runs
133    /// fast would otherwise delete work that is younger than it looks.
134    pub terminal_max_age: Duration,
135
136    /// Rows per `DELETE`. Default 1,000.
137    ///
138    /// The point of batching is the lock, not the throughput. One statement
139    /// deleting a million rows holds locks and grows a transaction for as long
140    /// as it takes; a thousand statements deleting a thousand rows each let
141    /// every other query through in between.
142    pub batch_size: u32,
143
144    /// Idempotency keys older than this are deleted. `None` keeps them for
145    /// ever, which is what every release before this one did.
146    ///
147    /// # Why a key has to expire at all
148    ///
149    /// A key is released when the send it guards fails, and otherwise it
150    /// stays — deliberately, because it has to outlive the task it names or a
151    /// late retry would execute a second time. Nothing else ever removed one.
152    /// A busy deployment therefore accumulated a row per keyed send for the
153    /// life of the database, and the index that makes the claim atomic grew
154    /// with it.
155    ///
156    /// # What expiring one costs
157    ///
158    /// Exactly what the key was preventing: a retry arriving **after** the
159    /// key expires re-executes the send. That is the trade every idempotency
160    /// key has, and the reason the default is a full day rather than
161    /// something tidier — it has to exceed the longest window in which a
162    /// client might still retry, and clients retry in seconds.
163    ///
164    /// # It is never allowed below `terminal_max_age`
165    ///
166    /// A key expiring while the task it names is still retained is the bad
167    /// case: the retry does not replay, it creates a *second* task alongside
168    /// the first, and the caller ends up with two ids for one logical send.
169    /// So the sweep uses
170    /// [`effective_idempotency_key_max_age`](Self::effective_idempotency_key_max_age),
171    /// which never returns less than
172    /// [`terminal_max_age`](Self::terminal_max_age) — the invariant holds by
173    /// construction rather than by the operator having read this paragraph.
174    pub idempotency_key_max_age: Option<Duration>,
175
176    /// Stop after this many batches, leaving the rest for the next call.
177    /// `None` runs until nothing is left.
178    ///
179    /// Set it to bound how long one sweep can run when the first sweep after
180    /// switching retention on has years of backlog to work through.
181    pub max_batches: Option<u32>,
182}
183
184impl RetentionPolicy {
185    /// A policy keeping terminal tasks for `terminal_max_age`, with default
186    /// batching.
187    #[must_use]
188    pub const fn new(terminal_max_age: Duration) -> Self {
189        Self {
190            terminal_max_age,
191            batch_size: 1_000,
192            idempotency_key_max_age: Some(DEFAULT_IDEMPOTENCY_KEY_MAX_AGE),
193            max_batches: None,
194        }
195    }
196
197    /// Sets the rows-per-`DELETE` batch size. Zero is treated as one.
198    #[must_use]
199    pub const fn with_batch_size(mut self, batch_size: u32) -> Self {
200        self.batch_size = batch_size;
201        self
202    }
203
204    /// Sets how long an idempotency key is kept; `None` keeps them for ever.
205    ///
206    /// See [`idempotency_key_max_age`](Self::idempotency_key_max_age) for what
207    /// expiring one costs and why the sweep will not honour a value below
208    /// [`terminal_max_age`](Self::terminal_max_age).
209    #[must_use]
210    pub const fn with_idempotency_key_max_age(mut self, max_age: Option<Duration>) -> Self {
211        self.idempotency_key_max_age = max_age;
212        self
213    }
214
215    /// The key age the sweep actually uses: never below
216    /// [`terminal_max_age`](Self::terminal_max_age).
217    ///
218    /// A key must outlive the task it names. Deleting one while its task is
219    /// still retained turns the next retry into a second task rather than a
220    /// replay, so a policy that asks for that is clamped rather than obeyed —
221    /// the same shape as `effective_batch_size` — which is `pub(crate)` and
222    /// feature-gated, so it cannot be linked from a public doc comment —
223    /// where a nonsensical value is floored at the nearest sensible one
224    /// instead of being honoured into a defect.
225    #[must_use]
226    pub fn effective_idempotency_key_max_age(&self) -> Option<Duration> {
227        self.idempotency_key_max_age
228            .map(|age| age.max(self.terminal_max_age))
229    }
230
231    /// Bounds how many batches a single sweep runs.
232    #[must_use]
233    pub const fn with_max_batches(mut self, max_batches: u32) -> Self {
234        self.max_batches = Some(max_batches);
235        self
236    }
237
238    /// The batch size actually used, never zero — a zero-size batch would
239    /// delete nothing forever while reporting progress.
240    // Feature-gated because its only callers are the two backends. Without
241    // either, `-D warnings` makes it dead code -- and RUSTFLAGS applies to
242    // this crate even when it is built as a dependency of something else,
243    // which is why a policy type with no backend compiled broke fifteen CI
244    // jobs that never mention retention.
245    #[cfg(any(feature = "sqlite", feature = "postgres"))]
246    #[must_use]
247    pub(crate) const fn effective_batch_size(&self) -> u32 {
248        if self.batch_size == 0 {
249            1
250        } else {
251            self.batch_size
252        }
253    }
254}
255
256/// What one call to `purge_expired` did.
257///
258/// `#[non_exhaustive]`: read its fields, do not construct or exhaustively
259/// destructure it. A sweep that learns to count something new should not be a
260/// breaking change — 0.13.0's breaking list already carries one renamed field
261/// of this struct.
262#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
263#[non_exhaustive]
264pub struct PurgeReport {
265    /// Task rows deleted.
266    pub tasks_deleted: u64,
267    /// Side-table rows this sweep had to reclaim itself: artifact-journal
268    /// rows and event-log rows whose task was deleted.
269    ///
270    /// Normally **zero**, and that is the healthy reading: both tables have
271    /// an `ON DELETE CASCADE`, so on a pool with `foreign_keys=ON` — which
272    /// `SqliteTaskStore::new` sets — the rows go with the task and the sweep
273    /// finds nothing left to do. A non-zero count means rows had outlived
274    /// their task, which happens when `from_pool` was handed a pool without
275    /// the pragma.
276    ///
277    /// # It is zero on `PostgreSQL` because nothing counts there
278    ///
279    /// Only the `SQLite` sweep runs anti-join deletes; the `PostgreSQL` one
280    /// has no orphan statement at all, so this field is *structurally* zero on
281    /// both Postgres stores rather than observed to be. That is a deliberate
282    /// omission and not an oversight: `PostgreSQL` has no per-session
283    /// equivalent of `foreign_keys=OFF`, so a declared `ON DELETE CASCADE`
284    /// always fires and there is nothing for a sweep to find.
285    ///
286    /// The residual case it does **not** cover is a caller who handed
287    /// `from_pool` a database in which `task_events` or `tenant_task_events`
288    /// already existed without the foreign key — `CREATE TABLE IF NOT EXISTS`
289    /// leaves such a table alone. Rows can then be stranded there and no
290    /// Postgres sweep will reclaim them. Declare the tables from this crate's
291    /// own DDL, or from its migration runner, and the case does not arise.
292    ///
293    /// Since 0.13 the `SQLite` sweep runs on every purge rather than only on
294    /// one that deleted a task: a purge that fails part way strands rows that
295    /// outlive it, and the sweep is what reclaims them.
296    pub orphan_rows_deleted: u64,
297    /// Idempotency keys deleted for being older than
298    /// [`RetentionPolicy::idempotency_key_max_age`].
299    ///
300    /// Zero when that is `None`, and zero on a sweep that finds none expired.
301    /// Unlike [`orphan_rows_deleted`](Self::orphan_rows_deleted) a non-zero
302    /// count here is the healthy reading, not a warning: it is the sweep doing
303    /// the job it was given.
304    pub idempotency_keys_deleted: u64,
305
306    /// Batches executed.
307    pub batches: u32,
308    /// `false` when [`RetentionPolicy::max_batches`] stopped the sweep with
309    /// work still to do, so a caller can tell "nothing left" from "ran out of
310    /// budget" instead of inferring it from a count.
311    pub complete: bool,
312}
313
314/// The `state` column values a purge matches.
315///
316/// Built from [`TaskState`]'s own `Display`, not from string literals: the
317/// column holds whatever `to_string()` produced at write time, and a purge
318/// filtering on a hand-copied spelling would match nothing while looking
319/// correct.
320#[cfg(any(feature = "sqlite", feature = "postgres"))]
321pub(crate) fn terminal_state_labels() -> Vec<String> {
322    terminal_states().iter().map(TaskState::to_string).collect()
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn terminal_states_matches_is_terminal_for_every_variant() {
331        // `TaskState::ALL` is only reachable here, in test code compiled
332        // against the workspace copy of a2a-protocol-types. That is the point:
333        // it gives this crate an enumeration of a `#[non_exhaustive]` foreign
334        // enum without the library depending on an API the published version
335        // does not have yet.
336        let purgeable = terminal_states();
337        for state in TaskState::ALL {
338            assert_eq!(
339                purgeable.contains(&state),
340                state.is_terminal(),
341                "{state} must be purgeable exactly when it is terminal"
342            );
343        }
344        assert_eq!(
345            purgeable.len(),
346            TaskState::ALL.iter().filter(|s| s.is_terminal()).count(),
347            "TERMINAL_STATES has drifted from is_terminal(); a new terminal \
348             state would otherwise never be purged"
349        );
350    }
351
352    #[test]
353    #[cfg(any(feature = "sqlite", feature = "postgres"))]
354    fn labels_are_the_stored_spellings() {
355        let labels = terminal_state_labels();
356        assert!(labels.contains(&"TASK_STATE_COMPLETED".to_string()));
357        assert!(labels.contains(&"TASK_STATE_REJECTED".to_string()));
358        assert_eq!(labels.len(), terminal_states().len());
359        // The store writes `task.status.state.to_string()`; if that ever stops
360        // agreeing with what a purge looks for, every sweep silently becomes a
361        // no-op.
362        assert_eq!(labels[0], TaskState::Completed.to_string());
363    }
364
365    #[test]
366    #[cfg(any(feature = "sqlite", feature = "postgres"))]
367    fn zero_batch_size_cannot_stall_a_sweep() {
368        let policy = RetentionPolicy::new(Duration::from_secs(1)).with_batch_size(0);
369        assert_eq!(
370            policy.effective_batch_size(),
371            1,
372            "a zero batch size would delete nothing while looping forever"
373        );
374    }
375
376    #[test]
377    fn builders_compose() {
378        let policy = RetentionPolicy::new(Duration::from_secs(60))
379            .with_batch_size(50)
380            .with_max_batches(3);
381        assert_eq!(policy.terminal_max_age, Duration::from_secs(60));
382        assert_eq!(policy.batch_size, 50);
383        assert_eq!(policy.max_batches, Some(3));
384    }
385
386    #[test]
387    fn report_defaults_to_nothing_done_but_complete() {
388        let report = PurgeReport::default();
389        assert_eq!(report.tasks_deleted, 0);
390        assert!(!report.complete, "default must not claim a completed sweep");
391    }
392}
393
394// ── The idempotency key TTL and its floor ────────────────────────────────────
395
396#[cfg(test)]
397mod key_age_tests {
398    use super::{DEFAULT_IDEMPOTENCY_KEY_MAX_AGE, RetentionPolicy};
399    use std::time::Duration;
400
401    #[test]
402    fn a_new_policy_expires_keys_after_a_day() {
403        let policy = RetentionPolicy::new(Duration::from_secs(3600));
404        assert_eq!(
405            policy.idempotency_key_max_age,
406            Some(DEFAULT_IDEMPOTENCY_KEY_MAX_AGE),
407            "keys accumulated for ever before this default existed"
408        );
409        assert_eq!(DEFAULT_IDEMPOTENCY_KEY_MAX_AGE, Duration::from_secs(86_400));
410    }
411
412    /// The invariant the whole design rests on: a key outlives the task it
413    /// names.
414    ///
415    /// A key expiring while its task is still retained does not produce a
416    /// replay and does not produce a clear "that task is gone" — it produces
417    /// a *second* task alongside the first, and the caller ends up with two
418    /// ids for one logical send. So a policy asking for that is clamped
419    /// rather than obeyed.
420    #[test]
421    fn a_key_age_below_the_task_age_is_raised_to_it() {
422        let policy = RetentionPolicy::new(Duration::from_secs(30 * 24 * 3600))
423            .with_idempotency_key_max_age(Some(Duration::from_secs(60)));
424        assert_eq!(
425            policy.effective_idempotency_key_max_age(),
426            Some(Duration::from_secs(30 * 24 * 3600)),
427            "a key must not be swept while the task it names is still kept"
428        );
429        assert_eq!(
430            policy.idempotency_key_max_age,
431            Some(Duration::from_secs(60)),
432            "the field itself is untouched — the clamp is in the accessor, so \
433             what the operator set stays readable"
434        );
435    }
436
437    /// Counter-test: a key age above the task age is honoured exactly.
438    ///
439    /// Without it, an accessor that always returned `terminal_max_age` would
440    /// satisfy the test above.
441    #[test]
442    fn a_key_age_above_the_task_age_is_used_as_given() {
443        let policy = RetentionPolicy::new(Duration::from_secs(3600))
444            .with_idempotency_key_max_age(Some(Duration::from_secs(7 * 24 * 3600)));
445        assert_eq!(
446            policy.effective_idempotency_key_max_age(),
447            Some(Duration::from_secs(7 * 24 * 3600))
448        );
449    }
450
451    #[test]
452    fn opting_out_stays_opted_out() {
453        let policy =
454            RetentionPolicy::new(Duration::from_secs(3600)).with_idempotency_key_max_age(None);
455        assert_eq!(
456            policy.effective_idempotency_key_max_age(),
457            None,
458            "the clamp must not resurrect a sweep the operator turned off"
459        );
460    }
461}