#![cfg(all(
feature = "armour",
feature = "schema",
feature = "typed-tree",
feature = "rapira-codec"
))]
use std::sync::Arc;
use std::time::Duration;
use armdb::armour::{Db, WorkerIntervals};
use armdb::{Config, FixedConfig, NoHook, Projected, RapiraCodec, UniqueIndex, UniqueTargets};
use armour_core::{Fuid, GetType};
use rapira::Rapira;
armour_core::const_hasher_or!(user_id, "USR", "dev-key-user_id", brand = "user");
type UserId = Fuid<user_id::Hasher>;
fn id(n: u64) -> UserId {
Fuid::from_be_bytes(n.to_be_bytes())
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
zerocopy::KnownLayout,
)]
#[repr(C)]
pub struct UsernameKey([u8; 24]);
armdb::impl_key_zerocopy!(
UsernameKey,
armour_core::KeyScheme::Typed(&[armour_core::KeyType::Array(24)])
);
impl UsernameKey {
fn new(s: &str) -> Self {
let mut b = [0u8; 24];
b[..s.len()].copy_from_slice(s.as_bytes());
Self(b)
}
}
armdb::unique_index!(
Usernames,
key = UsernameKey,
name = "unique_reservation_usernames"
);
fn open_usernames(db: &Arc<Db>) -> Arc<UniqueIndex<Usernames>> {
db.open_unique_index::<Usernames>(FixedConfig::test())
.expect("open usernames namespace")
}
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
struct User {
username: Option<String>,
avatar: u32,
}
impl armdb::CollectionMeta for User {
type SelfId = UserId;
const NAME: &'static str = "unique_reservation_users";
}
type UserHook = Projected<UserId, User, NoHook>;
fn claim_hook_user(usernames: &Arc<UniqueIndex<Usernames>>) -> UserHook {
let targets = UniqueTargets::new().with(usernames);
let ns = Arc::clone(usernames);
Projected::<UserId, User>::new(armdb::IndexTargets::new(), |_, _, _| Ok(())).and_unique(
targets,
move |_k, v: &User, sink| {
if let Some(u) = &v.username {
sink.claim(&ns, UsernameKey::new(u))?;
}
Ok(())
},
)
}
type Coll = armdb::typed_map!(User, RapiraCodec, UserHook);
armour_core::const_hasher_or!(session_id, "SESS", "dev-key-session_id", brand = "session");
type SessionId = Fuid<session_id::Hasher>;
fn sid(n: u64) -> SessionId {
Fuid::from_be_bytes(n.to_be_bytes())
}
fn take_err<T>(r: armdb::DbResult<T>) -> armdb::DbError {
match r {
Ok(_) => panic!("expected an Err, got Ok"),
Err(e) => e,
}
}
#[derive(Clone, Debug, PartialEq, GetType, Rapira)]
struct Session {
active: bool,
}
impl armdb::CollectionMeta for Session {
type SelfId = SessionId;
const NAME: &'static str = "unique_reservation_sessions";
}
type SessionColl = armdb::typed_map!(Session, RapiraCodec);
fn open_sessions(db: &Arc<Db>) -> Arc<SessionColl> {
db.open_typed_map::<Session, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap()
}
fn assert_deadline_near(deadline: u64, now_before_ms: u64, ttl_ms: u64) {
let target = now_before_ms + ttl_ms;
let window_ms = 10_000;
assert!(
deadline + window_ms >= target && deadline <= target + window_ms,
"deadline {deadline} not within {window_ms}ms of now_before({now_before_ms}) + ttl({ttl_ms}) = {target}"
);
}
fn fixture() -> (
tempfile::TempDir,
Arc<Db>,
Arc<UniqueIndex<Usernames>>,
Arc<Coll>,
) {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let usernames = open_usernames(&db);
let hook = claim_hook_user(&usernames);
let users = db
.open_typed_map::<User, RapiraCodec, _>(Config::test(), hook, &[])
.unwrap();
(dir, db, usernames, users)
}
fn workers(interval_ms: u64, budget: usize) -> WorkerIntervals {
WorkerIntervals {
compaction: None,
flush: None,
ttl_sweep: Some(Duration::from_millis(interval_ms)),
ttl_budget: budget,
}
}
fn fixture_with_fast_sweeper() -> (
tempfile::TempDir,
Arc<Db>,
Arc<UniqueIndex<Usernames>>,
Arc<Coll>,
) {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_with_workers(dir.path(), workers(20, 4096)).unwrap());
let usernames = open_usernames(&db);
let hook = claim_hook_user(&usernames);
let users = db
.open_typed_map::<User, RapiraCodec, _>(Config::test(), hook, &[])
.unwrap();
(dir, db, usernames, users)
}
#[test]
fn reservation_holds_the_key_and_is_taken_over_by_its_own_source_row() {
let (_dir, db, usernames, users) = fixture();
let now_before_ms = armdb::testing::now_ms();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
let owner = usernames.owner_of(&UsernameKey::new("rail")).unwrap();
assert!(owner.is_reservation());
assert_eq!(owner.belongs_to::<User>(), Some(id(1)));
assert_deadline_near(owner.expires_at().unwrap(), now_before_ms, 900_000);
assert!(matches!(
db.tx()
.put(
&users,
&id(2),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap_err(),
armdb::DbError::UniqueViolation { .. }
));
db.tx()
.put(
&users,
&id(1),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap();
assert!(
!usernames
.owner_of(&UsernameKey::new("rail"))
.unwrap()
.is_reservation()
);
}
#[test]
fn release_is_idempotent_and_refuses_a_foreign_owner() {
let (_dir, db, usernames, users) = fixture();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
assert!(matches!(
db.tx()
.release(&usernames, UsernameKey::new("rail"), &users, &id(2))
.unwrap()
.commit()
.unwrap_err(),
armdb::DbError::UniqueViolation { .. }
));
assert!(usernames.contains(&UsernameKey::new("rail")));
db.tx()
.release(&usernames, UsernameKey::new("rail"), &users, &id(1))
.unwrap()
.commit()
.unwrap();
assert!(!usernames.contains(&UsernameKey::new("rail")));
db.tx()
.release(&usernames, UsernameKey::new("rail"), &users, &id(1))
.unwrap()
.commit()
.unwrap();
}
#[test]
fn reservation_deadline_conversion_is_bounded() {
let (_dir, db, usernames, users) = fixture();
assert!(matches!(
db.tx().reserve(
&usernames,
UsernameKey::new("a"),
&users,
&id(1),
Duration::ZERO
),
Err(armdb::DbError::UniqueReservationInvalid {
reason: "zero duration",
..
})
));
let now_before_ms = armdb::testing::now_ms();
db.tx()
.reserve(
&usernames,
UsernameKey::new("b"),
&users,
&id(1),
Duration::from_micros(1),
)
.unwrap()
.commit()
.unwrap();
let deadline = usernames
.owner_of(&UsernameKey::new("b"))
.unwrap()
.expires_at()
.unwrap();
assert!(
deadline >= now_before_ms,
"deadline {deadline} must not land before now_before_ms {now_before_ms}"
);
assert!(
deadline <= now_before_ms + 60_000,
"deadline {deadline} is implausibly far from now_before_ms {now_before_ms} for a 1 ms ttl"
);
assert!(matches!(
db.tx().reserve(
&usernames,
UsernameKey::new("c"),
&users,
&id(1),
Duration::from_secs(u64::MAX),
),
Err(armdb::DbError::UniqueReservationInvalid {
reason: "deadline overflow",
..
})
));
}
#[test]
fn reservation_deadline_overflow_at_resolve_time_is_rejected() {
let (_dir, db, usernames, users) = fixture();
let huge = Duration::from_millis(u64::MAX - 1_000);
let err = db
.tx()
.reserve(&usernames, UsernameKey::new("huge"), &users, &id(1), huge)
.unwrap()
.commit()
.unwrap_err();
assert!(
matches!(
err,
armdb::DbError::UniqueReservationInvalid {
reason: "deadline overflow",
..
}
),
"{err}"
);
assert!(!usernames.contains(&UsernameKey::new("huge")));
}
#[test]
fn expired_reservation_is_swept_and_the_key_becomes_free() {
let (_dir, db, usernames, users) = fixture();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_millis(1),
)
.unwrap()
.commit()
.unwrap();
assert!(
usernames.contains(&UsernameKey::new("rail")),
"the reservation must actually have been written before its removal proves anything"
);
std::thread::sleep(Duration::from_millis(50));
let deleted = db.sweep_expired_now().unwrap();
assert!(
deleted >= 1,
"sweep_expired_now must have deleted the expired reservation"
);
assert!(!usernames.contains(&UsernameKey::new("rail")));
db.tx()
.put(
&users,
&id(2),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap();
}
#[test]
fn a_permanent_row_is_never_swept() {
let (_dir, db, usernames, users) = fixture_with_fast_sweeper();
db.tx()
.put(
&users,
&id(1),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap();
std::thread::sleep(Duration::from_millis(300));
assert!(usernames.contains(&UsernameKey::new("rail")));
}
#[test]
fn same_transaction_reserve_then_claim_promotes_to_permanent() {
let (_dir, db, usernames, users) = fixture();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.put(
&users,
&id(1),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap();
let owner = usernames.owner_of(&UsernameKey::new("rail")).unwrap();
assert!(
!owner.is_reservation(),
"the source row's own claim must promote the reservation to permanent"
);
assert_eq!(owner.belongs_to::<User>(), Some(id(1)));
}
#[test]
fn same_transaction_claim_then_reserve_still_promotes_to_permanent() {
let (_dir, db, usernames, users) = fixture();
db.tx()
.put(
&users,
&id(1),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
let owner = usernames.owner_of(&UsernameKey::new("rail")).unwrap();
assert!(
!owner.is_reservation(),
"builder call order must not change the outcome: the row must still end up permanent"
);
assert_eq!(owner.belongs_to::<User>(), Some(id(1)));
}
#[test]
fn same_transaction_reserve_and_a_different_owners_claim_conflict() {
let (_dir, db, usernames, users) = fixture();
let err = db
.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.put(
&users,
&id(2),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap_err();
assert!(
matches!(err, armdb::DbError::UniqueViolation { .. }),
"{err}"
);
assert_eq!(usernames.owner_of(&UsernameKey::new("rail")), None);
assert!(users.get(&id(1)).is_none());
assert!(users.get(&id(2)).is_none());
}
#[test]
fn reserve_on_a_key_i_already_own_permanently_does_not_demote_it() {
let (_dir, db, usernames, users) = fixture();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
db.tx()
.put(
&users,
&id(1),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap();
assert!(
!usernames
.owner_of(&UsernameKey::new("rail"))
.unwrap()
.is_reservation(),
"fixture must actually be permanent before the retry, or this test proves nothing"
);
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
let owner = usernames.owner_of(&UsernameKey::new("rail")).unwrap();
assert!(
!owner.is_reservation(),
"a permanent claim must outrank a reservation: the retried reserve must not demote it"
);
assert_eq!(owner.belongs_to::<User>(), Some(id(1)));
}
#[test]
fn reserve_and_release_refuse_a_source_that_never_declared_the_namespace() {
let (_dir, db, usernames, _users) = fixture();
let sessions = open_sessions(&db);
let err = take_err(db.tx().reserve(
&usernames,
UsernameKey::new("rail"),
&sessions,
&sid(1),
Duration::from_secs(900),
));
assert!(
matches!(
err,
armdb::DbError::UndeclaredUniqueTarget {
namespace: "unique_reservation_usernames"
}
),
"{err}"
);
let err = take_err(
db.tx()
.release(&usernames, UsernameKey::new("rail"), &sessions, &sid(1)),
);
assert!(
matches!(
err,
armdb::DbError::UndeclaredUniqueTarget {
namespace: "unique_reservation_usernames"
}
),
"{err}"
);
assert!(!usernames.contains(&UsernameKey::new("rail")));
}
#[test]
fn reserve_refreshes_the_deadline_through_the_real_stack() {
let (_dir, db, usernames, users) = fixture();
let now1 = armdb::testing::now_ms();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(60),
)
.unwrap()
.commit()
.unwrap();
let deadline1 = usernames
.owner_of(&UsernameKey::new("rail"))
.unwrap()
.expires_at()
.unwrap();
assert_deadline_near(deadline1, now1, 60_000);
let now2 = armdb::testing::now_ms();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
let deadline2 = usernames
.owner_of(&UsernameKey::new("rail"))
.unwrap()
.expires_at()
.unwrap();
assert_deadline_near(deadline2, now2, 900_000);
assert!(
deadline2 > deadline1,
"the second reserve must move the deadline forward"
);
}
#[test]
fn release_then_reserve_the_same_key_in_one_transaction() {
let (_dir, db, usernames, users) = fixture();
db.tx()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(1),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
db.tx()
.release(&usernames, UsernameKey::new("rail"), &users, &id(1))
.unwrap()
.reserve(
&usernames,
UsernameKey::new("rail"),
&users,
&id(2),
Duration::from_secs(900),
)
.unwrap()
.commit()
.unwrap();
let owner = usernames.owner_of(&UsernameKey::new("rail")).unwrap();
assert!(owner.is_reservation());
assert_eq!(owner.belongs_to::<User>(), Some(id(2)));
}
#[test]
fn release_of_a_permanent_claim_deletes_only_the_namespace_row() {
let (_dir, db, usernames, users) = fixture();
db.tx()
.put(
&users,
&id(1),
&User {
username: Some("rail".into()),
avatar: 1,
},
)
.unwrap()
.commit()
.unwrap();
assert!(
!usernames
.owner_of(&UsernameKey::new("rail"))
.unwrap()
.is_reservation(),
"fixture must be a permanent claim, or this test cannot tell release-of-permanent \
from release-of-reservation"
);
db.tx()
.release(&usernames, UsernameKey::new("rail"), &users, &id(1))
.unwrap()
.commit()
.unwrap();
assert!(!usernames.contains(&UsernameKey::new("rail")));
assert_eq!(
users.get(&id(1)).and_then(|u| u.username.clone()),
Some("rail".into()),
"release only touches the namespace row — the source row still carries the value"
);
}