Skip to main content

server/
tx.rs

1//! Interactive transactions over HTTP: `POST /tx/begin`, `POST /tx/{id}/run`,
2//! `POST /tx/{id}/commit`, `POST /tx/{id}/rollback`, `DELETE /tx/{id}` and
3//! `GET /tx/{id}`.
4//!
5//! # Where the state lives
6//!
7//! An open transaction is a [`Transaction`] (an owned, `Send` handle that holds
8//! no lock) kept in a [`TxStore`], a map from a random 128-bit id to the
9//! transaction, the identity that began it and an idle deadline. The state is in
10//! memory: a restart loses every open transaction, which is a rollback.
11//!
12//! # Rules the store enforces
13//!
14//! - **Ownership.** A transaction belongs to the credential that began it (the
15//!   bearer token itself is the owner key; a server with no authentication has
16//!   one shared owner, so every caller can reach every transaction). Any
17//!   other caller gets 404 for it, the answer for an id that never existed, so
18//!   an id reveals nothing to someone who is not its owner.
19//! - **Idle timeout.** `MUSHROOMDB_TX_TIMEOUT_SECS` (default 30) from the last
20//!   request that finished on the transaction. A background thread rolls back
21//!   what expired; a request that arrives first is answered 410 and rolls it
22//!   back itself. An expired id answers 410 to its owner for a while, then 404.
23//! - **Limit.** At most `MUSHROOMDB_TX_MAX` (default 100) open at once; a
24//!   `begin` beyond it is 503 with `Retry-After`.
25//! - **Lifetime.** `MUSHROOMDB_TX_MAX_LIFETIME_SECS` (default 300) from `begin`,
26//!   however often it is used: a client that runs a statement every few
27//!   seconds cannot pin its snapshot and write buffer forever. Past it the
28//!   transaction is rolled back, even if a request is running on it.
29//! - **Memory.** An open transaction holds its snapshot and every write it
30//!   buffered, up to a request body (64 MiB) per statement. The cap on open
31//!   transactions and the lifetime bound the total; they do not make one
32//!   transaction small.
33//! - **One request at a time.** A request on a transaction checks it out of the
34//!   map and puts it back when it ends. A second request in between is 409
35//!   "transaction busy": two statements never interleave on one transaction.
36//!   The hand-back runs on the blocking thread that ran the statement, not in
37//!   the request's future, so a request that is cancelled (client gone, 408,
38//!   load shed) cannot strand the transaction; a drop guard ends it if the
39//!   statement panics.
40//! - **Shutdown.** [`TxStore::close`] rolls back every open transaction and
41//!   makes `begin` answer 503. The server calls it when the shutdown signal
42//!   arrives, which is the start of the drain phase. A transaction that is
43//!   mid-request then is rolled back when that request ends.
44//!
45//! # Identity and scope
46//!
47//! A full token, an `admin` key and a `write` key run the transaction engine
48//! itself: reads see the transaction's own writes and a commit is one WAL
49//! frame. A role token and a `read` key are scoped: the role's mask is
50//! resolved at `begin` and every read runs through it on the snapshot the
51//! transaction began on. Such a transaction never writes (403, the refusal
52//! `/query` gives), because the transaction engine has no role-scoped write
53//! path; its commit writes nothing and returns the snapshot's commit number.
54
55use super::{
56    err_response, forbidden, graph_err, json_ok, request_token, role_mask_err, AppState,
57    AuthIdentity, ReadOnlyKey, READ_TIER_REFUSAL,
58};
59use crate::json::{params_from_json, result_set_json};
60use axum::extract::{Extension, Path, Request, State};
61use axum::http::{header, HeaderValue, StatusCode};
62use axum::middleware::{self, Next};
63use axum::response::{IntoResponse, Response};
64use axum::routing::{get, post};
65use axum::{Json, Router};
66use mushroomdb::{
67    is_write_query, GraphError, NodeMask, ReaderSnapshot, ResultSet, Transaction, Value,
68};
69use serde_json::{json, Value as Js};
70use std::collections::{BTreeMap, HashMap};
71use std::sync::{Arc, Mutex, MutexGuard, Once, PoisonError, Weak};
72use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
73
74/// Environment variable: seconds a transaction may sit idle.
75pub const TX_TIMEOUT_ENV: &str = "MUSHROOMDB_TX_TIMEOUT_SECS";
76/// Environment variable: seconds a transaction may live, used or not.
77pub const TX_MAX_LIFETIME_ENV: &str = "MUSHROOMDB_TX_MAX_LIFETIME_SECS";
78/// Environment variable: most transactions open at once.
79pub const TX_MAX_ENV: &str = "MUSHROOMDB_TX_MAX";
80
81const DEFAULT_IDLE_SECS: u64 = 30;
82const DEFAULT_MAX_OPEN: usize = 100;
83const DEFAULT_LIFETIME_SECS: u64 = 300;
84/// How many expired ids are remembered, so their owner hears 410 and not 404.
85const MAX_TOMBSTONES: usize = 10_000;
86/// The text of a bookmark; the Bolt `COMMIT` reply carries the same one.
87const BOOKMARK_PREFIX: &str = "mushroomdb:commit:";
88
89/// A commit number as the text a client hands to a later request.
90fn bookmark_text(seq: u64) -> String {
91    format!("{BOOKMARK_PREFIX}{seq}")
92}
93
94/// The limits on open transactions.
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct TxLimits {
97    /// Most transactions open at once; a `begin` beyond it is 503.
98    pub max_open: usize,
99    /// How long a transaction may sit idle before it is rolled back.
100    pub idle_timeout: Duration,
101    /// How long a transaction may live from `begin`, however busy.
102    pub max_lifetime: Duration,
103}
104
105impl Default for TxLimits {
106    fn default() -> Self {
107        Self {
108            max_open: DEFAULT_MAX_OPEN,
109            idle_timeout: Duration::from_secs(DEFAULT_IDLE_SECS),
110            max_lifetime: Duration::from_secs(DEFAULT_LIFETIME_SECS),
111        }
112    }
113}
114
115impl TxLimits {
116    /// The limits `MUSHROOMDB_TX_MAX`, `MUSHROOMDB_TX_TIMEOUT_SECS` and
117    /// `MUSHROOMDB_TX_MAX_LIFETIME_SECS` name. A
118    /// value that is unset keeps the default; one that is not a positive
119    /// integer keeps the default too and logs a warning.
120    pub fn from_env() -> Self {
121        Self::from_lookup(|name| std::env::var(name).ok())
122    }
123
124    /// [`TxLimits::from_env`] reading the three variables through `lookup`
125    /// instead of the process environment.
126    pub fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
127        Self::from_values(
128            lookup(TX_MAX_ENV).as_deref(),
129            lookup(TX_TIMEOUT_ENV).as_deref(),
130            lookup(TX_MAX_LIFETIME_ENV).as_deref(),
131        )
132    }
133
134    fn from_values(
135        max_open: Option<&str>,
136        idle_secs: Option<&str>,
137        lifetime_secs: Option<&str>,
138    ) -> Self {
139        let positive = |name: &str, raw: Option<&str>| -> Option<u64> {
140            let raw = raw?;
141            match raw.trim().parse::<u64>() {
142                Ok(n) if n > 0 => Some(n),
143                _ => {
144                    tracing::warn!("{name}={raw:?} is not a positive integer; using the default");
145                    None
146                }
147            }
148        };
149        let defaults = Self::default();
150        Self {
151            max_open: positive(TX_MAX_ENV, max_open).map_or(defaults.max_open, |n| {
152                usize::try_from(n).unwrap_or(usize::MAX)
153            }),
154            idle_timeout: positive(TX_TIMEOUT_ENV, idle_secs)
155                .map_or(defaults.idle_timeout, Duration::from_secs),
156            max_lifetime: positive(TX_MAX_LIFETIME_ENV, lifetime_secs)
157                .map_or(defaults.max_lifetime, Duration::from_secs),
158        }
159    }
160}
161
162/// What an open transaction runs on.
163pub(crate) enum TxBody {
164    /// The transaction engine: writes buffer and commit as one WAL frame.
165    Full(Box<Transaction>),
166    /// A role's read-only view of the snapshot the transaction began on.
167    Scoped {
168        snapshot: ReaderSnapshot,
169        mask: NodeMask,
170        /// A `read`-tier key, whose refusal text differs from a role token's.
171        read_tier_key: bool,
172        /// The clock at BEGIN: what `.transaction()` reads in each statement.
173        clock_micros: i64,
174    },
175}
176
177struct Slot {
178    owner: String,
179    /// When `begin` ran; the absolute lifetime counts from here.
180    born: Instant,
181    deadline: Instant,
182    deadline_wall: SystemTime,
183    op_count: usize,
184    /// `None` while a request has the transaction checked out.
185    body: Option<TxBody>,
186}
187
188struct Tombstone {
189    owner: String,
190    expired_at: Instant,
191    why: Expiry,
192}
193
194/// Which limit rolled a transaction back; the 410 says which.
195#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub(crate) enum Expiry {
197    /// Idle for the idle timeout.
198    Idle,
199    /// Open for the maximum lifetime, however busy.
200    Lifetime,
201}
202
203#[derive(Default)]
204struct Registry {
205    open: HashMap<String, Slot>,
206    expired: HashMap<String, Tombstone>,
207    closed: bool,
208}
209
210struct Inner {
211    limits: TxLimits,
212    state: Mutex<Registry>,
213    reaper: Once,
214}
215
216/// Why a store call failed; [`TxError::into_response`] gives the HTTP answer.
217#[derive(Debug, PartialEq, Eq)]
218pub(crate) enum TxError {
219    /// No such transaction, or not the caller's.
220    NotFound,
221    /// The idle timeout or the lifetime rolled it back.
222    Expired(Expiry),
223    /// Another request has it checked out.
224    Busy,
225    /// The server is shutting down.
226    Closed,
227    /// The open-transaction limit is reached.
228    TooMany(usize),
229}
230
231impl IntoResponse for TxError {
232    fn into_response(self) -> Response {
233        let (status, message) = match self {
234            Self::NotFound => (StatusCode::NOT_FOUND, "unknown transaction".to_string()),
235            Self::Expired(Expiry::Idle) => (
236                StatusCode::GONE,
237                "transaction expired: idle too long, rolled back".to_string(),
238            ),
239            Self::Expired(Expiry::Lifetime) => (
240                StatusCode::GONE,
241                "transaction expired: open longer than its maximum lifetime, rolled back"
242                    .to_string(),
243            ),
244            Self::Busy => (StatusCode::CONFLICT, "transaction busy".to_string()),
245            Self::Closed => (
246                StatusCode::SERVICE_UNAVAILABLE,
247                "server is shutting down; no transactions".to_string(),
248            ),
249            Self::TooMany(limit) => (
250                StatusCode::SERVICE_UNAVAILABLE,
251                format!("too many open transactions (limit {limit}); retry"),
252            ),
253        };
254        let mut body = json!({ "error": message });
255        if self == Self::Busy {
256            // Told apart from a commit conflict (`"conflict"`): nothing is
257            // wrong with the transaction, the request can be retried.
258            body["code"] = json!("busy");
259        }
260        let mut res = (status, Json(body)).into_response();
261        if status == StatusCode::SERVICE_UNAVAILABLE {
262            res.headers_mut()
263                .insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
264        }
265        res
266    }
267}
268
269/// The open transactions of one server. Cheap to clone; clones share state.
270#[derive(Clone)]
271pub(crate) struct TxStore {
272    inner: Arc<Inner>,
273}
274
275fn lock(state: &Mutex<Registry>) -> MutexGuard<'_, Registry> {
276    state.lock().unwrap_or_else(PoisonError::into_inner)
277}
278
279/// Equality that does not stop at the first differing byte: the owner is a
280/// bearer token.
281fn same_owner(a: &str, b: &str) -> bool {
282    super::constant_time_eq(a.as_bytes(), b.as_bytes())
283}
284
285impl TxStore {
286    pub(crate) fn new(limits: TxLimits) -> Self {
287        Self {
288            inner: Arc::new(Inner {
289                limits,
290                state: Mutex::new(Registry::default()),
291                reaper: Once::new(),
292            }),
293        }
294    }
295
296    /// Whether a `begin` could be accepted right now. Checked before the
297    /// snapshot is frozen, so a flood of begins at the limit does no work.
298    pub(crate) fn has_room(&self) -> Result<(), TxError> {
299        let mut state = lock(&self.inner.state);
300        if state.closed {
301            return Err(TxError::Closed);
302        }
303        let reaped = self.reap_locked(&mut state, Instant::now());
304        let full = state.open.len() >= self.inner.limits.max_open;
305        drop(state);
306        drop(reaped);
307        if full {
308            return Err(TxError::TooMany(self.inner.limits.max_open));
309        }
310        Ok(())
311    }
312
313    /// Register a new transaction for `owner`; returns its id and when it
314    /// expires.
315    pub(crate) fn begin(
316        &self,
317        owner: String,
318        body: TxBody,
319    ) -> Result<(String, SystemTime), TxError> {
320        self.start_reaper();
321        let now = Instant::now();
322        let id = format!("{:032x}", rand::random::<u128>());
323        let (deadline, deadline_wall) = self.next_deadline(now, now);
324        let reaped = {
325            let mut state = lock(&self.inner.state);
326            if state.closed {
327                return Err(TxError::Closed);
328            }
329            // Count only live ones, whether or not the reaper has run yet.
330            let reaped = self.reap_locked(&mut state, now);
331            if state.open.len() >= self.inner.limits.max_open {
332                drop(state);
333                drop(reaped);
334                return Err(TxError::TooMany(self.inner.limits.max_open));
335            }
336            state.open.insert(
337                id.clone(),
338                Slot {
339                    owner,
340                    born: now,
341                    deadline,
342                    deadline_wall,
343                    op_count: 0,
344                    body: Some(body),
345                },
346            );
347            reaped
348        };
349        drop(reaped);
350        Ok((id, deadline_wall))
351    }
352
353    /// Take the transaction out of the map for one request. While it is out, a
354    /// second request on it is [`TxError::Busy`].
355    pub(crate) fn checkout(&self, id: &str, owner: &str) -> Result<TxBody, TxError> {
356        let now = Instant::now();
357        let mut state = lock(&self.inner.state);
358        let Some(slot) = state.open.get_mut(id) else {
359            return Err(Self::missing(&state, id, owner));
360        };
361        if !same_owner(&slot.owner, owner) {
362            return Err(TxError::NotFound);
363        }
364        let Some(body) = slot.body.take() else {
365            return Err(TxError::Busy);
366        };
367        if slot.deadline <= now {
368            // Expired before the reaper got to it: it is rolled back now.
369            slot.body = Some(body);
370            let why = self.expiry(slot);
371            let reaped = self.expire(&mut state, id, now);
372            drop(state);
373            drop(reaped);
374            return Err(TxError::Expired(why));
375        }
376        Ok(body)
377    }
378
379    /// Return a checked-out transaction and restart its idle timer. `ran` counts
380    /// the request in `op_count`. If the server began shutting down meanwhile
381    /// the transaction is rolled back and the request is refused.
382    pub(crate) fn checkin(&self, id: &str, body: TxBody, ran: bool) -> Result<(), TxError> {
383        let now = Instant::now();
384        let mut state = lock(&self.inner.state);
385        if state.closed {
386            let slot = state.open.remove(id);
387            drop(state);
388            drop((slot, body));
389            return Err(TxError::Closed);
390        }
391        let was_expired = state.expired.get(id).map(|t| t.why);
392        match state.open.get_mut(id) {
393            Some(slot) => {
394                let (deadline, deadline_wall) = self.next_deadline(now, slot.born);
395                slot.body = Some(body);
396                slot.deadline = deadline;
397                slot.deadline_wall = deadline_wall;
398                slot.op_count += usize::from(ran);
399                Ok(())
400            }
401            // Removed while the statement ran: past its lifetime.
402            None => Err(was_expired.map_or(TxError::NotFound, TxError::Expired)),
403        }
404    }
405
406    /// Forget a transaction that ended (committed, rolled back, conflicted).
407    pub(crate) fn finish(&self, id: &str) {
408        let slot = lock(&self.inner.state).open.remove(id);
409        drop(slot);
410    }
411
412    /// What `GET /tx/{id}` reports. Does not restart the idle timer. While a
413    /// request has the transaction checked out it is [`TxError::Busy`], as
414    /// every other request on it is.
415    pub(crate) fn info(&self, id: &str, owner: &str) -> Result<(SystemTime, usize), TxError> {
416        let now = Instant::now();
417        let mut state = lock(&self.inner.state);
418        let Some(slot) = state.open.get(id) else {
419            return Err(Self::missing(&state, id, owner));
420        };
421        if !same_owner(&slot.owner, owner) {
422            return Err(TxError::NotFound);
423        }
424        // A request holds it: a statement or a commit is running, and the
425        // caller cannot yet know whether the transaction will still be there.
426        if slot.body.is_none() {
427            return Err(TxError::Busy);
428        }
429        if slot.deadline <= now {
430            let why = self.expiry(slot);
431            let reaped = self.expire(&mut state, id, now);
432            drop(state);
433            drop(reaped);
434            return Err(TxError::Expired(why));
435        }
436        Ok((slot.deadline_wall, slot.op_count))
437    }
438
439    /// Roll back every open transaction and refuse new ones. A transaction a
440    /// request has checked out is rolled back when that request ends.
441    pub(crate) fn close(&self) -> usize {
442        let dropped: Vec<Slot> = {
443            let mut state = lock(&self.inner.state);
444            state.closed = true;
445            let idle: Vec<String> = state
446                .open
447                .iter()
448                .filter(|(_, slot)| slot.body.is_some())
449                .map(|(id, _)| id.clone())
450                .collect();
451            idle.iter().filter_map(|id| state.open.remove(id)).collect()
452        };
453        dropped.len()
454    }
455
456    /// Roll back every idle transaction whose deadline is at or before `now`.
457    /// Returns how many.
458    pub(crate) fn reap(&self, now: Instant) -> usize {
459        let reaped = {
460            let mut state = lock(&self.inner.state);
461            self.reap_locked(&mut state, now)
462        };
463        reaped.len()
464    }
465
466    /// How many transactions are open.
467    pub(crate) fn open_count(&self) -> usize {
468        lock(&self.inner.state).open.len()
469    }
470
471    /// The idle deadline from `now`, never past `born` plus the lifetime.
472    fn next_deadline(&self, now: Instant, born: Instant) -> (Instant, SystemTime) {
473        let limits = &self.inner.limits;
474        let deadline = (now + limits.idle_timeout).min(born + limits.max_lifetime);
475        (
476            deadline,
477            SystemTime::now() + deadline.saturating_duration_since(now),
478        )
479    }
480
481    /// The answer for an id that is not open: 410 to the owner of an id that
482    /// expired, 404 to anyone else.
483    fn missing(state: &Registry, id: &str, owner: &str) -> TxError {
484        match state.expired.get(id) {
485            Some(t) if same_owner(&t.owner, owner) => TxError::Expired(t.why),
486            _ => TxError::NotFound,
487        }
488    }
489
490    /// Remove `id` as expired. The removed slot is returned so the caller drops
491    /// it (the rollback) after releasing the lock.
492    fn expire(&self, state: &mut Registry, id: &str, now: Instant) -> Option<Slot> {
493        let slot = state.open.remove(id)?;
494        let why = self.expiry(&slot);
495        self.remember(state, id, &slot.owner, now, why);
496        Some(slot)
497    }
498
499    /// Which limit a due slot ran into first, whenever it is noticed. A slot a
500    /// request holds has no idle deadline, so only the lifetime can end it.
501    /// Otherwise `deadline` is the nearer of the idle deadline and the
502    /// lifetime ([`Self::next_deadline`]): equal to the lifetime when that is
503    /// the nearer one, earlier when the idle deadline is.
504    fn expiry(&self, slot: &Slot) -> Expiry {
505        if slot.body.is_none() || slot.deadline >= slot.born + self.inner.limits.max_lifetime {
506            Expiry::Lifetime
507        } else {
508            Expiry::Idle
509        }
510    }
511
512    fn remember(&self, state: &mut Registry, id: &str, owner: &str, now: Instant, why: Expiry) {
513        if state.expired.len() >= MAX_TOMBSTONES {
514            let oldest = state
515                .expired
516                .iter()
517                .min_by_key(|(_, t)| t.expired_at)
518                .map(|(k, _)| k.clone());
519            if let Some(oldest) = oldest {
520                state.expired.remove(&oldest);
521            }
522        }
523        state.expired.insert(
524            id.to_string(),
525            Tombstone {
526                owner: owner.to_string(),
527                expired_at: now,
528                why,
529            },
530        );
531    }
532
533    fn reap_locked(&self, state: &mut Registry, now: Instant) -> Vec<Slot> {
534        let retention = (self.inner.limits.idle_timeout * 10).max(Duration::from_secs(60));
535        state
536            .expired
537            .retain(|_, t| now.saturating_duration_since(t.expired_at) < retention);
538        let due: Vec<String> = state
539            .open
540            .iter()
541            .filter(|(_, slot)| {
542                // Idle past its deadline, or past its lifetime even while a
543                // request holds it (which also frees a slot whose request was
544                // lost some other way).
545                (slot.body.is_some() && slot.deadline <= now)
546                    || slot.born + self.inner.limits.max_lifetime <= now
547            })
548            .map(|(id, _)| id.clone())
549            .collect();
550        due.iter()
551            .filter_map(|id| self.expire(state, id, now))
552            .collect()
553    }
554
555    /// Start the background thread that rolls back expired transactions, once.
556    /// It holds only a weak reference, so it ends when the server is dropped.
557    fn start_reaper(&self) {
558        self.inner.reaper.call_once(|| {
559            let weak: Weak<Inner> = Arc::downgrade(&self.inner);
560            let tick = (self.inner.limits.idle_timeout / 4)
561                .clamp(Duration::from_millis(10), Duration::from_secs(1));
562            let spawned =
563                std::thread::Builder::new()
564                    .name("tx-reaper".into())
565                    .spawn(move || loop {
566                        std::thread::sleep(tick);
567                        let Some(inner) = weak.upgrade() else { break };
568                        let store = TxStore { inner };
569                        if lock(&store.inner.state).closed {
570                            break;
571                        }
572                        let n = store.reap(Instant::now());
573                        if n > 0 {
574                            tracing::debug!(n, "rolled back idle transactions");
575                        }
576                    });
577            if let Err(e) = spawned {
578                // Expiry is still enforced on access and at `begin`.
579                tracing::error!("could not start the transaction reaper: {e}");
580            }
581        });
582    }
583}
584
585// ── HTTP ─────────────────────────────────────────────────────────────────────
586
587/// The credential a request presented: who owns the transactions it begins.
588#[derive(Clone, Debug)]
589struct Principal(String);
590
591/// Runs inside the auth middleware, so the credential here already resolved.
592async fn principal(mut req: Request, next: Next) -> Response {
593    let who = Principal(request_token(&req).unwrap_or_default());
594    req.extensions_mut().insert(who);
595    next.run(req).await
596}
597
598pub(super) fn routes() -> Router<AppState> {
599    Router::new()
600        .route("/tx/begin", post(begin))
601        .route("/tx/{id}", get(info))
602        .route("/tx/{id}", axum::routing::delete(rollback))
603        .route("/tx/{id}/run", post(run))
604        .route("/tx/{id}/commit", post(commit))
605        .route("/tx/{id}/rollback", post(rollback))
606        .route_layer(middleware::from_fn(principal))
607}
608
609/// `2026-10-22T12:34:56.789Z`.
610fn iso8601(t: SystemTime) -> String {
611    let since = t.duration_since(UNIX_EPOCH).unwrap_or_default();
612    let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX / 2);
613    let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
614    // Days since 1970-01-01 to a civil date (Hinnant's `civil_from_days`).
615    let z = days + 719_468;
616    let era = z.div_euclid(146_097);
617    let doe = z.rem_euclid(146_097);
618    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
619    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
620    let mp = (5 * doy + 2) / 153;
621    let day = doy - (153 * mp + 2) / 5 + 1;
622    let month = if mp < 10 { mp + 3 } else { mp - 9 };
623    let year = yoe + era * 400 + i64::from(month <= 2);
624    format!(
625        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
626        rem / 3_600,
627        rem % 3_600 / 60,
628        rem % 60,
629        since.subsec_millis()
630    )
631}
632
633async fn begin(
634    State(state): State<AppState>,
635    Extension(identity): Extension<AuthIdentity>,
636    Extension(who): Extension<Principal>,
637    read_only_key: Option<Extension<ReadOnlyKey>>,
638) -> Response {
639    // Before the snapshot is frozen: a begin that will be refused does no work.
640    if let Err(e) = state.txs.has_room() {
641        return e.into_response();
642    }
643    let db = state.db.clone();
644    let body = match identity {
645        AuthIdentity::Role(role) => {
646            let snapshot = db.reader();
647            let mask = match snapshot.mask_for_role(&role) {
648                Ok(mask) => mask,
649                Err(e) => return role_mask_err(e),
650            };
651            TxBody::Scoped {
652                snapshot,
653                mask,
654                read_tier_key: read_only_key.is_some(),
655                clock_micros: mushroomdb::clock_micros(),
656            }
657        }
658        // `Anonymous` reaches only `/health`; refuse it here all the same.
659        AuthIdentity::Anonymous => return forbidden("a credential is required"),
660        AuthIdentity::Full | AuthIdentity::Write => {
661            // Freezing the snapshot reads under the store's read lock.
662            match tokio::task::spawn_blocking(move || db.begin_transaction()).await {
663                Ok(tx) => TxBody::Full(Box::new(tx)),
664                Err(_) => return err_response("begin task panicked"),
665            }
666        }
667    };
668    match state.txs.begin(who.0, body) {
669        Ok((id, expires)) => json_ok(json!({"tx_id": id, "expires_at": iso8601(expires)})),
670        Err(e) => e.into_response(),
671    }
672}
673
674async fn info(
675    State(state): State<AppState>,
676    Extension(who): Extension<Principal>,
677    Path(id): Path<String>,
678) -> Response {
679    match state.txs.info(&id, &who.0) {
680        Ok((expires, op_count)) => json_ok(json!({
681            "tx_id": id,
682            "expires_at": iso8601(expires),
683            "op_count": op_count,
684        })),
685        Err(e) => e.into_response(),
686    }
687}
688
689/// Why a statement did not run.
690enum RunError {
691    Engine(GraphError),
692    /// 400: the text is not a statement this path can classify or run.
693    Bad(String),
694    /// 403, with the refusal text.
695    Refused(&'static str),
696}
697
698impl RunError {
699    fn into_response(self) -> Response {
700        match self {
701            Self::Engine(e) => graph_err(e),
702            Self::Bad(detail) => err_response(detail),
703            Self::Refused(detail) => forbidden(detail),
704        }
705    }
706}
707
708/// One statement on a transaction body, on a blocking thread.
709fn execute(
710    body: &mut TxBody,
711    cypher: &str,
712    params: &BTreeMap<String, Value>,
713) -> Result<ResultSet, RunError> {
714    match body {
715        TxBody::Full(tx) => tx.execute(cypher, params).map_err(RunError::Engine),
716        TxBody::Scoped {
717            snapshot,
718            mask,
719            read_tier_key,
720            clock_micros,
721        } => {
722            if is_write_query(cypher).map_err(RunError::Bad)? {
723                return Err(RunError::Refused(if *read_tier_key {
724                    READ_TIER_REFUSAL
725                } else {
726                    "role-bound token: writes are not permitted inside a transaction"
727                }));
728            }
729            snapshot
730                .query_masked_in_transaction(cypher, params, mask, *clock_micros)
731                .map_err(|e| match e {
732                    GraphError::MaskedReadOnly => {
733                        RunError::Bad("masked queries are read-only".into())
734                    }
735                    other => RunError::Engine(other),
736                })
737        }
738    }
739}
740
741/// Ends a transaction's slot when dropped unless disarmed. The hand-back
742/// runs on a blocking thread; this covers that thread panicking.
743struct Lease<'a> {
744    store: &'a TxStore,
745    id: &'a str,
746    armed: bool,
747}
748
749impl Drop for Lease<'_> {
750    fn drop(&mut self) {
751        if self.armed {
752            self.store.finish(self.id);
753        }
754    }
755}
756
757async fn run(
758    State(state): State<AppState>,
759    Extension(who): Extension<Principal>,
760    Path(id): Path<String>,
761    Json(request): Json<Js>,
762) -> Response {
763    let Some(cypher) = request.get("cypher").and_then(Js::as_str).map(String::from) else {
764        return err_response("missing cypher");
765    };
766    let params = match params_from_json(request.get("params")) {
767        Ok(p) => p,
768        Err(e) => return err_response(e),
769    };
770    let mut body = match state.txs.checkout(&id, &who.0) {
771        Ok(body) => body,
772        Err(e) => return e.into_response(),
773    };
774    // Nothing awaits between the checkout and this spawn. The statement and
775    // the hand-back both run inside the blocking task, which finishes even if
776    // this future is dropped (client gone, 408, load shed), so the slot
777    // cannot be stranded checked out.
778    let store = state.txs.clone();
779    let joined = tokio::task::spawn_blocking(move || {
780        let mut lease = Lease {
781            store: &store,
782            id: &id,
783            armed: true,
784        };
785        let out = execute(&mut body, &cypher, &params);
786        let handed_back = store.checkin(&id, body, out.is_ok());
787        lease.armed = false;
788        (out, handed_back)
789    })
790    .await;
791    let Ok((out, handed_back)) = joined else {
792        return err_response("statement task panicked; transaction rolled back");
793    };
794    if let Err(e) = handed_back {
795        return e.into_response();
796    }
797    match out {
798        Ok(rs) => json_ok(result_set_json(&rs)),
799        Err(e) => e.into_response(),
800    }
801}
802
803async fn commit(
804    State(state): State<AppState>,
805    Extension(who): Extension<Principal>,
806    Path(id): Path<String>,
807) -> Response {
808    let body = match state.txs.checkout(&id, &who.0) {
809        Ok(body) => body,
810        Err(e) => return e.into_response(),
811    };
812    let store = state.txs.clone();
813    let joined = tokio::task::spawn_blocking(move || {
814        // The transaction is consumed whatever the outcome, even if the
815        // request that asked is gone.
816        let _lease = Lease {
817            store: &store,
818            id: &id,
819            armed: true,
820        };
821        match body {
822            TxBody::Full(tx) => tx.commit(),
823            // A scoped transaction wrote nothing: its bookmark is the commit it read.
824            TxBody::Scoped { snapshot, .. } => Ok(snapshot.commit_seq()),
825        }
826    })
827    .await;
828    match joined {
829        Ok(Ok(seq)) => json_ok(json!({
830            "bookmark": seq,
831            "bookmark_text": bookmark_text(seq),
832        })),
833        Ok(Err(conflict @ GraphError::CasConflict { .. })) => (
834            StatusCode::CONFLICT,
835            Json(json!({"error": conflict.to_string(), "code": "conflict"})),
836        )
837            .into_response(),
838        Ok(Err(e)) => graph_err(e),
839        Err(_) => err_response("commit task panicked"),
840    }
841}
842
843/// `POST /tx/{id}/rollback` and `DELETE /tx/{id}`.
844async fn rollback(
845    State(state): State<AppState>,
846    Extension(who): Extension<Principal>,
847    Path(id): Path<String>,
848) -> Response {
849    match state.txs.checkout(&id, &who.0) {
850        Ok(body) => {
851            state.txs.finish(&id);
852            drop(body);
853            json_ok(json!({"rolled_back": true}))
854        }
855        Err(e) => e.into_response(),
856    }
857}
858
859#[cfg(test)]
860mod tests {
861    use super::*;
862    use mushroomdb::SharedDb;
863
864    fn store(max_open: usize, idle_ms: u64) -> TxStore {
865        TxStore::new(TxLimits {
866            max_open,
867            idle_timeout: Duration::from_millis(idle_ms),
868            max_lifetime: Duration::from_secs(300),
869        })
870    }
871
872    fn body() -> (TxBody, tempfile::TempDir) {
873        let dir = tempfile::tempdir().unwrap();
874        let db = SharedDb::open(dir.path()).unwrap();
875        (TxBody::Full(Box::new(db.begin_transaction())), dir)
876    }
877
878    /// QL-CL (row 161): a role-scoped transaction holds the instant it began
879    /// at for `.transaction()`, in every statement.
880    #[test]
881    fn a_scoped_transaction_reads_one_transaction_instant() {
882        const BEGAN: i64 = 1_700_000_000_000_000;
883        let dir = tempfile::tempdir().unwrap();
884        let db = SharedDb::open(dir.path()).unwrap();
885        let mut body = TxBody::Scoped {
886            snapshot: db.reader(),
887            mask: mushroomdb::NodeMask::from_ids([]),
888            read_tier_key: false,
889            clock_micros: BEGAN,
890        };
891        let q = "RETURN datetime.transaction() = datetime.fromepoch(1700000000, 0) AS began, \
892                 datetime.statement() = datetime.fromepoch(1700000000, 0) AS stmt";
893        for _ in 0..2 {
894            let Ok(rs) = execute(&mut body, q, &BTreeMap::new()) else {
895                panic!("the statement failed")
896            };
897            assert_eq!(rs.get(0, "began"), Some(&mushroomdb::Value::Bool(true)));
898            // The statement's own instant is the clock's, not BEGIN's.
899            assert_eq!(rs.get(0, "stmt"), Some(&mushroomdb::Value::Bool(false)));
900        }
901    }
902
903    #[test]
904    fn limits_default_and_parse() {
905        assert_eq!(TxLimits::from_values(None, None, None), TxLimits::default());
906        let l = TxLimits::from_values(Some("7"), Some(" 5 "), Some("9"));
907        assert_eq!(
908            (l.max_open, l.idle_timeout, l.max_lifetime),
909            (7, Duration::from_secs(5), Duration::from_secs(9))
910        );
911        // Zero, text and negative numbers keep the default.
912        for bad in ["0", "x", "-1", ""] {
913            assert_eq!(
914                TxLimits::from_values(Some(bad), Some(bad), Some(bad)),
915                TxLimits::default(),
916                "{bad:?}"
917            );
918        }
919    }
920
921    #[test]
922    fn iso8601_formats_known_instants() {
923        let at = |secs: u64, millis: u32| UNIX_EPOCH + Duration::new(secs, millis * 1_000_000);
924        assert_eq!(iso8601(at(0, 0)), "1970-01-01T00:00:00.000Z");
925        assert_eq!(iso8601(at(951_782_400, 5)), "2000-02-29T00:00:00.005Z");
926        assert_eq!(iso8601(at(1_700_000_000, 999)), "2023-11-14T22:13:20.999Z");
927    }
928
929    #[test]
930    fn a_second_checkout_is_busy_until_checkin() {
931        let s = store(10, 60_000);
932        let (b, _dir) = body();
933        let (id, _) = s.begin("alice".into(), b).unwrap();
934        let held = s.checkout(&id, "alice").unwrap();
935        assert_eq!(s.checkout(&id, "alice").err(), Some(TxError::Busy));
936        // Busy is not a reason to tell a stranger anything else.
937        assert_eq!(s.checkout(&id, "mallory").err(), Some(TxError::NotFound));
938        s.checkin(&id, held, true).unwrap();
939        assert!(s.checkout(&id, "alice").is_ok());
940    }
941
942    #[test]
943    fn begin_refuses_beyond_the_limit_and_counts_only_live() {
944        let s = store(1, 60_000);
945        let (b1, _d1) = body();
946        let (id, _) = s.begin("a".into(), b1).unwrap();
947        let (b2, _d2) = body();
948        assert_eq!(s.begin("a".into(), b2).err(), Some(TxError::TooMany(1)));
949        s.finish(&id);
950        let (b3, _d3) = body();
951        assert!(s.begin("a".into(), b3).is_ok());
952    }
953
954    #[test]
955    fn reap_rolls_back_idle_expired_and_remembers_the_owner() {
956        let s = store(10, 100);
957        let (b, _dir) = body();
958        let (id, _) = s.begin("alice".into(), b).unwrap();
959        assert_eq!(s.reap(Instant::now()), 0);
960        assert_eq!(s.reap(Instant::now() + Duration::from_secs(1)), 1);
961        assert_eq!(s.open_count(), 0);
962        assert_eq!(
963            s.checkout(&id, "alice").err(),
964            Some(TxError::Expired(Expiry::Idle))
965        );
966        assert_eq!(s.checkout(&id, "mallory").err(), Some(TxError::NotFound));
967    }
968
969    #[test]
970    fn a_checked_out_transaction_is_not_reaped() {
971        let s = store(10, 100);
972        let (b, _dir) = body();
973        let (id, _) = s.begin("alice".into(), b).unwrap();
974        let held = s.checkout(&id, "alice").unwrap();
975        assert_eq!(s.reap(Instant::now() + Duration::from_secs(1)), 0);
976        s.checkin(&id, held, true).unwrap();
977        assert_eq!(s.info(&id, "alice").unwrap().1, 1);
978    }
979
980    #[test]
981    fn info_is_busy_while_a_request_holds_the_transaction() {
982        let s = store(10, 60_000);
983        let (b, _dir) = body();
984        let (id, _) = s.begin("alice".into(), b).unwrap();
985        let held = s.checkout(&id, "alice").unwrap();
986        assert_eq!(s.info(&id, "alice").err(), Some(TxError::Busy));
987        // Ownership is checked first: a stranger still hears 404.
988        assert_eq!(s.info(&id, "mallory").err(), Some(TxError::NotFound));
989        s.checkin(&id, held, true).unwrap();
990        assert_eq!(s.info(&id, "alice").unwrap().1, 1);
991        // A commit consumes it: busy while it runs, gone after.
992        let held = s.checkout(&id, "alice").unwrap();
993        assert_eq!(s.info(&id, "alice").err(), Some(TxError::Busy));
994        s.finish(&id);
995        drop(held);
996        assert_eq!(s.info(&id, "alice").err(), Some(TxError::NotFound));
997    }
998
999    #[test]
1000    fn close_rolls_back_and_refuses_begin() {
1001        let s = store(10, 60_000);
1002        let (b1, _d1) = body();
1003        let (idle, _) = s.begin("a".into(), b1).unwrap();
1004        let (b2, _d2) = body();
1005        let (busy, _) = s.begin("a".into(), b2).unwrap();
1006        let held = s.checkout(&busy, "a").unwrap();
1007        assert_eq!(s.close(), 1);
1008        assert_eq!(s.checkout(&idle, "a").err(), Some(TxError::NotFound));
1009        let (b3, _d3) = body();
1010        assert_eq!(s.begin("a".into(), b3).err(), Some(TxError::Closed));
1011        // The one a request held is rolled back when that request ends.
1012        assert_eq!(s.checkin(&busy, held, true).err(), Some(TxError::Closed));
1013        assert_eq!(s.open_count(), 0);
1014    }
1015
1016    #[test]
1017    fn the_reaper_thread_expires_idle_transactions() {
1018        let s = store(10, 50);
1019        let (b, _dir) = body();
1020        let (_id, _) = s.begin("a".into(), b).unwrap();
1021        let deadline = Instant::now() + Duration::from_secs(5);
1022        while s.open_count() > 0 && Instant::now() < deadline {
1023            std::thread::sleep(Duration::from_millis(20));
1024        }
1025        assert_eq!(s.open_count(), 0);
1026    }
1027
1028    #[test]
1029    fn the_lifetime_ends_a_transaction_that_keeps_being_used() {
1030        let s = TxStore::new(TxLimits {
1031            max_open: 10,
1032            idle_timeout: Duration::from_secs(60),
1033            max_lifetime: Duration::from_secs(100),
1034        });
1035        let (b, _dir) = body();
1036        let (id, expires) = s.begin("a".into(), b).unwrap();
1037        // The reported expiry is the nearer of the two bounds.
1038        assert!(expires <= SystemTime::now() + Duration::from_secs(61));
1039        let held = s.checkout(&id, "a").unwrap();
1040        s.checkin(&id, held, true).unwrap();
1041        // Past the lifetime it goes, even while a request is using it.
1042        let held = s.checkout(&id, "a").unwrap();
1043        assert_eq!(s.reap(Instant::now() + Duration::from_secs(101)), 1);
1044        assert_eq!(
1045            s.checkin(&id, held, true).err(),
1046            Some(TxError::Expired(Expiry::Lifetime))
1047        );
1048        assert_eq!(
1049            s.checkout(&id, "a").err(),
1050            Some(TxError::Expired(Expiry::Lifetime))
1051        );
1052    }
1053
1054    #[test]
1055    fn an_idle_expiry_noticed_after_the_lifetime_is_still_idle() {
1056        let s = TxStore::new(TxLimits {
1057            max_open: 10,
1058            idle_timeout: Duration::from_secs(10),
1059            max_lifetime: Duration::from_secs(100),
1060        });
1061        let (b, _dir) = body();
1062        let (id, _) = s.begin("a".into(), b).unwrap();
1063        // Idle from 10 s; the reap that notices it runs only at 150 s.
1064        assert_eq!(s.reap(Instant::now() + Duration::from_secs(150)), 1);
1065        assert_eq!(
1066            s.checkout(&id, "a").err(),
1067            Some(TxError::Expired(Expiry::Idle))
1068        );
1069    }
1070
1071    #[test]
1072    fn a_lifetime_expiry_is_lifetime_when_the_idle_deadline_lies_beyond_it() {
1073        let s = TxStore::new(TxLimits {
1074            max_open: 10,
1075            idle_timeout: Duration::from_secs(60),
1076            max_lifetime: Duration::from_secs(30),
1077        });
1078        let (b, _dir) = body();
1079        let (id, _) = s.begin("a".into(), b).unwrap();
1080        assert_eq!(s.reap(Instant::now() + Duration::from_secs(31)), 1);
1081        assert_eq!(
1082            s.checkout(&id, "a").err(),
1083            Some(TxError::Expired(Expiry::Lifetime))
1084        );
1085    }
1086
1087    #[test]
1088    fn a_slot_whose_request_was_lost_is_freed_at_the_lifetime() {
1089        let s = TxStore::new(TxLimits {
1090            max_open: 1,
1091            idle_timeout: Duration::from_secs(60),
1092            max_lifetime: Duration::from_secs(100),
1093        });
1094        let (b, _dir) = body();
1095        let (id, _) = s.begin("a".into(), b).unwrap();
1096        // Checked out and never returned.
1097        drop(s.checkout(&id, "a").unwrap());
1098        assert_eq!(s.checkout(&id, "a").err(), Some(TxError::Busy));
1099        assert_eq!(s.reap(Instant::now() + Duration::from_secs(101)), 1);
1100        let (b2, _d2) = body();
1101        assert!(s.begin("a".into(), b2).is_ok());
1102    }
1103
1104    #[test]
1105    fn has_room_follows_the_limit_and_close() {
1106        let s = store(1, 60_000);
1107        assert_eq!(s.has_room(), Ok(()));
1108        let (b, _dir) = body();
1109        let (id, _) = s.begin("a".into(), b).unwrap();
1110        assert_eq!(s.has_room(), Err(TxError::TooMany(1)));
1111        s.finish(&id);
1112        assert_eq!(s.has_room(), Ok(()));
1113        s.close();
1114        assert_eq!(s.has_room(), Err(TxError::Closed));
1115    }
1116
1117    #[test]
1118    fn bookmark_text_is_the_form_a_bolt_commit_returns() {
1119        // The same literal the Bolt tests pin.
1120        assert_eq!(bookmark_text(7), "mushroomdb:commit:7");
1121    }
1122}