Skip to main content

persistant_relational/
sweep.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Expiry sweep: delete the rows of a consumer's own table whose timestamp column is older than a
3//! cutoff, in bounded batches.
4//!
5//! **This is a mechanism, not a policy.** There is no default cutoff, no schedule and no table list:
6//! which tables expire, by which column, and how long a row lives belong to the program that owns the
7//! table (its retention policy). The key-value kind's own `kv.purge_expired` is separate.
8//!
9//! **Batches.** Each batch is one autocommit `DELETE` of at most `batch` rows, so a sweep over millions
10//! of expired rows holds row locks for one batch at a time and never one long transaction. The sweep
11//! repeats until a batch deletes nothing, or until `max_batches` batches have run
12//! ([`SweepReport::complete`] is then `false` and a later call takes the rest).
13//!
14//! **Concurrent writers.** Only rows whose column is `< cutoff` *at the time the batch runs* are
15//! deleted: a row whose timestamp another transaction moved past the cutoff (a refreshed session) is
16//! kept, on every dialect. A sweep may delete fewer rows than exist under contention; the next call
17//! takes the rest. Rows whose column is `NULL` are never deleted (SQL comparison with `NULL`).
18//!
19//! **Names.** `table` (optionally `schema.table`) and `column` are used as written, unquoted, and must
20//! be plain identifiers (ASCII letters, digits, `_`, not starting with a digit): nothing else is
21//! accepted, so a name can never carry SQL. A reserved word as a name is the caller's to avoid.
22//!
23//! **SQLite** deletes by `rowid`: a `WITHOUT ROWID` table is refused by the database.
24
25use crate::{Dialect, RelError, RelationalBackend, Value};
26use std::fmt;
27
28/// What a sweep did.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SweepReport {
31    /// Rows deleted over all batches.
32    pub deleted: u64,
33    /// Batches run, including the final one that found nothing (when `complete`).
34    pub batches: u32,
35    /// `true` when a batch found nothing left to delete; `false` when `max_batches` stopped it first.
36    pub complete: bool,
37}
38
39/// Why a sweep did not run or did not finish.
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum SweepError {
43    /// `table` or `column` is not a plain identifier.
44    BadName(String),
45    /// `batch` or `max_batches` is zero.
46    ZeroBound,
47    /// The cutoff is `NULL`, which would match no row.
48    NullCutoff,
49    /// The database refused a batch. Batches before it stay deleted.
50    Rel(RelError),
51    /// The backend speaks a dialect this function has no statement for.
52    Dialect(String),
53}
54
55impl fmt::Display for SweepError {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            SweepError::BadName(n) => write!(f, "not a plain identifier: {n:?}"),
59            SweepError::ZeroBound => f.write_str("batch and max_batches must be at least 1"),
60            SweepError::NullCutoff => f.write_str("the cutoff must not be NULL"),
61            SweepError::Rel(e) => write!(f, "sweep: {e}"),
62            SweepError::Dialect(d) => write!(f, "sweep: no statement for dialect {d}"),
63        }
64    }
65}
66
67impl std::error::Error for SweepError {}
68
69fn ident(s: &str) -> bool {
70    !s.is_empty()
71        && !s.starts_with(|c: char| c.is_ascii_digit())
72        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
73}
74
75fn table_name(s: &str) -> bool {
76    match s.split_once('.') {
77        Some((schema, table)) => ident(schema) && ident(table),
78        None => ident(s),
79    }
80}
81
82/// The one-batch statement for `dialect`, with the cutoff as its only parameter.
83fn statement(dialect: &Dialect, table: &str, column: &str, batch: u32) -> Option<String> {
84    Some(match dialect {
85        Dialect::Postgres => format!(
86            "DELETE FROM {table} WHERE ctid IN \
87             (SELECT ctid FROM {table} WHERE {column} < $1 LIMIT {batch})"
88        ),
89        Dialect::Sqlite => format!(
90            "DELETE FROM {table} WHERE rowid IN \
91             (SELECT rowid FROM {table} WHERE {column} < ? LIMIT {batch})"
92        ),
93        Dialect::MySql | Dialect::MariaDb => {
94            format!("DELETE FROM {table} WHERE {column} < ? LIMIT {batch}")
95        }
96        _ => return None,
97    })
98}
99
100/// Delete rows of `table` whose `column` is less than `cutoff`, at most `batch` per statement, for at
101/// most `max_batches` statements. Batches, concurrent writers, `NULL` and naming rules are in the header of this file (`sweep.rs`).
102pub async fn sweep<B: RelationalBackend>(
103    db: &B,
104    table: &str,
105    column: &str,
106    cutoff: &Value,
107    batch: u32,
108    max_batches: u32,
109) -> Result<SweepReport, SweepError> {
110    if !table_name(table) {
111        return Err(SweepError::BadName(table.to_string()));
112    }
113    if !ident(column) {
114        return Err(SweepError::BadName(column.to_string()));
115    }
116    if batch == 0 || max_batches == 0 {
117        return Err(SweepError::ZeroBound);
118    }
119    if *cutoff == Value::Null {
120        return Err(SweepError::NullCutoff);
121    }
122    let dialect = db.dialect();
123    let sql = statement(&dialect, table, column, batch)
124        .ok_or_else(|| SweepError::Dialect(dialect.to_string()))?;
125    let mut report = SweepReport {
126        deleted: 0,
127        batches: 0,
128        complete: false,
129    };
130    while report.batches < max_batches {
131        let n = db
132            .execute(&sql, std::slice::from_ref(cutoff))
133            .await
134            .map_err(SweepError::Rel)?;
135        report.batches += 1;
136        report.deleted += n;
137        if n == 0 {
138            report.complete = true;
139            break;
140        }
141    }
142    Ok(report)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn names_are_plain_identifiers_only() {
151        for ok in ["sessions", "_t1", "public.sessions", "A_b"] {
152            assert!(table_name(ok), "{ok}");
153        }
154        for bad in [
155            "",
156            "1t",
157            "t; DROP TABLE t",
158            "a.b.c",
159            ".t",
160            "t.",
161            "a b",
162            "t\"",
163            "t-1",
164            "é",
165        ] {
166            assert!(!table_name(bad), "{bad:?}");
167        }
168        assert!(!ident("a.b"));
169    }
170
171    #[test]
172    fn each_dialect_has_a_statement_with_one_parameter() {
173        for d in [
174            Dialect::Postgres,
175            Dialect::Sqlite,
176            Dialect::MySql,
177            Dialect::MariaDb,
178        ] {
179            let s = statement(&d, "t", "c", 7).unwrap();
180            assert!(s.contains("LIMIT 7"), "{s}");
181            assert_eq!(s.matches("c < ").count(), 1);
182        }
183        assert!(statement(&Dialect::Other("x".into()), "t", "c", 1).is_none());
184    }
185}