Skip to main content

persistant_sqlx/
bounded.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! An optional per-call timeout for any [`RelationalBackend`].
3//!
4//! A server that cannot make progress may make a client wait without end: MySQL on a full disk
5//! logs `Disk is full ... Waiting for someone to free space` and never answers (run 39325665814),
6//! and any server waits on a row lock held by a stuck session. [`Bounded`] puts a clock on every
7//! call: past the bound the call returns [`RelError::Timeout`] instead of hanging. The default is no
8//! bound: nothing here is on unless a caller builds a [`Bounded`] (or reads a bound from the
9//! environment with [`call_bound_from_env`]).
10//!
11//! What a timeout does and does not promise:
12//! * the call returns an error naming the timeout, within about the bound;
13//! * a **transaction** that saw a timeout is poisoned: every later call in it, and `commit`, return
14//!   an error, and dropping it rolls it back, so a batch that timed out leaves nothing applied;
15//! * a **single statement** outside a transaction, or a `commit` that timed out, may or may not have
16//!   taken effect on the server: the outcome is unknown, as for any timed-out network call.
17
18use persistant_relational::{
19    Dialect, Isolation, RelError, RelationalBackend, RelationalTx, Row, Value,
20};
21use std::future::Future;
22use std::pin::Pin;
23use std::time::Duration;
24
25/// A [`RelationalBackend`] whose every call is cut off after `bound`.
26#[derive(Debug, Clone)]
27pub struct Bounded<B> {
28    inner: B,
29    bound: Duration,
30}
31
32impl<B> Bounded<B> {
33    /// Wrap `inner` so that no call waits longer than `bound`.
34    pub fn new(inner: B, bound: Duration) -> Self {
35        Bounded { inner, bound }
36    }
37
38    /// The wrapped backend (its calls are not bounded).
39    pub fn inner(&self) -> &B {
40        &self.inner
41    }
42
43    /// The per-call bound.
44    pub fn bound(&self) -> Duration {
45        self.bound
46    }
47}
48
49/// Read a per-call bound, in milliseconds, from the environment variable `var`. `None` when it is
50/// unset, empty, `0` or not a number: no bound, the default. The caller decides the variable's name.
51pub fn call_bound_from_env(var: &str) -> Option<Duration> {
52    parse_bound(&std::env::var(var).ok()?)
53}
54
55/// `"1500"` is 1.5 s; anything that is not a positive whole number of milliseconds is no bound.
56pub fn parse_bound(text: &str) -> Option<Duration> {
57    let ms: u64 = text.trim().parse().ok()?;
58    (ms > 0).then(|| Duration::from_millis(ms))
59}
60
61async fn within<T>(
62    bound: Duration,
63    call: impl Future<Output = Result<T, RelError>>,
64) -> Result<T, RelError> {
65    match tokio::time::timeout(bound, call).await {
66        Ok(r) => r,
67        Err(_) => Err(RelError::Timeout { after: bound }),
68    }
69}
70
71impl<B: RelationalBackend> RelationalBackend for Bounded<B> {
72    type Tx<'a>
73        = BoundedTx<B::Tx<'a>>
74    where
75        Self: 'a;
76
77    fn backend(&self) -> &str {
78        self.inner.backend()
79    }
80
81    fn version(&self) -> &str {
82        self.inner.version()
83    }
84
85    fn dialect(&self) -> Dialect {
86        self.inner.dialect()
87    }
88
89    async fn execute(&self, sql: &str, params: &[Value]) -> Result<u64, RelError> {
90        within(self.bound, self.inner.execute(sql, params)).await
91    }
92
93    async fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Row>, RelError> {
94        within(self.bound, self.inner.query(sql, params)).await
95    }
96
97    async fn begin(&self, isolation: Isolation) -> Result<Self::Tx<'_>, RelError> {
98        let tx = within(self.bound, self.inner.begin(isolation)).await?;
99        Ok(BoundedTx {
100            inner: tx,
101            bound: self.bound,
102            poisoned: false,
103        })
104    }
105
106    fn call_bound(&self) -> Option<Duration> {
107        Some(self.bound)
108    }
109
110    async fn with_lock_held<'a>(
111        &'a self,
112        lock_sql: &'a str,
113        at_most: Duration,
114        during: Pin<Box<dyn Future<Output = ()> + Send + 'a>>,
115    ) -> Result<(), RelError> {
116        let mut tx = self.begin(Isolation::ReadCommitted).await?;
117        // The lock is taken through the bounded transaction; nothing blocks it (the lock is free).
118        RelationalTx::query(&mut tx, lock_sql, &[]).await?;
119        // Drive the blocked call while the lock is held, but never longer than `at_most`: with a
120        // broken bound the call would otherwise wait for a lock that this function holds.
121        let _ = tokio::time::timeout(at_most, during).await;
122        RelationalTx::rollback(tx).await
123    }
124}
125
126/// A transaction on a [`Bounded`] backend. After any call times out it is poisoned: the statement
127/// that timed out has an unknown outcome inside the transaction, so nothing more is run in it and
128/// `commit` refuses; dropping it rolls it back.
129#[derive(Debug)]
130pub struct BoundedTx<T> {
131    inner: T,
132    bound: Duration,
133    poisoned: bool,
134}
135
136impl<T> BoundedTx<T> {
137    fn refuse(&self) -> RelError {
138        RelError::Backend(
139            "transaction poisoned by an earlier timeout; it can only be rolled back".to_string(),
140        )
141    }
142}
143
144impl<T: RelationalTx> RelationalTx for BoundedTx<T> {
145    async fn execute(&mut self, sql: &str, params: &[Value]) -> Result<u64, RelError> {
146        if self.poisoned {
147            return Err(self.refuse());
148        }
149        let r = within(self.bound, self.inner.execute(sql, params)).await;
150        self.poisoned |= matches!(r, Err(RelError::Timeout { .. }));
151        r
152    }
153
154    async fn query(&mut self, sql: &str, params: &[Value]) -> Result<Vec<Row>, RelError> {
155        if self.poisoned {
156            return Err(self.refuse());
157        }
158        let r = within(self.bound, self.inner.query(sql, params)).await;
159        self.poisoned |= matches!(r, Err(RelError::Timeout { .. }));
160        r
161    }
162
163    async fn commit(self) -> Result<(), RelError> {
164        if self.poisoned {
165            return Err(self.refuse());
166        }
167        within(self.bound, self.inner.commit()).await
168    }
169
170    async fn rollback(self) -> Result<(), RelError> {
171        within(self.bound, self.inner.rollback()).await
172    }
173}