1use 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
74pub const TX_TIMEOUT_ENV: &str = "MUSHROOMDB_TX_TIMEOUT_SECS";
76pub const TX_MAX_LIFETIME_ENV: &str = "MUSHROOMDB_TX_MAX_LIFETIME_SECS";
78pub 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;
84const MAX_TOMBSTONES: usize = 10_000;
86const BOOKMARK_PREFIX: &str = "mushroomdb:commit:";
88
89fn bookmark_text(seq: u64) -> String {
91 format!("{BOOKMARK_PREFIX}{seq}")
92}
93
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub struct TxLimits {
97 pub max_open: usize,
99 pub idle_timeout: Duration,
101 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 pub fn from_env() -> Self {
121 Self::from_lookup(|name| std::env::var(name).ok())
122 }
123
124 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
162pub(crate) enum TxBody {
164 Full(Box<Transaction>),
166 Scoped {
168 snapshot: ReaderSnapshot,
169 mask: NodeMask,
170 read_tier_key: bool,
172 clock_micros: i64,
174 },
175}
176
177struct Slot {
178 owner: String,
179 born: Instant,
181 deadline: Instant,
182 deadline_wall: SystemTime,
183 op_count: usize,
184 body: Option<TxBody>,
186}
187
188struct Tombstone {
189 owner: String,
190 expired_at: Instant,
191 why: Expiry,
192}
193
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
196pub(crate) enum Expiry {
197 Idle,
199 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#[derive(Debug, PartialEq, Eq)]
218pub(crate) enum TxError {
219 NotFound,
221 Expired(Expiry),
223 Busy,
225 Closed,
227 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 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#[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
279fn 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 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 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 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 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 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 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 None => Err(was_expired.map_or(TxError::NotFound, TxError::Expired)),
403 }
404 }
405
406 pub(crate) fn finish(&self, id: &str) {
408 let slot = lock(&self.inner.state).open.remove(id);
409 drop(slot);
410 }
411
412 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 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 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 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 pub(crate) fn open_count(&self) -> usize {
468 lock(&self.inner.state).open.len()
469 }
470
471 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 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 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 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 (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 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 tracing::error!("could not start the transaction reaper: {e}");
580 }
581 });
582 }
583}
584
585#[derive(Clone, Debug)]
589struct Principal(String);
590
591async 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
609fn 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 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 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 AuthIdentity::Anonymous => return forbidden("a credential is required"),
660 AuthIdentity::Full | AuthIdentity::Write => {
661 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
689enum RunError {
691 Engine(GraphError),
692 Bad(String),
694 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
708fn 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
741struct 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 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, ¶ms);
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 let _lease = Lease {
817 store: &store,
818 id: &id,
819 armed: true,
820 };
821 match body {
822 TxBody::Full(tx) => tx.commit(),
823 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
843async 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 #[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 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 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 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 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 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 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 assert!(expires <= SystemTime::now() + Duration::from_secs(61));
1039 let held = s.checkout(&id, "a").unwrap();
1040 s.checkin(&id, held, true).unwrap();
1041 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 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 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 assert_eq!(bookmark_text(7), "mushroomdb:commit:7");
1121 }
1122}