1use 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
81pub const TX_TIMEOUT_ENV: &str = "MUSHROOMDB_TX_TIMEOUT_SECS";
83pub const TX_MAX_LIFETIME_ENV: &str = "MUSHROOMDB_TX_MAX_LIFETIME_SECS";
85pub 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;
91const MAX_TOMBSTONES: usize = 10_000;
93const BOOKMARK_PREFIX: &str = "mushroomdb:commit:";
95
96fn bookmark_text(seq: u64) -> String {
98 format!("{BOOKMARK_PREFIX}{seq}")
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub struct TxLimits {
104 pub max_open: usize,
106 pub idle_timeout: Duration,
108 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 pub fn from_env() -> Self {
128 Self::from_lookup(|name| std::env::var(name).ok())
129 }
130
131 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
169pub(crate) enum TxBody {
171 Full(Box<Transaction>),
173 RoleOwned {
176 tx: Box<Transaction>,
177 role: String,
179 scope: WriteScope,
181 mask: NodeMask,
183 namespaces: Option<Vec<String>>,
185 },
186 Scoped {
188 snapshot: ReaderSnapshot,
189 mask: NodeMask,
190 read_tier_key: bool,
192 clock_micros: i64,
194 },
195}
196
197struct Slot {
198 owner: String,
199 born: Instant,
201 deadline: Instant,
202 deadline_wall: SystemTime,
203 op_count: usize,
204 body: Option<TxBody>,
206}
207
208struct Tombstone {
209 owner: String,
210 expired_at: Instant,
211 why: Expiry,
212}
213
214#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216pub(crate) enum Expiry {
217 Idle,
219 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#[derive(Debug, PartialEq, Eq)]
238pub(crate) enum TxError {
239 NotFound,
241 Expired(Expiry),
243 Busy,
245 Closed,
247 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 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#[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
299fn 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 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 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 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 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 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 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 None => Err(was_expired.map_or(TxError::NotFound, TxError::Expired)),
423 }
424 }
425
426 pub(crate) fn finish(&self, id: &str) {
428 let slot = lock(&self.inner.state).open.remove(id);
429 drop(slot);
430 }
431
432 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 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 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 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 pub(crate) fn open_count(&self) -> usize {
488 lock(&self.inner.state).open.len()
489 }
490
491 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 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 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 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 (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 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 tracing::error!("could not start the transaction reaper: {e}");
600 }
601 });
602 }
603}
604
605#[derive(Clone, Debug)]
609struct Principal(String);
610
611async 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
629fn 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 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 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 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 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 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 AuthIdentity::Anonymous => return forbidden("a credential is required"),
711 AuthIdentity::Full | AuthIdentity::Write => {
712 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
740enum RunError {
742 Engine(GraphError),
743 Bad(String),
745 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
759fn 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
801struct 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 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, ¶ms);
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 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 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
904async 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 #[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 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 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 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 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 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 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 assert!(expires <= SystemTime::now() + Duration::from_secs(61));
1100 let held = s.checkout(&id, "a").unwrap();
1101 s.checkin(&id, held, true).unwrap();
1102 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 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 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 assert_eq!(bookmark_text(7), "mushroomdb:commit:7");
1182 }
1183}