1use 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
11pub fn statement_timeout_for(remaining: Duration) -> Duration {
15 remaining
16 .saturating_sub(STATEMENT_DEADLINE_MARGIN)
17 .max(STATEMENT_TIMEOUT_FLOOR)
18}
19
20pub fn budget_of(statement_timeout: Duration) -> Duration {
23 statement_timeout + STATEMENT_DEADLINE_MARGIN
24}
25
26pub 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
43pub fn effective_statement_timeout(guest: Option<Duration>, platform: Duration) -> Duration {
47 guest.map_or(platform, |guest| guest.min(platform))
48}
49
50#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum Outcome {
74 Ok,
75 Refused,
76 ServerError,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Next {
82 Stay(Phase),
83 Close,
84}
85
86pub 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
98pub 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
111pub 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
135pub 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
147pub 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
169pub 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
178pub 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
186pub 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
194pub 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}