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}