use std::sync::Arc;
use crate::error::{DbError, DbResult};
use crate::key::Key;
use crate::txlog::record::{OpKind, TxOp, TxRecord, VersionTag};
use crate::txn::participant::{DeferredReplay, PlannedOp, TxLocked, TxParticipant};
use crate::txn_depth::ATOMIC_DEPTH;
use crate::upsert::{IntoUpsert, Upsert};
pub(crate) mod checkpoint;
pub(crate) mod families;
pub(crate) mod held;
pub(crate) mod participant;
pub(crate) mod projection_commit;
pub(crate) mod replay;
#[cfg(any(test, feature = "test-faults"))]
pub mod failpoints {
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering};
pub const NONE: u8 = 0;
pub const AFTER_LOG_APPEND: u8 = 1; pub const MID_APPLY: u8 = 2; pub const AFTER_APPLY: u8 = 3; pub static STAGE: AtomicU8 = AtomicU8::new(NONE);
pub static APPLY_FAIL_AT: AtomicUsize = AtomicUsize::new(usize::MAX);
static APPLY_FAIL_HELD: AtomicBool = AtomicBool::new(false);
static LOG_SYNC_FAIL_HELD: AtomicBool = AtomicBool::new(false);
struct Claim(&'static AtomicBool);
impl Claim {
fn take(held: &'static AtomicBool, name: &str) -> Self {
assert!(
held.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok(),
"{name} is already armed by a live guard. Failpoints are \
process-global, so only one guard at a time is meaningful — run \
failpoint tests under `cargo nextest`, which gives each test its \
own process."
);
Self(held)
}
}
impl Drop for Claim {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
#[must_use = "the failpoint disarms as soon as the guard is dropped"]
pub struct ApplyFailGuard(Claim);
impl ApplyFailGuard {
pub fn at(index: usize) -> Self {
let claim = Claim::take(&APPLY_FAIL_HELD, "APPLY_FAIL_AT");
APPLY_FAIL_AT.store(index, Ordering::SeqCst);
Self(claim)
}
}
impl Drop for ApplyFailGuard {
fn drop(&mut self) {
APPLY_FAIL_AT.store(usize::MAX, Ordering::SeqCst);
}
}
pub static FAIL_LOG_SYNC: AtomicBool = AtomicBool::new(false);
#[must_use = "the failpoint disarms as soon as the guard is dropped"]
pub struct LogSyncFailGuard(Claim);
impl LogSyncFailGuard {
pub fn arm() -> Self {
let claim = Claim::take(&LOG_SYNC_FAIL_HELD, "FAIL_LOG_SYNC");
FAIL_LOG_SYNC.store(true, Ordering::SeqCst);
Self(claim)
}
}
impl Drop for LogSyncFailGuard {
fn drop(&mut self) {
FAIL_LOG_SYNC.store(false, Ordering::SeqCst);
}
}
#[cfg(feature = "test-faults")]
pub(crate) fn hit(stage: u8) {
if STAGE.load(Ordering::SeqCst) == stage {
unsafe { libc::_exit(9) }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_fail_guard_disarms_on_unwind() {
assert_eq!(APPLY_FAIL_AT.load(Ordering::SeqCst), usize::MAX);
let caught = std::panic::catch_unwind(|| {
let _g = ApplyFailGuard::at(0);
assert_eq!(APPLY_FAIL_AT.load(Ordering::SeqCst), 0);
panic!("boom");
});
assert!(caught.is_err());
assert_eq!(
APPLY_FAIL_AT.load(Ordering::SeqCst),
usize::MAX,
"a failed assertion must not poison the next test"
);
}
fn quietly<R>(f: impl FnOnce() -> R) -> std::thread::Result<R> {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
std::panic::set_hook(previous);
outcome
}
#[test]
fn a_second_live_guard_panics_instead_of_stealing_the_failpoint() {
let first = ApplyFailGuard::at(0);
assert!(
quietly(|| ApplyFailGuard::at(1)).is_err(),
"a second live ApplyFailGuard must panic"
);
assert_eq!(
APPLY_FAIL_AT.load(Ordering::SeqCst),
0,
"the refused guard must not have moved the first one's index"
);
let sync = LogSyncFailGuard::arm();
assert!(
quietly(LogSyncFailGuard::arm).is_err(),
"a second live LogSyncFailGuard must panic"
);
assert!(FAIL_LOG_SYNC.load(Ordering::SeqCst));
drop(first);
drop(sync);
let again = ApplyFailGuard::at(2);
assert_eq!(APPLY_FAIL_AT.load(Ordering::SeqCst), 2);
drop(again);
assert_eq!(APPLY_FAIL_AT.load(Ordering::SeqCst), usize::MAX);
assert!(!FAIL_LOG_SYNC.load(Ordering::SeqCst));
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct TxConfig {
pub durable_by_default: bool,
pub segment_bytes: u64,
pub checkpoint_bytes: u64,
pub max_ops: usize,
pub max_bytes: usize,
pub discard_pending_on_schema_mismatch: bool,
#[cfg(feature = "encryption")]
pub log_encryption_key: Option<[u8; 32]>,
}
impl Default for TxConfig {
fn default() -> Self {
Self {
durable_by_default: false,
segment_bytes: 16 << 20,
checkpoint_bytes: 64 << 20,
max_ops: 1024,
max_bytes: 8 << 20,
discard_pending_on_schema_mismatch: false,
#[cfg(feature = "encryption")]
log_encryption_key: None,
}
}
}
pub trait TxTarget: Send + Sync + 'static {
type Key: Key;
type Val;
fn tx_encode(&self, value: &Self::Val, out: &mut Vec<u8>) -> DbResult<()>;
fn tx_decode(&self, bytes: &[u8]) -> DbResult<Self::Val>;
}
type UpsertBytes = Upsert<Vec<u8>>;
pub(crate) type TxEvalFn = Box<dyn FnOnce(Option<&[u8]>) -> DbResult<UpsertBytes>>;
pub(crate) type TxGuardFn = Box<dyn FnOnce(Option<&[u8]>) -> DbResult<()>>;
pub(crate) enum Action {
Put(Vec<u8>),
Delete,
Eval(TxEvalFn),
Guard(TxGuardFn),
}
pub(crate) struct Op {
pub participant: Arc<dyn TxParticipant>,
pub key: Vec<u8>,
pub action: Action,
}
#[cfg(any(test, feature = "test-faults"))]
pub(crate) static SKIP_APPLY: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[cfg(any(test, feature = "test-faults"))]
pub(crate) static SKIP_PROJECTION_RECONCILE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[cfg(test)]
pub(crate) static APPLY_PANIC_AT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(usize::MAX);
pub struct Tx<'d> {
pub(crate) db: &'d crate::armour::Db,
pub(crate) ops: Vec<Op>,
pub(crate) durable: bool,
}
pub(crate) fn check_op_limits(current_ops: usize, max_ops: usize, key_len: usize) -> DbResult<()> {
if key_len > crate::txlog::record::MAX_KEY_LEN {
return Err(DbError::Client("transaction key exceeds 64 KiB"));
}
if current_ops >= max_ops {
return Err(DbError::Client("transaction exceeds max_ops"));
}
Ok(())
}
#[allow(private_bounds)]
impl<'d> Tx<'d> {
pub fn put<S>(mut self, collection: &Arc<S>, key: &S::Key, value: &S::Val) -> DbResult<Self>
where
S: TxTarget + TxParticipant + 'static,
{
let mut bytes = Vec::new();
collection.tx_encode(value, &mut bytes)?;
let key_bytes = key.as_bytes().to_vec();
check_op_limits(self.ops.len(), self.db.tx_config().max_ops, key_bytes.len())?;
self.ops.push(Op {
participant: collection.clone() as Arc<dyn TxParticipant>,
key: key_bytes,
action: Action::Put(bytes),
});
Ok(self)
}
pub fn delete<S>(mut self, collection: &Arc<S>, key: &S::Key) -> DbResult<Self>
where
S: TxTarget + TxParticipant + 'static,
{
let key_bytes = key.as_bytes().to_vec();
check_op_limits(self.ops.len(), self.db.tx_config().max_ops, key_bytes.len())?;
self.ops.push(Op {
participant: collection.clone() as Arc<dyn TxParticipant>,
key: key_bytes,
action: Action::Delete,
});
Ok(self)
}
pub fn upsert<S, U>(
mut self,
collection: &Arc<S>,
key: &S::Key,
f: impl FnOnce(Option<&S::Val>) -> U + 'static,
) -> DbResult<Self>
where
S: TxTarget + TxParticipant + 'static,
U: IntoUpsert<S::Val> + 'static,
{
let key_bytes = key.as_bytes().to_vec();
check_op_limits(self.ops.len(), self.db.tx_config().max_ops, key_bytes.len())?;
let coll = collection.clone();
let eval = move |stored: Option<&[u8]>| -> DbResult<Upsert<Vec<u8>>> {
let decoded = match stored {
Some(bytes) => Some(coll.tx_decode(bytes)?),
None => None,
};
match f(decoded.as_ref()).into_parts().0 {
Upsert::Put(value) => {
let mut out = Vec::new();
coll.tx_encode(&value, &mut out)?;
Ok(Upsert::Put(out))
}
Upsert::Keep => Ok(Upsert::Keep),
Upsert::Delete => Ok(Upsert::Delete),
}
};
self.ops.push(Op {
participant: collection.clone() as Arc<dyn TxParticipant>,
key: key_bytes,
action: Action::Eval(Box::new(eval)),
});
Ok(self)
}
pub fn guard<S>(
mut self,
collection: &Arc<S>,
key: &S::Key,
f: impl FnOnce(Option<&S::Val>) -> DbResult<()> + 'static,
) -> DbResult<Self>
where
S: TxTarget + TxParticipant + 'static,
{
let key_bytes = key.as_bytes().to_vec();
check_op_limits(self.ops.len(), self.db.tx_config().max_ops, key_bytes.len())?;
let coll = collection.clone();
let check = move |stored: Option<&[u8]>| -> DbResult<()> {
let decoded = match stored {
Some(bytes) => Some(coll.tx_decode(bytes)?),
None => None,
};
f(decoded.as_ref())
};
self.ops.push(Op {
participant: collection.clone() as Arc<dyn TxParticipant>,
key: key_bytes,
action: Action::Guard(Box::new(check)),
});
Ok(self)
}
pub fn durable(mut self) -> Self {
self.durable = true;
self
}
pub fn commit(self) -> DbResult<()> {
if ATOMIC_DEPTH.with(|d| d.get()) > 0 || crate::txn_depth::in_tx_commit() {
return Err(DbError::ReentrantTx);
}
if self.ops.is_empty() {
return Ok(());
}
let projection_commit::PlanOutcome {
entries,
rank0_len,
rank0_ops,
rank1_ops,
} = projection_commit::plan(self.db, self.ops)?;
for op in rank0_ops.iter().chain(rank1_ops.iter()) {
if op.participant.encrypted() && !self.db.txlog().is_encrypted() {
return Err(DbError::Config(
"an encrypted collection needs TxConfig::log_encryption_key: \
the transaction log would otherwise hold its values in the clear",
));
}
}
let commit_guard = crate::txn_depth::TxCommitGuard::enter();
let mut locked: Vec<Box<dyn TxLocked + '_>> = Vec::with_capacity(entries.len());
for entry in &entries[..rank0_len] {
match entry.participant.lock_shards(&entry.shards) {
Ok(l) => locked.push(l),
Err(e) => {
projection_commit::abort_locked(locked);
return Err(e);
}
}
}
let (mut writes, projection_inputs) =
match projection_commit::eval_rank0(&entries, &mut locked, rank0_ops) {
Ok(v) => v,
Err(e) => {
projection_commit::abort_locked(locked);
return Err(e);
}
};
let extra_shards = match projection_commit::project_rank0(
&entries,
&mut writes,
projection_inputs,
self.db.txlog().is_encrypted(),
) {
Ok(s) => s,
Err(e) => {
projection_commit::abort_locked(locked);
return Err(e);
}
};
for (offset, entry) in entries[rank0_len..].iter().enumerate() {
let entry_pos = rank0_len + offset;
let shards = merge_lock_shards(&entry.shards, extra_shards.get(&entry_pos));
match entry.participant.lock_shards(&shards) {
Ok(l) => locked.push(l),
Err(e) => {
projection_commit::abort_locked(locked);
return Err(e);
}
}
}
if let Err(e) =
projection_commit::eval_rank1(&entries, &mut locked, rank0_len, rank1_ops, &mut writes)
{
projection_commit::abort_locked(locked);
return Err(e);
}
if writes.is_empty() {
projection_commit::abort_locked(locked);
return Ok(());
}
if let Err(e) = projection_commit::admit(self.db, &entries, &writes) {
projection_commit::abort_locked(locked);
return Err(e);
}
let pre_append: DbResult<(u64, crate::txlog::Committed, Vec<VersionTag>)> = (|| {
let mut tag_slots: Vec<Option<VersionTag>> = vec![None; writes.len()];
for (pos, l) in locked.iter_mut().enumerate() {
let indices: Vec<usize> = writes
.iter()
.enumerate()
.filter(|(_, w)| w.entry == pos)
.map(|(i, _)| i)
.collect();
let planned: Vec<PlannedOp<'_>> = indices
.iter()
.map(|&i| PlannedOp {
key: &writes[i].key,
delete: writes[i].value.is_none(),
})
.collect();
let reserved = l.reserve(&planned)?;
if reserved.len() != indices.len() {
return Err(DbError::Internal("reserve returned too few tags"));
}
for (j, &wi) in indices.iter().enumerate() {
tag_slots[wi] = Some(reserved[j]);
}
}
let tags: Vec<VersionTag> = tag_slots
.into_iter()
.map(|t| t.ok_or(DbError::Internal("missing reserved tag for a write")))
.collect::<DbResult<_>>()?;
let tx_id = self.db.txlog().next_tx_id()?;
let record = build_record(tx_id, &entries, &writes, &tags);
let mut encoded_len = Vec::new();
crate::txlog::record::encode_record(&record, &mut encoded_len)?;
if encoded_len.len() > self.db.tx_config().max_bytes {
return Err(projection_commit::max_bytes_err(&writes));
}
let committed = self.db.txlog().append(&record)?;
Ok((tx_id, committed, tags))
})();
let (tx_id, committed, tags) = match pre_append {
Ok(v) => v,
Err(e) => {
for l in locked.into_iter().rev() {
l.abort();
}
return Err(e);
}
};
for l in locked.iter_mut() {
l.mark_logged();
}
let mut settle = Settle {
log: self.db.txlog(),
tx_id,
applied: false,
};
let mut sync_err = None;
if self.durable {
#[cfg(any(test, feature = "test-faults"))]
let injected = failpoints::FAIL_LOG_SYNC.load(std::sync::atomic::Ordering::SeqCst);
#[cfg(not(any(test, feature = "test-faults")))]
let injected = false;
let outcome = if injected {
Err(DbError::Internal("injected log sync failure"))
} else {
self.db.txlog().sync_upto(&committed)
};
if let Err(e) = outcome {
tracing::error!(tx_id, "txlog sync failed after append: {e}");
sync_err.get_or_insert(e);
}
}
#[cfg(any(test, feature = "test-faults"))]
if SKIP_APPLY.load(std::sync::atomic::Ordering::SeqCst) {
for l in locked.into_iter().rev() {
l.abort();
}
return Ok(());
}
#[cfg(feature = "test-faults")]
failpoints::hit(failpoints::AFTER_LOG_APPEND);
let mut apply_err = None;
let mut order: Vec<(usize, usize)> = Vec::new(); let mut per_entry_events = vec![0usize; entries.len()];
#[cfg(feature = "test-faults")]
let mut applied_writes = 0usize;
let mut failed_shards: Vec<(usize, usize)> = Vec::new();
#[cfg_attr(
not(any(test, feature = "test-faults")),
allow(clippy::unused_enumerate_index)
)]
for (_index, (w, tag)) in writes.iter().zip(tags.iter()).enumerate() {
let sid = match entries[w.entry].participant.shard_of(&w.key) {
Ok(sid) => sid,
Err(e) => {
apply_err.get_or_insert(e);
continue;
}
};
#[cfg(any(test, feature = "test-faults"))]
if _index == failpoints::APPLY_FAIL_AT.load(std::sync::atomic::Ordering::SeqCst) {
apply_err.get_or_insert(DbError::Internal("injected apply failure"));
failed_shards.push((w.entry, sid));
continue;
}
#[cfg(test)]
assert!(
_index != APPLY_PANIC_AT.load(std::sync::atomic::Ordering::SeqCst),
"injected apply panic"
);
if failed_shards.contains(&(w.entry, sid)) {
continue;
}
let outcome = match &w.value {
Some(bytes) => locked[w.entry].apply_put(&w.key, bytes, *tag),
None => locked[w.entry].apply_delete(&w.key, *tag),
};
let produced = match outcome {
Ok(n) => n,
Err(e) => {
apply_err.get_or_insert(e);
failed_shards.push((w.entry, sid));
continue;
}
};
for _ in 0..produced {
order.push((w.entry, per_entry_events[w.entry]));
per_entry_events[w.entry] += 1;
}
#[cfg(feature = "test-faults")]
{
applied_writes += 1;
}
#[cfg(feature = "test-faults")]
if applied_writes == 1 {
failpoints::hit(failpoints::MID_APPLY);
}
}
#[cfg(feature = "test-faults")]
failpoints::hit(failpoints::AFTER_APPLY);
settle.applied = apply_err.is_none();
let mut deferred: Vec<Box<dyn DeferredReplay>> = Vec::with_capacity(locked.len());
let mut finish_err = None;
let mut fenced: Vec<crate::FencedShard> = Vec::new();
for (i, l) in locked.into_iter().enumerate() {
let collection = entries[i].participant.name();
let mut local: Vec<(usize, u64)> = Vec::new();
match l.finish(&mut local) {
Ok(d) => deferred.push(d),
Err(e) => {
tracing::error!(tx_id, "releasing a transaction participant failed: {e}");
finish_err.get_or_insert(e);
}
}
fenced.extend(local.into_iter().map(|(shard, gsn)| crate::FencedShard {
collection,
shard,
gsn,
}));
}
fenced.sort_unstable_by_key(|f| (f.collection, f.shard));
drop(commit_guard);
if finish_err.is_none() {
for (entry, local) in order {
deferred[entry].replay_one(local);
}
}
drop(deferred);
drop(settle); if let Err(e) = self.db.maybe_checkpoint() {
tracing::error!(tx_id, "checkpoint after commit failed: {e}");
}
match stage_and_source(apply_err, finish_err, sync_err) {
Some((stage, source)) => Err(DbError::TxFailedAfterLog {
tx_id,
stage,
fenced,
source: Box::new(source),
}),
None => Ok(()),
}
}
}
struct Settle<'a> {
log: &'a crate::txlog::TxLog,
tx_id: u64,
applied: bool,
}
impl Drop for Settle<'_> {
fn drop(&mut self) {
if self.applied {
self.log.mark_applied(self.tx_id);
} else {
self.log.clear_in_flight(self.tx_id);
}
}
}
fn build_record(
tx_id: u64,
entries: &[projection_commit::Entry],
writes: &[projection_commit::Write],
tags: &[VersionTag],
) -> TxRecord {
let ops = writes
.iter()
.zip(tags.iter())
.map(|(w, tag)| {
let entry = &entries[w.entry];
TxOp {
collection_id: entry.id,
name: entry.participant.name().to_string(),
schema_fingerprint: entry.participant.schema_fingerprint(),
key: w.key.clone(),
kind: if w.value.is_some() {
OpKind::Put
} else {
OpKind::Delete
},
value: w.value.clone().unwrap_or_default(),
version: *tag,
}
})
.collect();
TxRecord { tx_id, ops }
}
fn stage_and_source(
apply_err: Option<DbError>,
finish_err: Option<DbError>,
sync_err: Option<DbError>,
) -> Option<(crate::TxFailStage, DbError)> {
apply_err
.map(|e| (crate::TxFailStage::Apply, e))
.or_else(|| finish_err.map(|e| (crate::TxFailStage::Finish, e)))
.or_else(|| sync_err.map(|e| (crate::TxFailStage::Sync, e)))
}
fn merge_lock_shards(base: &[usize], extra: Option<&Vec<usize>>) -> Vec<usize> {
let mut shards = base.to_vec();
if let Some(extra) = extra {
for sid in extra {
if !shards.contains(sid) {
shards.push(*sid);
}
}
}
shards.sort_unstable();
shards
}
#[cfg(test)]
mod builder_tests {
use super::*;
#[test]
fn merged_lock_shards_are_ascending_and_deduplicated() {
let merged = merge_lock_shards(&[5], Some(&vec![9, 2, 5, 7]));
assert_eq!(merged, vec![2, 5, 7, 9]);
let mirrored = merge_lock_shards(&[5], Some(&vec![7, 5, 2, 9]));
assert_eq!(merged, mirrored);
assert_eq!(merge_lock_shards(&[3, 1], None), vec![1, 3]);
}
#[test]
fn tx_builder_rejects_an_oversized_key_before_any_lock() {
let err = check_op_limits(0, 8, crate::txlog::record::MAX_KEY_LEN + 1).unwrap_err();
assert!(matches!(err, crate::DbError::Client(_)), "{err}");
}
#[test]
fn tx_builder_rejects_more_ops_than_max_ops() {
assert!(
check_op_limits(7, 8, 8).is_ok(),
"the 8th operation still fits"
);
let err = check_op_limits(8, 8, 8).unwrap_err();
assert!(matches!(err, crate::DbError::Client(_)), "{err}");
}
#[test]
fn stage_follows_the_apply_finish_sync_priority() {
use crate::TxFailStage::*;
assert!(stage_and_source(None, None, None).is_none());
let only_finish = stage_and_source(None, Some(DbError::Internal("f")), None).unwrap();
assert_eq!(only_finish.0, Finish);
assert!(format!("{}", only_finish.1).contains('f'));
let only_sync = stage_and_source(None, None, Some(DbError::Internal("s"))).unwrap();
assert_eq!(only_sync.0, Sync);
let all_three = stage_and_source(
Some(DbError::Internal("a")),
Some(DbError::Internal("f")),
Some(DbError::Internal("s")),
)
.unwrap();
assert_eq!(all_three.0, Apply);
assert!(format!("{}", all_three.1).contains('a'));
}
}
#[cfg(all(test, feature = "armour"))]
pub(super) mod commit_tests {
use std::sync::Arc;
use crate::armour::Db;
use crate::{Config, NoHook, TtlHook, TxConfig, Upsert};
use crate::{CollectionMeta, RapiraCodec, TypedMap};
use armour_core::GetType;
use rapira::Rapira;
use zerocopy::{FromBytes, Immutable, IntoBytes};
pub(super) type UserId = [u8; 8];
pub(super) type CounterId = [u8; 8];
#[derive(Clone, Copy, Debug, PartialEq, Eq, GetType, Rapira)]
pub(super) struct User {
pub active: bool,
}
impl CollectionMeta for User {
type SelfId = UserId;
const NAME: &'static str = "users";
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, GetType, Rapira)]
pub(super) struct Counter {
pub n: u32,
}
impl CollectionMeta for Counter {
type SelfId = CounterId;
const NAME: &'static str = "counters";
}
pub(super) fn open_pair(
db: &Arc<Db>,
) -> (
Arc<TypedMap<UserId, User, RapiraCodec, TtlHook<User, NoHook>>>,
Arc<TypedMap<CounterId, Counter, RapiraCodec, TtlHook<Counter, NoHook>>>,
) {
let users = db
.open_typed_map::<User, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
let counters = db
.open_typed_map::<Counter, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
(users, counters)
}
pub(super) fn user_id(n: u64) -> UserId {
n.to_be_bytes()
}
#[derive(Clone, Debug, PartialEq, Eq, GetType, Rapira)]
pub(super) struct Blob {
pub payload: Vec<u8>,
}
impl CollectionMeta for Blob {
type SelfId = [u8; 8];
const NAME: &'static str = "blobs";
}
pub(super) fn open_blobs(
db: &Arc<Db>,
) -> Arc<TypedMap<[u8; 8], Blob, RapiraCodec, TtlHook<Blob, NoHook>>> {
db.open_typed_map::<Blob, RapiraCodec, _>(
Config::balanced()
.shard_count(1)
.write_buffer_size(8192)
.hints(true)
.build(),
NoHook,
&[],
)
.unwrap()
}
#[test]
fn tx_apply_failure_stops_its_shard_so_replay_can_finish_the_record() {
let dir = tempfile::tempdir().unwrap();
{
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let blobs = open_blobs(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(0);
let err = db
.tx()
.put(
&blobs,
&user_id(1),
&Blob {
payload: vec![7u8; 8],
},
)
.unwrap()
.put(
&blobs,
&user_id(2),
&Blob {
payload: vec![1u8; 8],
},
)
.unwrap()
.durable()
.commit()
.unwrap_err();
drop(_fail);
assert!(format!("{err}").contains("injected apply failure"), "{err}");
assert!(
blobs.get(&user_id(2)).is_none(),
"a write on a shard whose apply failed must not take a higher GSN"
);
db.close().unwrap();
}
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let blobs = open_blobs(&db);
assert!(
blobs.get(&user_id(1)).is_some(),
"the failed operation must still be replayable"
);
assert!(
blobs.get(&user_id(2)).is_some(),
"replay must finish the whole record"
);
}
#[test]
fn tx_apply_failure_fences_the_shard_against_later_writes() {
let dir = tempfile::tempdir().unwrap();
{
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let blobs = open_blobs(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(0);
let err = db
.tx()
.put(
&blobs,
&user_id(1),
&Blob {
payload: vec![7u8; 8],
},
)
.unwrap()
.durable()
.commit()
.unwrap_err();
drop(_fail);
assert!(format!("{err}").contains("injected apply failure"), "{err}");
let outcome = blobs.put(
&user_id(2),
Blob {
payload: vec![2u8; 8],
},
);
assert!(
matches!(outcome, Err(crate::DbError::ShardFenced { .. })),
"a shard owing a committed write must refuse further writes"
);
db.close().unwrap();
}
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let blobs = open_blobs(&db);
assert!(
blobs.get(&user_id(1)).is_some(),
"replay must still finish the committed operation"
);
assert!(
blobs
.put(
&user_id(3),
Blob {
payload: vec![3u8; 8]
}
)
.is_ok(),
"the fence lasts only until the record is replayed at open"
);
}
#[test]
fn tx_apply_failure_reports_stage_and_fenced_shard() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(0);
let err = db
.tx()
.put(&users, &user_id(1), &User { active: true })
.unwrap()
.put(&counters, &user_id(1), &Counter { n: 1 })
.unwrap()
.commit()
.unwrap_err();
drop(_fail);
let crate::DbError::TxFailedAfterLog {
stage,
fenced,
source,
..
} = &err
else {
panic!("expected TxFailedAfterLog, got {err}");
};
assert_eq!(*stage, crate::TxFailStage::Apply);
assert!(
format!("{source}").contains("injected apply failure"),
"{source}"
);
assert_eq!(fenced.len(), 1, "{fenced:?}");
assert_eq!(fenced[0].collection, "users");
let reported = fenced[0].gsn;
let again = users.put(&user_id(1), User { active: false });
match again {
Err(crate::DbError::ShardFenced { gsn, .. }) => assert_eq!(gsn, reported),
Ok(_) => panic!("expected ShardFenced, got Ok"),
Err(other) => panic!("expected ShardFenced, got {other}"),
}
}
#[test]
fn a_failed_log_sync_reports_the_sync_stage_and_fences_nothing() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let _fail = crate::txn::failpoints::LogSyncFailGuard::arm();
let err = db
.tx()
.put(&users, &user_id(40), &User { active: true })
.unwrap()
.put(&counters, &user_id(40), &Counter { n: 4 })
.unwrap()
.durable()
.commit()
.unwrap_err();
drop(_fail);
let crate::DbError::TxFailedAfterLog {
stage,
fenced,
source,
..
} = &err
else {
panic!("expected TxFailedAfterLog, got {err}");
};
assert_eq!(*stage, crate::TxFailStage::Sync);
assert!(
fenced.is_empty(),
"a sync failure fences nothing: {fenced:?}"
);
assert!(
format!("{source}").contains("injected log sync failure"),
"{source}"
);
assert_eq!(err.status_code(), 500);
assert_eq!(users.get(&user_id(40)).map(|u| u.active), Some(true));
assert_eq!(counters.get(&user_id(40)).map(|c| c.n), Some(4));
}
#[test]
fn several_fenced_shards_are_reported_together_with_their_earliest_gsn() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(0);
let first = db
.tx()
.put(&users, &user_id(1), &User { active: true })
.unwrap()
.commit()
.unwrap_err();
let crate::DbError::TxFailedAfterLog { fenced: f1, .. } = &first else {
panic!("expected TxFailedAfterLog, got {first}");
};
let users_gsn = f1[0].gsn;
let second = db
.tx()
.put(&counters, &user_id(1), &Counter { n: 1 })
.unwrap()
.commit()
.unwrap_err();
let crate::DbError::TxFailedAfterLog { fenced: f2, .. } = &second else {
panic!("expected TxFailedAfterLog, got {second}");
};
let counters_gsn = f2[0].gsn;
drop(_fail);
let third = db
.tx()
.put(&users, &user_id(1), &User { active: false })
.unwrap()
.put(&counters, &user_id(1), &Counter { n: 2 })
.unwrap()
.commit()
.unwrap_err();
let crate::DbError::TxFailedAfterLog { stage, fenced, .. } = &third else {
panic!("expected TxFailedAfterLog, got {third}");
};
assert_eq!(*stage, crate::TxFailStage::Apply);
assert_eq!(fenced.len(), 2, "{fenced:?}");
let by_name = |name: &str| {
fenced
.iter()
.find(|f| f.collection == name)
.unwrap_or_else(|| panic!("{name} missing from {fenced:?}"))
};
assert_eq!(by_name("users").gsn, users_gsn);
assert_eq!(by_name("counters").gsn, counters_gsn);
}
#[test]
fn one_shard_is_reported_once_even_with_two_unapplied_reservations() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let blobs = open_blobs(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(0);
let err = db
.tx()
.put(
&blobs,
&user_id(10),
&Blob {
payload: vec![1, 2, 3],
},
)
.unwrap()
.put(
&blobs,
&user_id(11),
&Blob {
payload: vec![4, 5, 6],
},
)
.unwrap()
.commit()
.unwrap_err();
drop(_fail);
let crate::DbError::TxFailedAfterLog { fenced, .. } = &err else {
panic!("expected TxFailedAfterLog, got {err}");
};
assert_eq!(fenced.len(), 1, "one shard, one entry: {fenced:?}");
assert_eq!(fenced[0].collection, "blobs");
let reported = fenced[0].gsn;
let outcome = blobs.put(&user_id(12), Blob { payload: vec![7] });
match outcome {
Err(crate::DbError::ShardFenced { gsn, .. }) => assert_eq!(gsn, reported),
Ok(_) => panic!("expected ShardFenced, got Ok"),
Err(other) => panic!("expected ShardFenced, got {other}"),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, GetType, FromBytes, IntoBytes, Immutable)]
#[repr(C)]
pub(super) struct SeenMarker {
pub at: u64,
}
impl CollectionMeta for SeenMarker {
type SelfId = UserId;
const NAME: &'static str = "seen";
}
pub(super) fn open_seen(
db: &Arc<Db>,
) -> Arc<
crate::ZeroMap<
UserId,
8,
SeenMarker,
TtlHook<SeenMarker, NoHook>,
crate::durability::Fixed,
>,
> {
db.open_zero_map_fixed::<SeenMarker, 8, _>(crate::FixedConfig::test(), NoHook, &[])
.unwrap()
}
#[test]
fn a_fixed_participant_is_never_reported_as_fenced() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, _counters) = open_pair(&db);
let seen = open_seen(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(0);
let err = db
.tx()
.put(&users, &user_id(1), &User { active: true })
.unwrap()
.put(&seen, &user_id(1), &SeenMarker { at: 1 })
.unwrap()
.commit()
.unwrap_err();
drop(_fail);
let crate::DbError::TxFailedAfterLog { fenced, .. } = &err else {
panic!("expected TxFailedAfterLog, got {err}");
};
assert_eq!(fenced.len(), 1, "{fenced:?}");
assert_eq!(fenced[0].collection, "users");
assert!(
fenced.iter().all(|f| f.collection != SeenMarker::NAME),
"a FixedStore participant must never be reported as fenced: {fenced:?}"
);
}
#[test]
fn tx_failure_before_the_log_point_is_not_wrapped() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, _counters) = open_pair(&db);
let err = db
.tx()
.put(&users, &user_id(5), &User { active: true })
.unwrap()
.guard(&users, &user_id(5), |_cur: Option<&User>| {
Err(crate::DbError::Client("nope"))
})
.unwrap()
.commit()
.unwrap_err();
assert!(
!matches!(err, crate::DbError::TxFailedAfterLog { .. }),
"{err}"
);
assert!(matches!(err, crate::DbError::Client("nope")), "{err}");
assert!(users.get(&user_id(5)).is_none(), "nothing was written");
}
#[test]
fn tx_rejects_an_oversized_tombstone_before_the_log_point() {
#[derive(Clone, Copy, Debug, PartialEq, Eq, GetType, Rapira)]
struct Marker {
n: u32,
}
impl CollectionMeta for Marker {
type SelfId = [u8; 9000];
const NAME: &'static str = "markers";
}
fn open(
db: &Arc<Db>,
buffer: usize,
) -> Arc<TypedMap<[u8; 9000], Marker, RapiraCodec, TtlHook<Marker, NoHook>>> {
db.open_typed_map::<Marker, RapiraCodec, _>(
Config::balanced()
.shard_count(1)
.write_buffer_size(buffer)
.hints(true)
.build(),
NoHook,
&[],
)
.unwrap()
}
let dir = tempfile::tempdir().unwrap();
let key = [7u8; 9000];
{
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let markers = open(&db, 32 * 1024);
markers.put(&key, Marker { n: 1 }).unwrap();
db.close().unwrap();
}
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let markers = open(&db, 8192);
let err = db
.tx()
.delete(&markers, &key)
.unwrap()
.durable()
.commit()
.unwrap_err();
assert!(
matches!(err, crate::DbError::Client(_)),
"an oversized tombstone is a client error: {err}"
);
assert!(
db.pending_transactions().is_empty(),
"no record may reach the log for a delete that cannot be applied"
);
}
#[test]
fn tx_rejects_an_oversized_entry_before_the_log_point() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let blobs = open_blobs(&db);
let err = db
.tx()
.put(
&blobs,
&user_id(1),
&Blob {
payload: vec![7u8; 32 * 1024],
},
)
.unwrap()
.put(
&blobs,
&user_id(2),
&Blob {
payload: vec![1u8; 8],
},
)
.unwrap()
.durable()
.commit()
.unwrap_err();
assert!(
matches!(err, crate::DbError::Client(_)),
"an oversized entry is a client error: {err}"
);
assert!(
blobs.get(&user_id(2)).is_none(),
"the transaction must roll back whole — no sibling write may land"
);
assert!(
db.pending_transactions().is_empty(),
"no record may reach the log for a transaction that cannot be applied"
);
}
#[test]
fn tx_db_assigns_stable_collection_ids_and_finds_participants() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let id_users = db.tx_collection_id(users.as_ref()).unwrap();
let id_counters = db.tx_collection_id(counters.as_ref()).unwrap();
assert_ne!(id_users, id_counters);
assert_eq!(
db.tx_collection_id(users.as_ref()).unwrap(),
id_users,
"ids are stable"
);
}
#[test]
fn tx_db_config_limits_are_honoured() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Db::open_with_tx_config(
dir.path(),
TxConfig {
max_ops: 2,
..TxConfig::default()
},
)
.unwrap(),
);
let (users, _counters) = open_pair(&db);
let tx = db
.tx()
.put(&users, &user_id(1), &User { active: true })
.unwrap()
.put(&users, &user_id(2), &User { active: true })
.unwrap();
let err = tx
.put(&users, &user_id(3), &User { active: true })
.err()
.expect("third put should exceed max_ops");
assert!(matches!(err, crate::DbError::Client(_)), "{err}");
}
#[test]
fn tx_commit_applies_both_collections_or_neither() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
db.tx()
.put(&users, &user_id(1), &User { active: true })
.unwrap()
.upsert(&counters, &user_id(1), |old: Option<&Counter>| {
Upsert::Put(Counter {
n: old.map_or(1, |c| c.n + 1),
})
})
.unwrap()
.commit()
.unwrap();
assert_eq!(users.get(&user_id(1)).map(|u| u.active), Some(true));
assert_eq!(counters.get(&user_id(1)).map(|c| c.n), Some(1));
}
#[test]
fn tx_commit_guard_error_leaves_nothing_behind() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let err = db
.tx()
.put(&users, &user_id(2), &User { active: true })
.unwrap()
.guard(&counters, &user_id(2), |cur: Option<&Counter>| {
if cur.is_none() {
Err(crate::DbError::TxConflict)
} else {
Ok(())
}
})
.unwrap()
.commit()
.unwrap_err();
assert!(matches!(err, crate::DbError::TxConflict), "{err}");
assert!(
users.get(&user_id(2)).is_none(),
"no write survives a failed guard"
);
assert_eq!(
db.pending_transactions().len(),
0,
"nothing reached the log"
);
}
#[test]
fn tx_commit_collapses_duplicate_keys_and_reads_its_own_writes() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (_users, counters) = open_pair(&db);
db.tx()
.upsert(&counters, &user_id(3), |_old: Option<&Counter>| {
Upsert::Put(Counter { n: 5 })
})
.unwrap()
.upsert(&counters, &user_id(3), |old: Option<&Counter>| {
Upsert::Put(Counter {
n: old.map_or(0, |c| c.n) * 2,
})
})
.unwrap()
.commit()
.unwrap();
assert_eq!(counters.get(&user_id(3)).map(|c| c.n), Some(10));
}
#[test]
fn tx_commit_rejects_a_collection_from_another_db() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
let db_a = Arc::new(Db::open_test(dir_a.path()).unwrap());
let db_b = Arc::new(Db::open_test(dir_b.path()).unwrap());
let (users_a, _) = open_pair(&db_a);
let (_, counters_b) = open_pair(&db_b);
let err = db_a
.tx()
.put(&users_a, &user_id(4), &User { active: true })
.unwrap()
.put(&counters_b, &user_id(4), &Counter { n: 1 })
.unwrap()
.commit()
.unwrap_err();
assert!(matches!(err, crate::DbError::Client(_)), "{err}");
}
#[test]
fn tx_commit_durable_writes_the_log_before_applying() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, _counters) = open_pair(&db);
db.tx()
.put(&users, &user_id(5), &User { active: true })
.unwrap()
.durable()
.commit()
.unwrap();
assert!(dir.path().join("txlog").is_dir());
assert_eq!(users.get(&user_id(5)).map(|u| u.active), Some(true));
}
pub(super) fn open_counters(
db: &Arc<Db>,
) -> Arc<TypedMap<CounterId, Counter, RapiraCodec, TtlHook<Counter, NoHook>>> {
db.open_typed_map::<Counter, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap()
}
#[test]
fn tx_checkpoint_retires_applied_records_after_a_durable_flush() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Db::open_with_tx_config(
dir.path(),
TxConfig {
segment_bytes: 512,
..TxConfig::default()
},
)
.unwrap(),
);
let (users, _counters) = open_pair(&db);
for i in 0..20u64 {
db.tx()
.put(&users, &user_id(i), &User { active: true })
.unwrap()
.commit()
.unwrap();
}
assert!(!db.pending_transactions().is_empty());
db.checkpoint_now().unwrap();
assert!(
db.pending_transactions().is_empty(),
"applied records retire once their participants are durable"
);
}
#[test]
fn tx_checkpoint_keeps_a_record_whose_participant_was_never_opened() {
use std::sync::atomic::Ordering;
let dir = tempfile::tempdir().unwrap();
{
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
db.tx()
.put(&users, &user_id(1), &User { active: true })
.unwrap()
.put(&counters, &user_id(1), &Counter { n: 1 })
.unwrap()
.durable()
.commit()
.unwrap();
crate::txn::SKIP_APPLY.store(true, Ordering::SeqCst);
db.tx()
.put(&users, &user_id(2), &User { active: true })
.unwrap()
.durable()
.commit()
.unwrap();
crate::txn::SKIP_APPLY.store(false, Ordering::SeqCst);
}
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let _counters = open_counters(&db);
db.checkpoint_now().unwrap();
assert_eq!(db.pending_transactions().len(), 1);
}
#[test]
fn tx_partial_apply_error_keeps_record_in_flight() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(1);
let err = db
.tx()
.put(&users, &user_id(20), &User { active: true })
.unwrap()
.put(&counters, &user_id(20), &Counter { n: 2 })
.unwrap()
.durable()
.commit()
.unwrap_err();
drop(_fail);
assert!(format!("{err}").contains("injected apply failure"), "{err}");
assert!(
matches!(
err,
crate::DbError::TxFailedAfterLog {
stage: crate::TxFailStage::Apply,
..
}
),
"{err}"
);
assert_eq!(
users.get(&user_id(20)).map(|u| u.active),
Some(true),
"the first apply must land"
);
assert!(
counters.get(&user_id(20)).is_none(),
"the second apply must not"
);
assert_eq!(
db.pending_transactions().len(),
1,
"partial apply must not mark_applied — the record stays pending"
);
db.checkpoint_now().unwrap();
assert_eq!(
db.pending_transactions().len(),
1,
"checkpoint must not retire a record whose apply never fully succeeded"
);
#[derive(Clone, Copy, Debug, PartialEq, Eq, GetType, Rapira)]
struct Third {
v: u32,
}
impl CollectionMeta for Third {
type SelfId = [u8; 8];
const NAME: &'static str = "thirds";
}
let _third = db
.open_typed_map::<Third, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
db.checkpoint_now().unwrap();
assert_eq!(
db.pending_transactions().len(),
1,
"opening an unrelated collection must not settle a half-applied record"
);
}
#[test]
fn tx_partial_apply_clears_in_flight_so_later_commits_can_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Db::open_with_tx_config(
dir.path(),
TxConfig {
segment_bytes: 512,
..TxConfig::default()
},
)
.unwrap(),
);
let (users, counters) = open_pair(&db);
let _fail = crate::txn::failpoints::ApplyFailGuard::at(1);
let _ = db
.tx()
.put(&users, &user_id(30), &User { active: true })
.unwrap()
.put(&counters, &user_id(30), &Counter { n: 3 })
.unwrap()
.commit()
.unwrap_err();
drop(_fail);
assert_eq!(db.pending_transactions().len(), 1);
for i in 0..12u64 {
db.tx()
.put(&users, &user_id(100 + i), &User { active: true })
.unwrap()
.commit()
.unwrap();
}
db.checkpoint_now().unwrap();
assert_eq!(
db.pending_transactions().len(),
1,
"cleared in_flight must not freeze the checkpoint cut"
);
}
#[test]
fn tx_tags_follow_write_order_not_collection_id_order() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
for i in 0..8u64 {
db.tx()
.put(&users, &user_id(50 + i), &User { active: false })
.unwrap()
.commit()
.unwrap();
}
crate::txn::SKIP_APPLY.store(true, std::sync::atomic::Ordering::SeqCst);
db.tx()
.put(&counters, &user_id(40), &Counter { n: 7 })
.unwrap()
.put(&users, &user_id(40), &User { active: true })
.unwrap()
.durable()
.commit()
.unwrap();
crate::txn::SKIP_APPLY.store(false, std::sync::atomic::Ordering::SeqCst);
let pending = db.txlog().pending();
let ops = &pending.last().unwrap().ops;
assert_eq!(ops[0].name, "counters");
assert_eq!(ops[1].name, "users");
drop(users);
drop(counters);
drop(db);
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
assert_eq!(counters.get(&user_id(40)).map(|c| c.n), Some(7));
assert_eq!(users.get(&user_id(40)).map(|u| u.active), Some(true));
}
#[test]
fn tx_commit_durable_surfaces_sync_failure_after_unlock() {
use std::sync::atomic::Ordering;
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, _counters) = open_pair(&db);
crate::txlog::segment::FAIL_SYNC.store(true, Ordering::SeqCst);
let err = db
.tx()
.put(&users, &user_id(21), &User { active: true })
.unwrap()
.durable()
.commit()
.unwrap_err();
crate::txlog::segment::FAIL_SYNC.store(false, Ordering::SeqCst);
assert!(
format!("{err}").contains("injected txlog sync failure"),
"{err}"
);
assert_eq!(
users.get(&user_id(21)).map(|u| u.active),
Some(true),
"apply still runs after a sync failure"
);
}
#[test]
fn tx_reentrant_commit_inside_atomic_errors_instead_of_hanging() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let err = users
.atomic(&user_id(1), |_shard| {
db.tx()
.put(&counters, &user_id(1), &Counter { n: 1 })
.unwrap()
.commit()
})
.unwrap_err();
assert!(matches!(err, crate::DbError::ReentrantTx), "{err}");
}
#[test]
fn tx_collection_call_inside_an_upsert_closure_errors_instead_of_hanging() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, counters) = open_pair(&db);
let reentrant = users.clone();
let err = db
.tx()
.guard(&users, &user_id(1), move |_current: Option<&User>| {
reentrant.atomic(&user_id(1), |_shard| Ok(()))
})
.unwrap()
.commit()
.unwrap_err();
assert!(matches!(err, crate::DbError::ReentrantTx), "{err}");
let nested_db = db.clone();
let nested_counters = counters.clone();
let err = db
.tx()
.guard(&users, &user_id(2), move |_current: Option<&User>| {
nested_db
.tx()
.put(&nested_counters, &user_id(2), &Counter { n: 1 })?
.commit()
})
.unwrap()
.commit()
.unwrap_err();
assert!(matches!(err, crate::DbError::ReentrantTx), "{err}");
}
#[test]
fn tx_panic_under_the_locks_leaves_the_collection_usable() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, _counters) = open_pair(&db);
users.put(&user_id(1), User { active: true }).unwrap();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
db.tx()
.guard(&users, &user_id(1), |_current: Option<&User>| {
panic!("closure blew up under the locks")
})
.unwrap()
.commit()
}));
std::panic::set_hook(previous);
assert!(outcome.is_err(), "the closure must have panicked");
users.put(&user_id(1), User { active: false }).unwrap();
assert_eq!(users.get(&user_id(1)).map(|u| u.active), Some(false));
users.atomic(&user_id(1), |_shard| Ok(())).unwrap();
assert!(db.pending_transactions().is_empty());
}
#[test]
fn tx_delete_of_a_missing_key_does_not_strand_the_reserved_gsn() {
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let (users, _counters) = open_pair(&db);
db.tx()
.delete(&users, &user_id(1))
.unwrap()
.commit()
.unwrap();
db.tx()
.put(&users, &user_id(1), &User { active: true })
.unwrap()
.commit()
.unwrap();
assert_eq!(users.get(&user_id(1)).map(|u| u.active), Some(true));
db.tx()
.delete(&users, &user_id(2))
.unwrap()
.commit()
.unwrap();
users.put(&user_id(3), User { active: false }).unwrap();
assert_eq!(users.get(&user_id(3)).map(|u| u.active), Some(false));
}
#[test]
fn tx_delete_miss_next_to_a_write_keeps_hooks_and_checkpoint_alive() {
use std::sync::atomic::AtomicUsize;
#[derive(
Clone,
Copy,
Debug,
PartialEq,
armour_core::GetType,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
)]
#[repr(C)]
struct Row {
n: u32,
}
impl CollectionMeta for Row {
type SelfId = [u8; 8];
const NAME: &'static str = "rows";
}
#[derive(Clone, Default)]
struct CountingHook(Arc<AtomicUsize>);
impl crate::TypedWriteHook<[u8; 8], Row> for CountingHook {
const NEEDS_WRITE: bool = true;
fn on_write(&self, _k: &[u8; 8], _old: Option<&Row>, _new: Option<&Row>) {
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let fired = Arc::new(AtomicUsize::new(0));
let rows = db
.open_zero_map::<Row, 4, _>(Config::test(), CountingHook(fired.clone()), &[])
.unwrap();
db.tx()
.delete(&rows, &[9u8; 8])
.unwrap()
.put(&rows, &[1u8; 8], &Row { n: 5 })
.unwrap()
.commit()
.expect("a delete of an absent key must not fail the commit");
assert_eq!(rows.get(&[1u8; 8]).map(|r| r.n), Some(5));
assert_eq!(
fired.load(std::sync::atomic::Ordering::SeqCst),
1,
"the write that changed something must still fire its hook"
);
db.checkpoint_now().unwrap();
assert!(
db.pending_transactions().is_empty(),
"the transaction log must not be frozen by a write that changed nothing"
);
}
#[test]
fn tx_panic_during_apply_still_settles_the_record() {
use std::sync::atomic::Ordering;
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(
Db::open_with_tx_config(
dir.path(),
TxConfig {
segment_bytes: 512,
..TxConfig::default()
},
)
.unwrap(),
);
let blobs = open_blobs(&db);
let (users, _counters) = open_pair(&db);
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); crate::txn::APPLY_PANIC_AT.store(0, Ordering::SeqCst);
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
db.tx()
.put(
&blobs,
&user_id(1),
&Blob {
payload: vec![5u8; 8],
},
)
.unwrap()
.commit()
}));
crate::txn::APPLY_PANIC_AT.store(usize::MAX, Ordering::SeqCst);
std::panic::set_hook(previous);
assert!(outcome.is_err(), "apply must have panicked");
for i in 0..12u64 {
db.tx()
.put(&users, &user_id(100 + i), &User { active: true })
.unwrap()
.commit()
.unwrap();
}
db.checkpoint_now().unwrap();
assert_eq!(
db.pending_transactions().len(),
1,
"a panic in apply must not freeze the checkpoint floor"
);
}
#[test]
fn tx_panicking_hook_still_settles_the_record() {
#[derive(
Clone,
Copy,
Debug,
PartialEq,
armour_core::GetType,
zerocopy::FromBytes,
zerocopy::IntoBytes,
zerocopy::Immutable,
)]
#[repr(C)]
struct Boom {
n: u32,
}
impl CollectionMeta for Boom {
type SelfId = [u8; 8];
const NAME: &'static str = "boom";
}
struct PanicHook;
impl crate::TypedWriteHook<[u8; 8], Boom> for PanicHook {
const NEEDS_WRITE: bool = true;
fn on_write(&self, _k: &[u8; 8], _old: Option<&Boom>, _new: Option<&Boom>) {
panic!("hook blew up");
}
}
let dir = tempfile::tempdir().unwrap();
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let rows = db
.open_zero_map::<Boom, 4, _>(Config::test(), PanicHook, &[])
.unwrap();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {})); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
db.tx()
.put(&rows, &[1u8; 8], &Boom { n: 1 })
.unwrap()
.commit()
}));
std::panic::set_hook(previous);
assert!(outcome.is_err(), "the hook must have panicked");
assert_eq!(rows.get(&[1u8; 8]).map(|r| r.n), Some(1));
db.checkpoint_now().unwrap();
assert!(
db.pending_transactions().is_empty(),
"a panicking hook must not freeze the checkpoint floor"
);
}
}