#![allow(dead_code)]
use std::sync::Arc;
use rapira::Rapira;
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
use armour_core::{GetRefs, GetType};
use crate::counter::{CounterCollection, CounterTargets, OnMissingTarget};
use crate::projection::{IndexKeyed, ProjectableIndexTx};
use crate::{
CollectionMeta, Config, IndexTargets, NoHook, Projected, RapiraCodec, TtlHook, TypedWriteHook,
Upsert, armour::Db,
};
#[derive(Clone, Debug, PartialEq, GetType, GetRefs, Rapira)]
pub(crate) struct Group {
pub member_count: u64,
}
impl CollectionMeta for Group {
type SelfId = [u8; 8];
const NAME: &'static str = "groups";
}
#[derive(Clone, Debug, PartialEq, GetType, GetRefs, Rapira)]
pub(crate) struct Member {
pub group: [u8; 8],
}
impl CollectionMeta for Member {
type SelfId = MemberKey;
const NAME: &'static str = "members";
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
GetType,
GetRefs,
FromBytes,
IntoBytes,
Immutable,
KnownLayout,
)]
#[repr(transparent)]
pub(crate) struct MemberKey(pub [u8; 16]);
impl MemberKey {
pub(crate) fn new(group: [u8; 8], user: u64) -> Self {
let mut b = [0u8; 16];
b[..8].copy_from_slice(&group);
b[8..].copy_from_slice(&user.to_be_bytes());
Self(b)
}
}
crate::impl_key_zerocopy!(
MemberKey,
armour_core::KeyScheme::Typed(&[armour_core::KeyType::Array(16)])
);
pub(crate) fn group_counter_targets<C>(groups: &Arc<C>) -> CounterTargets
where
C: CounterCollection + ProjectableIndexTx + IndexKeyed<K = [u8; 8], V = Group>,
{
CounterTargets::new().with_counter(
groups,
"member_count",
OnMissingTarget::Error,
|g: &Group| g.member_count,
|cur: Option<&Group>, n: u64| match cur {
Some(_) if n == 0 => Upsert::Put(Group { member_count: 0 }),
#[allow(clippy::needless_update)]
Some(g) => Upsert::Put(Group {
member_count: n,
..g.clone()
}),
None => Upsert::Keep,
},
)
}
pub(crate) type GroupsMap = crate::typed_map!(Group, RapiraCodec, NoHook);
pub(crate) type MembersMap =
crate::typed_map!(Member, RapiraCodec, Projected<MemberKey, Member, NoHook>);
pub(crate) fn counted_fixture_at(
path: &std::path::Path,
) -> (Arc<Db>, Arc<MembersMap>, Arc<GroupsMap>) {
let db = Arc::new(Db::open_test(path).expect("open db"));
let groups = db
.open_typed_map::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let counter_targets = group_counter_targets(&groups);
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _sink| Ok(()),
)
.and_counters(counter_targets, move |_k: &MemberKey, v: &Member, sink| {
sink.add(&groups_for_project, v.group, 1)
});
let members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.expect("open members");
(db, members, groups)
}
pub(crate) fn counted_fixture() -> (Arc<Db>, Arc<MembersMap>, Arc<GroupsMap>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let (db, m, g) = counted_fixture_at(dir.path());
(db, m, g, dir)
}
#[test]
fn counted_fixture_hook_reports_counts_through_ttl_wrapper() {
assert!(
<TtlHook<Member, Projected<MemberKey, Member, NoHook>> as TypedWriteHook<
MemberKey,
Member,
>>::COUNTS
);
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, GetType, FromBytes, IntoBytes, Immutable, KnownLayout,
)]
#[repr(C)]
pub(crate) struct ZeroGroup {
pub member_count: u64,
}
impl CollectionMeta for ZeroGroup {
type SelfId = [u8; 8];
const NAME: &'static str = "zero_groups";
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, GetType, FromBytes, IntoBytes, Immutable, KnownLayout,
)]
#[repr(C)]
pub(crate) struct ZeroMember {
pub group: [u8; 8],
}
impl CollectionMeta for ZeroMember {
type SelfId = MemberKey;
const NAME: &'static str = "zero_members";
}
pub(crate) fn zero_group_counter_targets<C>(groups: &Arc<C>) -> CounterTargets
where
C: CounterCollection + ProjectableIndexTx + IndexKeyed<K = [u8; 8], V = ZeroGroup>,
{
CounterTargets::new().with_counter(
groups,
"member_count",
OnMissingTarget::Error,
|g: &ZeroGroup| g.member_count,
|cur: Option<&ZeroGroup>, n: u64| match cur {
Some(_) => Upsert::Put(ZeroGroup { member_count: n }),
None => Upsert::Keep,
},
)
}
#[test]
fn counter_collection_upsert_roundtrip_typed_map() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_typed_map::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let key = [7u8; 8];
groups.put(&key, Group { member_count: 3 }).expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(Group { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_collection_upsert_roundtrip_typed_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_typed_tree::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let key = [7u8; 8];
groups.put(&key, Group { member_count: 3 }).expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(Group { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_collection_upsert_roundtrip_zero_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_zero_tree::<ZeroGroup, { size_of::<ZeroGroup>() }, _>(Config::test(), NoHook, &[])
.expect("open groups");
let key = [7u8; 8];
groups
.put(&key, &ZeroGroup { member_count: 3 })
.expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(ZeroGroup { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_collection_upsert_roundtrip_zero_tree_fixed() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_zero_tree_fixed::<ZeroGroup, { size_of::<ZeroGroup>() }, _>(
crate::FixedConfig::test(),
NoHook,
&[],
)
.expect("open groups");
let key = [7u8; 8];
groups
.put(&key, &ZeroGroup { member_count: 3 })
.expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(ZeroGroup { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_collection_upsert_roundtrip_zero_map() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_zero_map::<ZeroGroup, { size_of::<ZeroGroup>() }, _>(Config::test(), NoHook, &[])
.expect("open groups");
let key = [7u8; 8];
groups
.put(&key, &ZeroGroup { member_count: 3 })
.expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(ZeroGroup { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_collection_upsert_roundtrip_zero_map_fixed() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_zero_map_fixed::<ZeroGroup, { size_of::<ZeroGroup>() }, _>(
crate::FixedConfig::test(),
NoHook,
&[],
)
.expect("open groups");
let key = [7u8; 8];
groups
.put(&key, &ZeroGroup { member_count: 3 })
.expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(ZeroGroup { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_collection_upsert_roundtrip_var_typed_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_var_typed_tree::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let key = [7u8; 8];
groups.put(&key, &Group { member_count: 3 }).expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(Group { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_collection_upsert_roundtrip_var_typed_map() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open"));
let groups = db
.open_var_typed_map::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let key = [7u8; 8];
groups.put(&key, &Group { member_count: 3 }).expect("put");
let bytes = key.to_vec();
assert!(groups.contains_bytes(&bytes).expect("contains"));
let missing = [42u8; 8].to_vec();
assert!(!groups.contains_bytes(&missing).expect("contains missing"));
assert!(groups.read_bytes(&missing).expect("read missing").is_none());
groups
.upsert_counter(&bytes, &mut |cur| {
let n = cur.map_or(0, |g| g.member_count) + 1;
Upsert::Put(Group { member_count: n })
})
.expect("upsert_counter");
assert_eq!(groups.get(&key).expect("row").member_count, 4);
let read = groups
.read_bytes(&bytes)
.expect("read_bytes")
.expect("row present");
assert_eq!(
groups
.decode_value(&read)
.expect("decode_value")
.member_count,
4
);
}
#[test]
fn counter_mutation_matrix_typed_map() {
let (_db, members, groups, _dir) = counted_fixture();
let g1 = [1u8; 8];
let g2 = [2u8; 8];
groups.put(&g1, Group { member_count: 0 }).unwrap();
groups.put(&g2, Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new(g1, 10), Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), Member { group: g2 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 0);
assert_eq!(groups.get(&g2).unwrap().member_count, 1);
members.delete(&MemberKey::new(g1, 10)).unwrap();
assert_eq!(groups.get(&g2).unwrap().member_count, 0);
}
#[test]
fn counter_mutation_matrix_typed_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let groups = db
.open_typed_tree::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let counter_targets = group_counter_targets(&groups);
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _sink| Ok(()),
)
.and_counters(counter_targets, move |_k: &MemberKey, v: &Member, sink| {
sink.add(&groups_for_project, v.group, 1)
});
let members = db
.open_typed_tree::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.expect("open members");
let g1 = [1u8; 8];
let g2 = [2u8; 8];
groups.put(&g1, Group { member_count: 0 }).unwrap();
groups.put(&g2, Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new(g1, 10), Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), Member { group: g2 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 0);
assert_eq!(groups.get(&g2).unwrap().member_count, 1);
members.delete(&MemberKey::new(g1, 10)).unwrap();
assert_eq!(groups.get(&g2).unwrap().member_count, 0);
}
#[test]
fn counter_mutation_matrix_zero_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let groups = db
.open_zero_tree::<ZeroGroup, { size_of::<ZeroGroup>() }, _>(Config::test(), NoHook, &[])
.expect("open groups");
let counter_targets = zero_group_counter_targets(&groups);
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, ZeroMember, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &ZeroMember, _sink| Ok(()),
)
.and_counters(
counter_targets,
move |_k: &MemberKey, v: &ZeroMember, sink| sink.add(&groups_for_project, v.group, 1),
);
let members = db
.open_zero_tree::<ZeroMember, { size_of::<ZeroMember>() }, _>(Config::test(), hook, &[])
.expect("open members");
let g1 = [1u8; 8];
let g2 = [2u8; 8];
groups.put(&g1, &ZeroGroup { member_count: 0 }).unwrap();
groups.put(&g2, &ZeroGroup { member_count: 0 }).unwrap();
members
.put(&MemberKey::new(g1, 10), &ZeroMember { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), &ZeroMember { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), &ZeroMember { group: g2 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 0);
assert_eq!(groups.get(&g2).unwrap().member_count, 1);
members.delete(&MemberKey::new(g1, 10)).unwrap();
assert_eq!(groups.get(&g2).unwrap().member_count, 0);
}
#[test]
fn counter_mutation_matrix_zero_tree_fixed() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let groups = db
.open_zero_tree_fixed::<ZeroGroup, { size_of::<ZeroGroup>() }, _>(
crate::FixedConfig::test(),
NoHook,
&[],
)
.expect("open groups");
let counter_targets = zero_group_counter_targets(&groups);
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, ZeroMember, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &ZeroMember, _sink| Ok(()),
)
.and_counters(
counter_targets,
move |_k: &MemberKey, v: &ZeroMember, sink| sink.add(&groups_for_project, v.group, 1),
);
let members = db
.open_zero_tree_fixed::<ZeroMember, { size_of::<ZeroMember>() }, _>(
crate::FixedConfig::test(),
hook,
&[],
)
.expect("open members");
let g1 = [1u8; 8];
let g2 = [2u8; 8];
groups.put(&g1, &ZeroGroup { member_count: 0 }).unwrap();
groups.put(&g2, &ZeroGroup { member_count: 0 }).unwrap();
members
.put(&MemberKey::new(g1, 10), &ZeroMember { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), &ZeroMember { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), &ZeroMember { group: g2 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 0);
assert_eq!(groups.get(&g2).unwrap().member_count, 1);
members.delete(&MemberKey::new(g1, 10)).unwrap();
assert_eq!(groups.get(&g2).unwrap().member_count, 0);
}
#[test]
fn counter_mutation_matrix_var_typed_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let groups = db
.open_var_typed_tree::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let counter_targets = group_counter_targets(&groups);
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _sink| Ok(()),
)
.and_counters(counter_targets, move |_k: &MemberKey, v: &Member, sink| {
sink.add(&groups_for_project, v.group, 1)
});
let members = db
.open_var_typed_tree::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.expect("open members");
let g1 = [1u8; 8];
let g2 = [2u8; 8];
groups.put(&g1, &Group { member_count: 0 }).unwrap();
groups.put(&g2, &Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new(g1, 10), &Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), &Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), &Member { group: g2 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 0);
assert_eq!(groups.get(&g2).unwrap().member_count, 1);
members.delete(&MemberKey::new(g1, 10)).unwrap();
assert_eq!(groups.get(&g2).unwrap().member_count, 0);
}
#[test]
fn counter_dirty_is_persisted_when_the_source_opens_and_cleared_on_close() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, members, groups) = counted_fixture_at(dir.path());
assert!(
db.debug_counters_dirty("members"),
"set when the counted source was opened"
);
assert!(
!db.debug_counters_dirty("groups"),
"a plain counter target is not marked — its flag means something else"
);
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
db.close().unwrap();
assert!(
!db.debug_counters_dirty("members"),
"cleared by a clean close"
);
}
#[test]
fn counter_dirty_spans_the_session_and_a_post_close_write_is_not_re_marked() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
assert!(
db.debug_counters_dirty("members"),
"set at open, with no counted write yet"
);
db.close().unwrap();
assert!(
!db.debug_counters_dirty("members"),
"cleared by a clean close"
);
members
.put(&MemberKey::new([1u8; 8], 2), Member { group: [1u8; 8] })
.unwrap();
assert!(
!db.debug_counters_dirty("members"),
"the write path does not mark: evidence is taken at open, once per session"
);
}
#[test]
fn counter_carrier_rejects_a_direct_open() {
let dir = tempfile::tempdir().expect("tempdir");
let target_dir = tempfile::tempdir().expect("tempdir");
let tdb = Arc::new(Db::open_test(target_dir.path()).expect("open"));
let groups = tdb
.open_typed_map::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let targets = group_counter_targets(&groups);
let groups_for_hook = Arc::clone(&groups);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(targets, move |_k: &MemberKey, v: &Member, sink| {
sink.add(&groups_for_hook, v.group, 1)
});
let err = crate::TypedMap::<MemberKey, Member, RapiraCodec, _>::open_hooked(
dir.path(),
Config::test(),
RapiraCodec,
hook,
)
.err()
.expect("a counter carrier has nowhere to persist its dirty marker");
assert!(matches!(err, crate::DbError::CountersRequireManagedOpen));
}
#[test]
fn counter_dirty_survives_a_failed_close() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, members, groups) = counted_fixture_at(dir.path());
assert!(db.debug_counters_dirty("members"), "set at open");
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
crate::projection::FAIL_COLLECTION_FLUSH.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = db.close();
crate::projection::FAIL_COLLECTION_FLUSH.store(false, std::sync::atomic::Ordering::SeqCst);
assert!(db.debug_counters_dirty("members"));
}
#[test]
fn counters_dirty_defaults_to_false_when_missing_from_a_pre_t9_stored_db_info() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("db.info"),
r#"{"version":0,"collections":{"legacy":{"version":0,"typ_hash":0,"seq":0,"projections_dirty":false}},"clean":true}"#,
)
.expect("write pre-T9 db.info");
let db = Db::open_test(dir.path())
.expect("a pre-T9 db.info (no counters_dirty key at all) must still load");
assert!(!db.debug_counters_dirty("legacy"));
}
#[derive(Clone, Debug, PartialEq, GetType, GetRefs, Rapira)]
pub(crate) struct GroupV2 {
pub member_count: u64,
}
impl CollectionMeta for GroupV2 {
type SelfId = [u8; 8];
const NAME: &'static str = "groups";
const VERSION: u16 = 1;
}
pub(crate) fn group_v2_counter_targets<C>(groups: &Arc<C>) -> CounterTargets
where
C: CounterCollection + ProjectableIndexTx + IndexKeyed<K = [u8; 8], V = GroupV2>,
{
CounterTargets::new().with_counter(
groups,
"member_count",
OnMissingTarget::Error,
|g: &GroupV2| g.member_count,
|cur: Option<&GroupV2>, n: u64| match cur {
Some(_) if n == 0 => Upsert::Put(GroupV2 { member_count: 0 }),
#[allow(clippy::needless_update)]
Some(g) => Upsert::Put(GroupV2 {
member_count: n,
..g.clone()
}),
None => Upsert::Keep,
},
)
}
#[test]
fn counter_recount_fixes_drift_and_zeroes_contributionless_rows() {
let (db, members, groups, _dir) = counted_fixture();
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
groups.put(&[2u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
groups.put(&[1u8; 8], Group { member_count: 99 }).unwrap();
groups.put(&[2u8; 8], Group { member_count: 7 }).unwrap();
let repaired = db.recount_counters("members").unwrap();
assert_eq!(repaired, 2);
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 1, "recomputed");
assert_eq!(
groups.get(&[2u8; 8]).unwrap().member_count,
0,
"driven to zero"
);
}
#[test]
fn counter_recount_runs_when_the_previous_close_failed() {
let dir = tempfile::tempdir().unwrap();
crate::projection::FAIL_COLLECTION_FLUSH.store(true, std::sync::atomic::Ordering::SeqCst);
{
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
groups.put(&[1u8; 8], Group { member_count: 99 }).unwrap(); let _ = db.close(); }
crate::projection::FAIL_COLLECTION_FLUSH.store(false, std::sync::atomic::Ordering::SeqCst);
let (db, _members, groups) = counted_fixture_at(dir.path());
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
1,
"recounted at open"
);
assert!(
db.debug_counters_dirty("members"),
"the recount cleared the old evidence; this session's open then took its own"
);
db.close().unwrap();
assert!(
!db.debug_counters_dirty("members"),
"and a clean close retires it, so the next open does not recount again"
);
}
#[test]
fn counter_recount_triggers_on_a_target_only_migration() {
let dir = tempfile::tempdir().unwrap();
{
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
db.close().unwrap(); }
fn zero_member_count(_k: &[u8; 8], _v: &GroupV2) -> crate::MigrateAction<GroupV2> {
crate::MigrateAction::Update(GroupV2 { member_count: 0 })
}
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let groups = db
.open_typed_map::<GroupV2, RapiraCodec, _>(
Config::test(),
NoHook,
&[(0, zero_member_count)],
)
.unwrap();
let targets = group_v2_counter_targets(&groups);
let groups_for_hook = Arc::clone(&groups);
let hook = Projected::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(targets, move |_k: &MemberKey, v: &Member, sink| {
sink.add(&groups_for_hook, v.group, 1)
});
let _members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.unwrap();
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
1,
"the target's migration marked it dirty, so the source recounted"
);
assert!(
db.debug_counters_dirty("groups"),
"the target's flag is still up while the session runs — a second source \
opening later must see it too"
);
db.close().unwrap();
assert!(
!db.debug_counters_dirty("groups"),
"a successful close retires the evidence the recount consumed"
);
}
#[test]
fn counter_recount_after_a_target_migration_does_not_repeat_on_the_next_open() {
fn zero_member_count(_k: &[u8; 8], _v: &GroupV2) -> crate::MigrateAction<GroupV2> {
crate::MigrateAction::Update(GroupV2 { member_count: 0 })
}
fn open_pair(
dir: &std::path::Path,
) -> (
Arc<Db>,
Arc<crate::typed_map!(GroupV2, RapiraCodec, NoHook)>,
) {
let db = Arc::new(Db::open_test(dir).unwrap());
let groups = db
.open_typed_map::<GroupV2, RapiraCodec, _>(
Config::test(),
NoHook,
&[(0, zero_member_count)],
)
.unwrap();
let groups_for_hook = Arc::clone(&groups);
let hook = Projected::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(
group_v2_counter_targets(&groups),
move |_k: &MemberKey, v: &Member, sink| sink.add(&groups_for_hook, v.group, 1),
);
let _members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.unwrap();
(db, groups)
}
let dir = tempfile::tempdir().unwrap();
{
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
db.close().unwrap();
}
{
let (db, groups) = open_pair(dir.path());
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 1, "recounted");
db.close().unwrap();
}
{
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let groups = db
.open_typed_map::<GroupV2, RapiraCodec, _>(Config::test(), NoHook, &[])
.unwrap();
assert!(
!db.debug_counters_dirty("groups"),
"the migration flag was retired by session 2's close"
);
groups.put(&[1u8; 8], GroupV2 { member_count: 99 }).unwrap();
let groups_for_hook = Arc::clone(&groups);
let hook = Projected::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(
group_v2_counter_targets(&groups),
move |_k: &MemberKey, v: &Member, sink| sink.add(&groups_for_hook, v.group, 1),
);
let _members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.unwrap();
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
99,
"no recount ran — a repeated one would have driven this back to 1"
);
}
}
#[test]
fn counter_recount_after_a_hard_exit_between_source_and_delta() {
if std::env::var("ARMDB_COUNTER_CHILD").is_ok() {
let (_db, members, _groups) = counted_fixture_at(std::path::Path::new(
&std::env::var("ARMDB_COUNTER_DIR").unwrap(),
));
crate::counter::FAIL_COUNTER_DELTA.store(true, std::sync::atomic::Ordering::SeqCst);
let _ = members.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] });
let _ = members.durable_flush();
std::process::abort();
}
let dir = tempfile::tempdir().unwrap();
{
let (db, _m, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
db.close().unwrap();
}
let status = std::process::Command::new(std::env::current_exe().unwrap())
.arg("--exact")
.arg("counter_tests::counter_recount_after_a_hard_exit_between_source_and_delta")
.env("ARMDB_COUNTER_CHILD", "1")
.env("ARMDB_COUNTER_DIR", dir.path())
.status()
.unwrap();
assert!(!status.success(), "the child aborts by design");
let (_db, _m, groups) = counted_fixture_at(dir.path());
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
1,
"the source row survived, so the recount restores its contribution"
);
}
#[test]
fn counter_tx_sums_two_deltas_into_one_absolute_write() {
let (db, members, groups, _dir) = counted_fixture();
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
db.tx()
.put(
&members,
&MemberKey::new([1u8; 8], 1),
&Member { group: [1u8; 8] },
)
.unwrap()
.put(
&members,
&MemberKey::new([1u8; 8], 2),
&Member { group: [1u8; 8] },
)
.unwrap()
.commit()
.unwrap();
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 2);
}
#[test]
fn counter_tx_sees_a_user_write_to_the_same_target_row() {
let (db, members, groups, _dir) = counted_fixture();
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
db.tx()
.put(&groups, &[1u8; 8], &Group { member_count: 10 }) .unwrap()
.put(
&members,
&MemberKey::new([1u8; 8], 1),
&Member { group: [1u8; 8] },
)
.unwrap()
.commit()
.unwrap();
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 11);
}
#[derive(Clone, Debug, PartialEq, GetType, GetRefs, Rapira)]
pub(crate) struct Org {
pub group_count: u64,
}
impl CollectionMeta for Org {
type SelfId = [u8; 8];
const NAME: &'static str = "orgs";
}
pub(crate) fn org_counter_targets<C>(orgs: &Arc<C>) -> CounterTargets
where
C: CounterCollection + ProjectableIndexTx + IndexKeyed<K = [u8; 8], V = Org>,
{
CounterTargets::new().with_counter(
orgs,
"group_count",
OnMissingTarget::Error,
|o: &Org| o.group_count,
|cur: Option<&Org>, n: u64| match cur {
Some(_) => Upsert::Put(Org { group_count: n }),
None => Upsert::Keep,
},
)
}
#[test]
fn counter_tx_rejects_writing_a_target_that_is_itself_a_projecting_source() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let orgs = db
.open_typed_map::<Org, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open orgs");
let orgs_for_project = Arc::clone(&orgs);
let groups_hook = Projected::<[u8; 8], Group, NoHook>::new(
IndexTargets::new(),
|_k: &[u8; 8], _v: &Group, _s| Ok(()),
)
.and_counters(
org_counter_targets(&orgs),
move |_k: &[u8; 8], _v: &Group, sink| sink.add(&orgs_for_project, [9u8; 8], 1),
);
let groups = db
.open_typed_map::<Group, RapiraCodec, _>(Config::test(), groups_hook, &[])
.expect("open groups");
let groups_for_project = Arc::clone(&groups);
let members_hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(
group_counter_targets(&groups),
move |_k: &MemberKey, v: &Member, sink| sink.add(&groups_for_project, v.group, 1),
);
let _members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), members_hook, &[])
.expect("open members");
orgs.put(&[9u8; 8], Org { group_count: 0 }).unwrap();
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
assert_eq!(orgs.get(&[9u8; 8]).unwrap().group_count, 1);
let err = db
.tx()
.put(&groups, &[1u8; 8], &Group { member_count: 5 })
.unwrap()
.commit()
.expect_err("a target that is itself a source cannot be written in a transaction");
assert!(
matches!(
err,
crate::DbError::TxTargetIsAlsoASource {
collection: "groups"
}
),
"{err}"
);
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 0);
assert_eq!(orgs.get(&[9u8; 8]).unwrap().group_count, 1);
}
#[test]
fn counter_tx_accepts_writing_a_plain_counter_target() {
let (db, _members, groups, _dir) = counted_fixture();
groups.put(&[1u8; 8], Group { member_count: 3 }).unwrap();
db.tx()
.put(&groups, &[1u8; 8], &Group { member_count: 7 })
.unwrap()
.commit()
.expect("a plain counter target has nothing of its own to project");
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 7);
}
#[test]
fn counter_tx_insert_into_a_plain_counter_target() {
let (db, _members, groups, _dir) = counted_fixture();
db.tx()
.insert(&groups, &[1u8; 8], &Group { member_count: 3 })
.unwrap()
.commit()
.expect("a free key on a plain counter target is insertable");
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 3);
db.checkpoint_now().unwrap();
let err = db
.tx()
.insert(&groups, &[1u8; 8], &Group { member_count: 9 })
.unwrap()
.commit()
.unwrap_err();
assert!(matches!(err, crate::DbError::KeyExists), "{err}");
assert!(
!matches!(err, crate::DbError::TxFailedAfterLog { .. }),
"the rank-1 arm must also abort before the log point: {err}"
);
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
3,
"the occupied target row is untouched"
);
assert!(
db.pending_transactions().is_empty(),
"nothing reached the log"
);
}
#[test]
fn counter_tx_replay_restores_the_exact_value_after_a_hard_exit() {
if let Ok(path) = std::env::var("ARMDB_COUNTER_DIR")
&& std::env::var("ARMDB_COUNTER_TX_CHILD").is_ok()
{
let (db, members, groups) = counted_fixture_at(std::path::Path::new(&path));
let guard = crate::txn::failpoints::ApplyFailGuard::at(1);
let outcome = db
.tx()
.put(
&members,
&MemberKey::new([1u8; 8], 1),
&Member { group: [1u8; 8] },
)
.and_then(crate::Tx::commit);
drop(guard);
assert!(
matches!(
&outcome,
Err(crate::DbError::TxFailedAfterLog { fenced, .. })
if fenced.iter().any(|f| f.collection == "groups")
),
"plan index 1 must be the counter write into `groups`, got {outcome:?}"
);
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
0,
"the counter must still be unapplied when the process dies"
);
std::process::abort();
}
let dir = tempfile::tempdir().unwrap();
{
let (db, _m, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
db.close().unwrap();
}
let status = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"counter_tests::counter_tx_replay_restores_the_exact_value_after_a_hard_exit",
])
.env("ARMDB_COUNTER_TX_CHILD", "1")
.env("ARMDB_COUNTER_DIR", dir.path())
.status()
.unwrap();
assert!(!status.success(), "the child aborts by design");
#[cfg(unix)]
assert!(
status.code().is_none(),
"the child must reach abort(), not fail an assertion: {status:?}"
);
let info_path = dir.path().join("db.info");
let raw = std::fs::read_to_string(&info_path).expect("db.info");
let info: crate::armour::DbInfo = serde_json::from_str(&raw).expect("parse db.info");
assert!(
!info.collections["groups"].counters_dirty,
"the transactional path must mark nothing dirty: {raw}"
);
std::fs::write(
&info_path,
raw.replace("\"counters_dirty\": true", "\"counters_dirty\": false")
.replace("\"counters_dirty\":true", "\"counters_dirty\":false"),
)
.expect("rewrite db.info");
let cleared = std::fs::read_to_string(&info_path).expect("db.info");
let cleared: crate::armour::DbInfo = serde_json::from_str(&cleared).expect("parse db.info");
assert!(
cleared.collections.values().all(|c| !c.counters_dirty),
"no flag may survive, or the reopen could recount instead of replaying"
);
let (_db, _m, groups) = counted_fixture_at(dir.path());
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 1);
}
#[test]
fn counter_recount_flushes_targets_before_returning_so_a_crash_cannot_lose_the_repair() {
if std::env::var("ARMDB_COUNTER_CHILD").is_ok() {
let (db, _members, _groups) = counted_fixture_at(std::path::Path::new(
&std::env::var("ARMDB_COUNTER_DIR").unwrap(),
));
let repaired = db.recount_counters("members").unwrap();
assert_eq!(repaired, 1);
std::process::abort();
}
let dir = tempfile::tempdir().unwrap();
{
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
groups.put(&[1u8; 8], Group { member_count: 99 }).unwrap(); db.close().unwrap();
}
let status = std::process::Command::new(std::env::current_exe().unwrap())
.arg("--exact")
.arg("counter_tests::counter_recount_flushes_targets_before_returning_so_a_crash_cannot_lose_the_repair")
.env("ARMDB_COUNTER_CHILD", "1")
.env("ARMDB_COUNTER_DIR", dir.path())
.status()
.unwrap();
assert!(!status.success(), "the child aborts by design");
let (_db, _m, groups) = counted_fixture_at(dir.path());
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
1,
"recount's own durable_flush made the repair survive the crash"
);
}
#[test]
fn counter_recount_counters_leaves_the_dirty_evidence_in_place() {
let (db, members, groups, _dir) = counted_fixture();
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
assert!(db.debug_counters_dirty("members"), "set when it was opened");
let repaired = db.recount_counters("members").unwrap();
assert_eq!(repaired, 0, "nothing had actually drifted");
assert!(
db.debug_counters_dirty("members"),
"an on-demand recount is not proof the counters are settled"
);
db.close().unwrap();
assert!(!db.debug_counters_dirty("members"));
}
#[test]
fn counter_recount_counters_refuses_a_non_source_and_leaves_its_flag_intact() {
let dir = tempfile::tempdir().unwrap();
{
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
db.close().unwrap();
}
fn zero_member_count(_k: &[u8; 8], _v: &GroupV2) -> crate::MigrateAction<GroupV2> {
crate::MigrateAction::Update(GroupV2 { member_count: 0 })
}
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let _groups = db
.open_typed_map::<GroupV2, RapiraCodec, _>(
Config::test(),
NoHook,
&[(0, zero_member_count)],
)
.unwrap();
assert!(
db.debug_counters_dirty("groups"),
"the migration set the flag"
);
let err = db.recount_counters("groups").unwrap_err();
assert!(
matches!(err, crate::DbError::NotACounterSource { .. }),
"groups declares no counter targets of its own"
);
assert!(
db.debug_counters_dirty("groups"),
"refused, not silently cleared — a later source's open still needs to see this flag"
);
}
#[test]
fn counter_recount_dangling_contribution_does_not_block_open() {
let dir = tempfile::tempdir().unwrap();
crate::projection::FAIL_COLLECTION_FLUSH.store(true, std::sync::atomic::Ordering::SeqCst);
{
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
let _ = members.put(&MemberKey::new([2u8; 8], 1), Member { group: [2u8; 8] });
let _ = db.close();
}
crate::projection::FAIL_COLLECTION_FLUSH.store(false, std::sync::atomic::Ordering::SeqCst);
let (db, _members, groups) = counted_fixture_at(dir.path());
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
1,
"the valid contribution is still counted correctly"
);
assert!(
groups.get(&[2u8; 8]).is_none(),
"the dangling target was never created"
);
db.close().unwrap();
assert!(
!db.debug_counters_dirty("members"),
"this session's own open-time mark was retired by a clean close"
);
}
#[test]
fn counter_dirty_mark_failure_fails_the_open_of_a_counted_source() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let groups = db
.open_typed_map::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("a plain target opens fine — it is not a counted source");
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(
group_counter_targets(&groups),
move |_k: &MemberKey, v: &Member, sink| sink.add(&groups_for_project, v.group, 1),
);
crate::counter_dirty::FAIL_COUNTER_MARK.store(true, std::sync::atomic::Ordering::SeqCst);
let err = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.err()
.expect("a source that cannot record evidence must not start taking writes");
crate::counter_dirty::FAIL_COUNTER_MARK.store(false, std::sync::atomic::Ordering::SeqCst);
assert!(
matches!(
err,
crate::DbError::Internal("injected counters_dirty mark failure")
),
"the mark's own error is what propagates: {err}"
);
assert!(
!db.debug_counters_dirty("members"),
"nothing was persisted — which is exactly why the open had to fail"
);
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(
group_counter_targets(&groups),
move |_k: &MemberKey, v: &Member, sink| sink.add(&groups_for_project, v.group, 1),
);
let members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.expect("open with a working mark");
assert!(db.debug_counters_dirty("members"));
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.expect("put");
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 1);
}
#[test]
fn counter_dirty_on_a_migrated_target_survives_a_close_that_consumed_nothing() {
fn zero_member_count(_k: &[u8; 8], _v: &GroupV2) -> crate::MigrateAction<GroupV2> {
crate::MigrateAction::Update(GroupV2 { member_count: 0 })
}
let dir = tempfile::tempdir().unwrap();
{
let (db, members, groups) = counted_fixture_at(dir.path());
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
members
.put(&MemberKey::new([1u8; 8], 1), Member { group: [1u8; 8] })
.unwrap();
db.close().unwrap();
assert!(
!db.debug_counters_dirty("members"),
"the source's own flag is cleared"
);
}
{
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let groups = db
.open_typed_map::<GroupV2, RapiraCodec, _>(
Config::test(),
NoHook,
&[(0, zero_member_count)],
)
.unwrap();
assert!(
db.debug_counters_dirty("groups"),
"the migration marks the target"
);
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
0,
"the migration destroyed the maintained value"
);
db.close().unwrap();
assert!(
db.debug_counters_dirty("groups"),
"a clean close that consumed nothing must leave the evidence in place"
);
}
let db = Arc::new(Db::open_test(dir.path()).unwrap());
let groups = db
.open_typed_map::<GroupV2, RapiraCodec, _>(
Config::test(),
NoHook,
&[(0, zero_member_count)],
)
.unwrap();
let groups_for_hook = Arc::clone(&groups);
let hook = Projected::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _s| Ok(()),
)
.and_counters(
group_v2_counter_targets(&groups),
move |_k: &MemberKey, v: &Member, sink| sink.add(&groups_for_hook, v.group, 1),
);
let _members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.unwrap();
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
1,
"the surviving target flag drove the recount"
);
}
#[test]
fn counter_mutation_matrix_fixed_map_target_bitcask_source() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let groups = db
.open_zero_map_fixed::<ZeroGroup, { size_of::<ZeroGroup>() }, _>(
crate::FixedConfig::test(),
NoHook,
&[],
)
.expect("open groups");
let counter_targets = zero_group_counter_targets(&groups);
let groups_for_project = Arc::clone(&groups);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _sink| Ok(()),
)
.and_counters(counter_targets, move |_k: &MemberKey, v: &Member, sink| {
sink.add(&groups_for_project, v.group, 1)
});
let members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.expect("open members");
let g1 = [1u8; 8];
let g2 = [2u8; 8];
groups.put(&g1, &ZeroGroup { member_count: 0 }).unwrap();
groups.put(&g2, &ZeroGroup { member_count: 0 }).unwrap();
members
.put(&MemberKey::new(g1, 10), Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), Member { group: g1 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 1);
members
.put(&MemberKey::new(g1, 10), Member { group: g2 })
.unwrap();
assert_eq!(groups.get(&g1).unwrap().member_count, 0);
assert_eq!(groups.get(&g2).unwrap().member_count, 1);
members.delete(&MemberKey::new(g1, 10)).unwrap();
assert_eq!(groups.get(&g2).unwrap().member_count, 0);
groups.put(&g2, &ZeroGroup { member_count: 77 }).unwrap();
members
.put(&MemberKey::new(g2, 11), Member { group: g2 })
.unwrap();
assert_eq!(db.recount_counters("members").unwrap(), 1);
assert_eq!(groups.get(&g2).unwrap().member_count, 1, "recounted");
}
#[derive(Clone, Debug, PartialEq, GetType, GetRefs, Rapira)]
pub(crate) struct Channel {
pub subscriber_count: u64,
}
impl CollectionMeta for Channel {
type SelfId = [u8; 8];
const NAME: &'static str = "channels";
}
pub(crate) type ChannelsMap = crate::typed_map!(Channel, RapiraCodec, NoHook);
fn one_of_fixture_at(
path: &std::path::Path,
) -> (Arc<Db>, Arc<MembersMap>, Arc<GroupsMap>, Arc<ChannelsMap>) {
let db = Arc::new(Db::open_test(path).expect("open db"));
let groups = db
.open_typed_map::<Group, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open groups");
let channels = db
.open_typed_map::<Channel, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open channels");
let targets = CounterTargets::new()
.one_of()
.with(
&groups,
"member_count",
OnMissingTarget::Error,
|g: &Group| g.member_count,
|cur: Option<&Group>, n: u64| match cur {
#[allow(clippy::needless_update)]
Some(g) => Upsert::Put(Group {
member_count: n,
..g.clone()
}),
None => Upsert::Keep,
},
)
.with(
&channels,
"subscriber_count",
OnMissingTarget::Error,
|c: &Channel| c.subscriber_count,
|cur: Option<&Channel>, n: u64| match cur {
#[allow(clippy::needless_update)]
Some(c) => Upsert::Put(Channel {
subscriber_count: n,
..c.clone()
}),
None => Upsert::Keep,
},
)
.build();
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _sink| Ok(()),
)
.and_counters(targets, |_k: &MemberKey, v: &Member, sink| {
sink.add_one_of(v.group, 1)
});
let members = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.expect("open members");
(db, members, groups, channels)
}
#[test]
fn counter_one_of_routes_to_whichever_collection_holds_the_key() {
let dir = tempfile::tempdir().expect("tempdir");
let (_db, members, groups, channels) = one_of_fixture_at(dir.path());
let g = [1u8; 8];
let c = [2u8; 8];
groups.put(&g, Group { member_count: 0 }).unwrap();
channels
.put(
&c,
Channel {
subscriber_count: 0,
},
)
.unwrap();
members
.put(&MemberKey::new(g, 10), Member { group: g })
.unwrap();
members
.put(&MemberKey::new(c, 11), Member { group: c })
.unwrap();
assert_eq!(groups.get(&g).unwrap().member_count, 1, "routed to groups");
assert_eq!(
channels.get(&c).unwrap().subscriber_count,
1,
"routed to channels"
);
assert!(channels.get(&g).is_none());
assert!(groups.get(&c).is_none());
members.delete(&MemberKey::new(c, 11)).unwrap();
assert_eq!(channels.get(&c).unwrap().subscriber_count, 0);
assert_eq!(groups.get(&g).unwrap().member_count, 1, "untouched");
}
#[test]
fn counter_one_of_with_no_holder_fails_the_write_and_warns_in_the_recount() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, members, groups, channels) = one_of_fixture_at(dir.path());
let g = [1u8; 8];
let orphan = [9u8; 8];
groups.put(&g, Group { member_count: 0 }).unwrap();
let Err(err) = members.put(&MemberKey::new(orphan, 10), Member { group: orphan }) else {
panic!("no alternative holds this key");
};
assert!(
matches!(
err,
crate::DbError::ProjectionIncomplete(_) | crate::DbError::CounterTargetMissing { .. }
),
"{err}"
);
assert!(groups.get(&orphan).is_none());
assert!(channels.get(&orphan).is_none());
members
.put(&MemberKey::new(g, 11), Member { group: g })
.unwrap();
let repaired = db
.recount_counters("members")
.expect("a dangling one_of contribution must not abort the recount");
assert_eq!(groups.get(&g).unwrap().member_count, 1);
assert_eq!(repaired, 0, "nothing had drifted; the orphan was skipped");
}
#[test]
fn counter_one_of_with_two_holders_is_ambiguous_on_the_write_path_and_in_the_recount() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, members, groups, channels) = one_of_fixture_at(dir.path());
let both = [3u8; 8];
groups.put(&both, Group { member_count: 0 }).unwrap();
channels
.put(
&both,
Channel {
subscriber_count: 0,
},
)
.unwrap();
let Err(err) = members.put(&MemberKey::new(both, 10), Member { group: both }) else {
panic!("two holders is ambiguous");
};
assert!(
matches!(
err,
crate::DbError::ProjectionIncomplete(_) | crate::DbError::AmbiguousCounterTarget { .. }
),
"{err}"
);
let err = db
.recount_counters("members")
.expect_err("ambiguity is a hard failure in the recount too");
assert!(
matches!(err, crate::DbError::AmbiguousCounterTarget { .. }),
"{err}"
);
}
#[derive(Clone, Debug, PartialEq, GetType, GetRefs, Rapira)]
pub(crate) struct ThreadMeta {
pub reply_count: u64,
}
impl CollectionMeta for ThreadMeta {
type SelfId = [u8; 8];
const NAME: &'static str = "thread_meta";
}
pub(crate) type ThreadMetaMap = crate::typed_map!(ThreadMeta, RapiraCodec, NoHook);
fn thread_meta_fixture_at(
path: &std::path::Path,
on_missing: OnMissingTarget,
) -> (Arc<Db>, Arc<MembersMap>, Arc<ThreadMetaMap>) {
let db = Arc::new(Db::open_test(path).expect("open db"));
let meta = db
.open_typed_map::<ThreadMeta, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open thread_meta");
let targets = CounterTargets::new().with_counter(
&meta,
"reply_count",
on_missing,
|m: &ThreadMeta| m.reply_count,
|cur: Option<&ThreadMeta>, n: u64| match (cur, n) {
(_, 0) => Upsert::Delete,
#[allow(clippy::needless_update)]
(Some(m), n) => Upsert::Put(ThreadMeta {
reply_count: n,
..m.clone()
}),
(None, n) => Upsert::Put(ThreadMeta { reply_count: n }),
},
);
let meta_for_project = Arc::clone(&meta);
let hook = Projected::<MemberKey, Member, NoHook>::new(
IndexTargets::new(),
|_k: &MemberKey, _v: &Member, _sink| Ok(()),
)
.and_counters(targets, move |_k: &MemberKey, v: &Member, sink| {
sink.add(&meta_for_project, v.group, 1)
});
let threads = db
.open_typed_map::<Member, RapiraCodec, _>(Config::test(), hook, &[])
.expect("open threads");
(db, threads, meta)
}
#[test]
fn counter_tx_creates_and_deletes_a_target_row_it_owns() {
let dir = tempfile::tempdir().expect("tempdir");
let t = [1u8; 8];
{
let (db, threads, meta) = thread_meta_fixture_at(dir.path(), OnMissingTarget::Create);
assert!(meta.get(&t).is_none(), "no row before the first reply");
db.tx()
.put(&threads, &MemberKey::new(t, 1), &Member { group: t })
.unwrap()
.put(&threads, &MemberKey::new(t, 2), &Member { group: t })
.unwrap()
.commit()
.unwrap();
assert_eq!(meta.get(&t).unwrap().reply_count, 2);
db.tx()
.delete(&threads, &MemberKey::new(t, 1))
.unwrap()
.delete(&threads, &MemberKey::new(t, 2))
.unwrap()
.commit()
.unwrap();
assert!(
meta.get(&t).is_none(),
"CounterApply::Delete must reach the log record as a delete"
);
db.close().unwrap();
}
let (_db, _threads, meta) = thread_meta_fixture_at(dir.path(), OnMissingTarget::Create);
assert!(meta.get(&t).is_none(), "still gone after reopen");
}
#[test]
fn counter_tx_skips_a_contribution_whose_target_row_is_missing() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, threads, meta) = thread_meta_fixture_at(dir.path(), OnMissingTarget::Skip);
let t = [1u8; 8];
db.tx()
.put(&threads, &MemberKey::new(t, 1), &Member { group: t })
.unwrap()
.commit()
.expect("a Skip target must not fail the commit");
assert!(
meta.get(&t).is_none(),
"Skip writes nothing — not a zero row"
);
meta.put(&t, ThreadMeta { reply_count: 0 }).unwrap();
db.tx()
.put(&threads, &MemberKey::new(t, 2), &Member { group: t })
.unwrap()
.commit()
.unwrap();
assert_eq!(meta.get(&t).unwrap().reply_count, 1);
}
#[test]
fn counter_one_of_cannot_resolve_a_target_created_in_the_same_transaction() {
let dir = tempfile::tempdir().expect("tempdir");
let (db, members, groups, _channels) = one_of_fixture_at(dir.path());
let g = [1u8; 8];
let err = db
.tx()
.put(&groups, &g, &Group { member_count: 0 })
.unwrap()
.put(&members, &MemberKey::new(g, 10), &Member { group: g })
.unwrap()
.commit()
.expect_err("v1: the pending parent row is not visible to `contains`");
assert!(
matches!(err, crate::DbError::CounterTargetMissing { .. }),
"{err}"
);
assert!(groups.get(&g).is_none(), "the whole transaction is refused");
db.tx()
.put(&groups, &g, &Group { member_count: 0 })
.unwrap()
.commit()
.unwrap();
db.tx()
.put(&members, &MemberKey::new(g, 10), &Member { group: g })
.unwrap()
.commit()
.unwrap();
assert_eq!(groups.get(&g).unwrap().member_count, 1);
}
#[test]
fn counter_tx_insert_contributes_once_and_not_at_all_when_refused() {
let (db, members, groups, _dir) = counted_fixture();
groups.put(&[1u8; 8], Group { member_count: 0 }).unwrap();
let k = MemberKey::new([1u8; 8], 1);
db.tx()
.insert(&members, &k, &Member { group: [1u8; 8] })
.unwrap()
.commit()
.unwrap();
assert_eq!(groups.get(&[1u8; 8]).unwrap().member_count, 1);
let err = db
.tx()
.insert(&members, &k, &Member { group: [1u8; 8] })
.unwrap()
.commit()
.unwrap_err();
assert!(matches!(err, crate::DbError::KeyExists), "{err}");
assert_eq!(
groups.get(&[1u8; 8]).unwrap().member_count,
1,
"a refused insert must not move the counter"
);
}