Skip to main content

cufflink_pgcodec/
tx.rs

1//! The rules of an interactive transaction, as a pure state machine: what
2//! an open or failed transaction admits, where it stands afterwards, and
3//! which savepoints it holds. Whatever holds the connection applies them.
4
5use cufflink_types::db::{DbError, DbErrorKind, Isolation};
6use std::time::Duration;
7
8const STATEMENT_DEADLINE_MARGIN: Duration = Duration::from_millis(500);
9const STATEMENT_TIMEOUT_FLOOR: Duration = Duration::from_secs(1);
10
11/// The platform's `statement_timeout` with `remaining` of the invocation
12/// left: half a second short of the deadline, and never under a second, so
13/// a nearly spent budget can still run a statement.
14pub fn statement_timeout_for(remaining: Duration) -> Duration {
15    remaining
16        .saturating_sub(STATEMENT_DEADLINE_MARGIN)
17        .max(STATEMENT_TIMEOUT_FLOOR)
18}
19
20/// The route budget the native test host stands for: the one whose
21/// platform timeout at the start is `statement_timeout`.
22pub fn budget_of(statement_timeout: Duration) -> Duration {
23    statement_timeout + STATEMENT_DEADLINE_MARGIN
24}
25
26/// A guest's own `statement_timeout` in milliseconds, as `StatementTimeout`
27/// carries it. Zero, which Postgres reads as no timeout, and anything past
28/// the invocation's whole `budget` are refused.
29pub fn guest_statement_timeout(ms: u64, budget: Duration) -> Result<Duration, DbError> {
30    let timeout = Duration::from_millis(ms);
31    match ms > 0 && timeout <= budget {
32        true => Ok(timeout),
33        false => Err(DbError::new(
34            DbErrorKind::InvalidParameter,
35            format!(
36                "a statement timeout is at least 1 ms and at most the invocation's budget of {} ms",
37                budget.as_millis()
38            ),
39        )),
40    }
41}
42
43/// What a statement runs under: the platform's timeout, or the guest's
44/// where it is shorter. A guest's value below the platform's floor stands,
45/// since the floor only keeps a nearly spent budget usable.
46pub fn effective_statement_timeout(guest: Option<Duration>, platform: Duration) -> Duration {
47    guest.map_or(platform, |guest| guest.min(platform))
48}
49
50/// Where an open transaction stands. After a statement error Postgres has
51/// aborted the transaction and only a rollback (to a savepoint or whole)
52/// is accepted.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum Phase {
55    Open,
56    Failed,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum SlotOp {
61    Statement,
62    Savepoint,
63    Release,
64    RollbackTo,
65    Commit,
66    Rollback,
67}
68
69/// How an operation that ran came back: fine, refused before reaching the
70/// server (the transaction is untouched), or failed on the server (which
71/// aborts the transaction).
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Outcome {
74    Ok,
75    Refused,
76    ServerError,
77}
78
79/// Where the slot goes after an operation.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Next {
82    Stay(Phase),
83    Close,
84}
85
86/// Whether `op` may run in `phase`; a failed transaction answers every
87/// statement, savepoint and release with the error Postgres would give.
88pub fn admit(phase: Phase, op: SlotOp) -> Result<(), DbErrorKind> {
89    match (phase, op) {
90        (Phase::Open, _)
91        | (Phase::Failed, SlotOp::RollbackTo | SlotOp::Rollback | SlotOp::Commit) => Ok(()),
92        (Phase::Failed, SlotOp::Statement | SlotOp::Savepoint | SlotOp::Release) => {
93            Err(DbErrorKind::InFailedTransaction)
94        }
95    }
96}
97
98/// The slot's phase after an admitted `op` ran with `outcome`. Commit and
99/// rollback end the transaction whatever happened; a server error inside
100/// it aborts it; a successful rollback to a savepoint recovers it.
101pub fn next(phase: Phase, op: SlotOp, outcome: Outcome) -> Next {
102    match (op, outcome) {
103        (SlotOp::Commit | SlotOp::Rollback, _) => Next::Close,
104        (_, Outcome::Refused) => Next::Stay(phase),
105        (_, Outcome::ServerError) => Next::Stay(Phase::Failed),
106        (SlotOp::RollbackTo, Outcome::Ok) => Next::Stay(Phase::Open),
107        (_, Outcome::Ok) => Next::Stay(phase),
108    }
109}
110
111/// Savepoint names are interpolated into SQL, so only the SDK's own
112/// `sp_{depth}` shape is accepted.
113pub fn valid_savepoint(name: &str) -> bool {
114    name.strip_prefix("sp_")
115        .is_some_and(|n| (1..=3).contains(&n.len()) && n.bytes().all(|b| b.is_ascii_digit()))
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum SavepointOp {
120    Create,
121    Release,
122    RollbackTo,
123}
124
125impl SavepointOp {
126    pub fn slot_op(self) -> SlotOp {
127        match self {
128            SavepointOp::Create => SlotOp::Savepoint,
129            SavepointOp::Release => SlotOp::Release,
130            SavepointOp::RollbackTo => SlotOp::RollbackTo,
131        }
132    }
133}
134
135/// The SQL for a savepoint operation, when the name it refers to exists:
136/// `RELEASE` and `ROLLBACK TO` an unknown savepoint would abort the whole
137/// transaction on the server, so they are refused first.
138pub fn savepoint_sql(op: SavepointOp, name: &str, known: Option<usize>) -> Option<String> {
139    match (op, known) {
140        (SavepointOp::Create, _) => Some(format!("SAVEPOINT {name}")),
141        (SavepointOp::Release, Some(_)) => Some(format!("RELEASE SAVEPOINT {name}")),
142        (SavepointOp::RollbackTo, Some(_)) => Some(format!("ROLLBACK TO SAVEPOINT {name}")),
143        (_, None) => None,
144    }
145}
146
147/// The savepoint stack after a successful operation: `RELEASE` drops the
148/// savepoint and every later one, `ROLLBACK TO` keeps it and drops the later
149/// ones.
150pub fn savepoints_after(mut stack: Vec<String>, op: SavepointOp, name: &str) -> Vec<String> {
151    let known = stack.iter().rposition(|s| s == name);
152    match (op, known) {
153        (SavepointOp::Create, _) => stack.push(name.to_string()),
154        (SavepointOp::Release, Some(at)) => stack.truncate(at),
155        (SavepointOp::RollbackTo, Some(at)) => stack.truncate(at + 1),
156        (_, None) => {}
157    }
158    stack
159}
160
161pub fn isolation_sql(isolation: Isolation) -> &'static str {
162    match isolation {
163        Isolation::ReadCommitted => "READ COMMITTED",
164        Isolation::RepeatableRead => "REPEATABLE READ",
165        Isolation::Serializable => "SERIALIZABLE",
166    }
167}
168
169/// Transaction control inside a statement, which the host owns.
170pub fn transaction_control_refused() -> DbError {
171    DbError::new(
172        DbErrorKind::NotSupported,
173        "transaction control is refused in db_call statements; use begin, commit, \
174         rollback and the savepoint operations",
175    )
176}
177
178/// Parameters on a statement that cannot be prepared inside a transaction.
179pub fn parameters_outside_transaction() -> DbError {
180    DbError::new(
181        DbErrorKind::NotSupported,
182        "a statement that cannot run inside a transaction takes no parameters",
183    )
184}
185
186/// What a failed transaction answers until it is rolled back.
187pub fn in_failed_transaction(kind: DbErrorKind) -> DbError {
188    DbError::new(
189        kind,
190        "the transaction was aborted by an earlier error; roll back, or roll back to a savepoint, first",
191    )
192}
193
194/// A `COMMIT` of a failed transaction, which the server answers by
195/// rolling it back.
196pub fn rolled_back_by_commit() -> DbError {
197    DbError::new(
198        DbErrorKind::InFailedTransaction,
199        "the transaction was aborted by an earlier error; COMMIT rolled it back",
200    )
201}
202
203pub fn transaction_not_found(tx: u32) -> DbError {
204    DbError::new(
205        DbErrorKind::TransactionNotFound,
206        format!("no open transaction {tx}"),
207    )
208}
209
210pub fn transaction_limit(max_open: usize) -> DbError {
211    DbError::new(
212        DbErrorKind::TransactionLimit,
213        format!("at most {max_open} transactions may be open at once"),
214    )
215}
216
217pub fn invalid_savepoint(name: &str) -> DbError {
218    DbError::new(
219        DbErrorKind::InvalidParameter,
220        format!("invalid savepoint name {name:?}"),
221    )
222}
223
224pub fn unknown_savepoint(name: &str, tx: u32) -> DbError {
225    DbError::new(
226        DbErrorKind::InvalidParameter,
227        format!("no savepoint {name} in transaction {tx}"),
228    )
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn the_platform_timeout_keeps_a_margin_and_a_floor() {
237        assert_eq!(
238            statement_timeout_for(Duration::from_secs(30)),
239            Duration::from_millis(29_500)
240        );
241        assert_eq!(
242            statement_timeout_for(Duration::from_millis(1_200)),
243            Duration::from_secs(1)
244        );
245        assert_eq!(
246            statement_timeout_for(Duration::ZERO),
247            Duration::from_secs(1)
248        );
249        assert_eq!(
250            statement_timeout_for(budget_of(Duration::from_secs(30))),
251            Duration::from_secs(30)
252        );
253    }
254
255    #[test]
256    fn a_guest_timeout_is_positive_and_within_the_budget() {
257        let budget = Duration::from_secs(30);
258        assert_eq!(
259            guest_statement_timeout(200, budget).ok(),
260            Some(Duration::from_millis(200))
261        );
262        assert_eq!(guest_statement_timeout(30_000, budget).ok(), Some(budget));
263        for refused in [0, 30_001, u64::MAX] {
264            assert_eq!(
265                guest_statement_timeout(refused, budget)
266                    .err()
267                    .map(|e| e.kind),
268                Some(DbErrorKind::InvalidParameter),
269                "{refused}"
270            );
271        }
272    }
273
274    #[test]
275    fn the_shorter_timeout_wins_and_a_guest_one_may_pass_the_floor() {
276        let platform = statement_timeout_for(Duration::from_secs(30));
277        assert_eq!(effective_statement_timeout(None, platform), platform);
278        assert_eq!(
279            effective_statement_timeout(Some(Duration::from_millis(200)), platform),
280            Duration::from_millis(200)
281        );
282        assert_eq!(
283            effective_statement_timeout(Some(Duration::from_secs(60)), platform),
284            platform
285        );
286        let spent = statement_timeout_for(Duration::from_millis(300));
287        assert_eq!(spent, Duration::from_secs(1));
288        assert_eq!(
289            effective_statement_timeout(Some(Duration::from_millis(200)), spent),
290            Duration::from_millis(200)
291        );
292        assert_eq!(
293            effective_statement_timeout(Some(Duration::from_secs(2)), spent),
294            spent
295        );
296    }
297
298    #[test]
299    fn refusals_name_their_kind() {
300        for (refusal, kind) in [
301            (transaction_control_refused(), DbErrorKind::NotSupported),
302            (parameters_outside_transaction(), DbErrorKind::NotSupported),
303            (
304                in_failed_transaction(DbErrorKind::InFailedTransaction),
305                DbErrorKind::InFailedTransaction,
306            ),
307            (rolled_back_by_commit(), DbErrorKind::InFailedTransaction),
308            (transaction_not_found(7), DbErrorKind::TransactionNotFound),
309            (transaction_limit(2), DbErrorKind::TransactionLimit),
310            (invalid_savepoint("x"), DbErrorKind::InvalidParameter),
311            (unknown_savepoint("sp_1", 7), DbErrorKind::InvalidParameter),
312        ] {
313            assert_eq!(refusal.kind, kind, "{refusal}");
314            assert!(refusal.code.is_none(), "{refusal}");
315        }
316        assert_eq!(transaction_not_found(7).message, "no open transaction 7");
317        assert_eq!(
318            transaction_limit(2).message,
319            "at most 2 transactions may be open at once"
320        );
321        assert_eq!(
322            unknown_savepoint("sp_1", 7).message,
323            "no savepoint sp_1 in transaction 7"
324        );
325    }
326
327    const PHASES: [Phase; 2] = [Phase::Open, Phase::Failed];
328    const OPS: [SlotOp; 6] = [
329        SlotOp::Statement,
330        SlotOp::Savepoint,
331        SlotOp::Release,
332        SlotOp::RollbackTo,
333        SlotOp::Commit,
334        SlotOp::Rollback,
335    ];
336    const OUTCOMES: [Outcome; 3] = [Outcome::Ok, Outcome::Refused, Outcome::ServerError];
337
338    #[test]
339    fn an_open_transaction_admits_everything() {
340        for op in OPS {
341            assert_eq!(admit(Phase::Open, op), Ok(()), "{op:?}");
342        }
343    }
344
345    #[test]
346    fn a_failed_transaction_admits_only_the_ways_out() {
347        for (op, admitted) in [
348            (SlotOp::Statement, false),
349            (SlotOp::Savepoint, false),
350            (SlotOp::Release, false),
351            (SlotOp::RollbackTo, true),
352            (SlotOp::Commit, true),
353            (SlotOp::Rollback, true),
354        ] {
355            assert_eq!(
356                admit(Phase::Failed, op),
357                if admitted {
358                    Ok(())
359                } else {
360                    Err(DbErrorKind::InFailedTransaction)
361                },
362                "{op:?}"
363            );
364        }
365    }
366
367    #[test]
368    fn transitions_cover_every_phase_op_and_outcome() {
369        for phase in PHASES {
370            for op in OPS {
371                for outcome in OUTCOMES {
372                    let expected = match (op, outcome) {
373                        (SlotOp::Commit | SlotOp::Rollback, _) => Next::Close,
374                        (_, Outcome::Refused) => Next::Stay(phase),
375                        (_, Outcome::ServerError) => Next::Stay(Phase::Failed),
376                        (SlotOp::RollbackTo, Outcome::Ok) => Next::Stay(Phase::Open),
377                        (_, Outcome::Ok) => Next::Stay(phase),
378                    };
379                    assert_eq!(
380                        next(phase, op, outcome),
381                        expected,
382                        "{phase:?} {op:?} {outcome:?}"
383                    );
384                }
385            }
386        }
387        assert_eq!(
388            next(Phase::Failed, SlotOp::RollbackTo, Outcome::Ok),
389            Next::Stay(Phase::Open),
390            "savepoint recovery"
391        );
392        assert_eq!(
393            next(Phase::Open, SlotOp::Statement, Outcome::ServerError),
394            Next::Stay(Phase::Failed)
395        );
396        assert_eq!(
397            next(Phase::Open, SlotOp::Statement, Outcome::Refused),
398            Next::Stay(Phase::Open),
399            "a parameter the host refused never reached the server"
400        );
401    }
402
403    #[test]
404    fn savepoint_names_are_the_sdk_shape_only() {
405        for name in ["sp_0", "sp_1", "sp_12", "sp_999"] {
406            assert!(valid_savepoint(name), "{name}");
407        }
408        for name in [
409            "sp_",
410            "sp_1000",
411            "sp_a",
412            "sp_1; DROP TABLE x",
413            "SP_1",
414            "x_1",
415            "",
416        ] {
417            assert!(!valid_savepoint(name), "{name}");
418        }
419    }
420
421    #[test]
422    fn savepoint_sql_refuses_unknown_names_before_the_server_sees_them() {
423        assert_eq!(
424            savepoint_sql(SavepointOp::Create, "sp_1", None).as_deref(),
425            Some("SAVEPOINT sp_1")
426        );
427        assert_eq!(
428            savepoint_sql(SavepointOp::Release, "sp_1", Some(0)).as_deref(),
429            Some("RELEASE SAVEPOINT sp_1")
430        );
431        assert_eq!(
432            savepoint_sql(SavepointOp::RollbackTo, "sp_1", Some(0)).as_deref(),
433            Some("ROLLBACK TO SAVEPOINT sp_1")
434        );
435        assert_eq!(savepoint_sql(SavepointOp::Release, "sp_1", None), None);
436        assert_eq!(savepoint_sql(SavepointOp::RollbackTo, "sp_1", None), None);
437    }
438
439    #[test]
440    fn the_savepoint_stack_follows_postgres() {
441        let stack = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
442        let three = stack(&["sp_0", "sp_1", "sp_2"]);
443        assert_eq!(
444            savepoints_after(stack(&["sp_0"]), SavepointOp::Create, "sp_1"),
445            stack(&["sp_0", "sp_1"])
446        );
447        assert_eq!(
448            savepoints_after(three.clone(), SavepointOp::Release, "sp_1"),
449            stack(&["sp_0"])
450        );
451        assert_eq!(
452            savepoints_after(three.clone(), SavepointOp::RollbackTo, "sp_1"),
453            stack(&["sp_0", "sp_1"])
454        );
455        assert_eq!(
456            savepoints_after(stack(&["sp_0", "sp_0"]), SavepointOp::Release, "sp_0"),
457            stack(&["sp_0"]),
458            "the most recent of two same-named savepoints"
459        );
460    }
461
462    #[test]
463    fn isolation_levels_render_as_sql() {
464        assert_eq!(isolation_sql(Isolation::ReadCommitted), "READ COMMITTED");
465        assert_eq!(isolation_sql(Isolation::RepeatableRead), "REPEATABLE READ");
466        assert_eq!(isolation_sql(Isolation::Serializable), "SERIALIZABLE");
467    }
468}