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