#![cfg(all(
feature = "test-faults",
feature = "armour",
feature = "schema",
feature = "typed-tree",
feature = "rapira-codec"
))]
use std::sync::Arc;
use armdb::armour::Db;
use armdb::schema::{
CollectionKind, Finding, RepairOptions, SchemaRegistry, debug_fail_next_namespace_repair,
repair, validate,
};
use armdb::{
ClaimSink, CollectionMeta, Config, DbResult, FormatMigration, IndexTargets, NoHook, Projected,
RapiraCodec, UniqueIndex, UniqueTargets,
};
use armour_core::{Fuid, GetRefs, GetType};
use rapira::Rapira;
use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout};
armour_core::const_hasher_or!(
format_unique_user,
"FMUUSR",
"dev-key-format-unique",
brand = "user"
);
type UserId = Fuid<format_unique_user::Hasher>;
fn id(n: u64) -> UserId {
Fuid::from_be_bytes(n.to_be_bytes())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, FromBytes, IntoBytes, Immutable, KnownLayout)]
#[repr(C)]
pub struct UsernameKey([u8; 24]);
armdb::impl_key_zerocopy!(
UsernameKey,
armour_core::KeyScheme::Typed(&[armour_core::KeyType::Array(24)])
);
impl UsernameKey {
fn new(s: &str) -> Self {
let mut key = [0; 24];
key[..s.len()].copy_from_slice(s.as_bytes());
Self(key)
}
}
armdb::unique_index!(
FormatUsernames,
key = UsernameKey,
name = "format_usernames"
);
#[derive(Clone, Debug, GetRefs, GetType, Rapira)]
struct UserV1 {
username: String,
}
impl CollectionMeta for UserV1 {
type SelfId = UserId;
const NAME: &'static str = "format_unique_users";
const VERSION: u16 = 1;
}
#[derive(Clone, Debug, GetRefs, GetType, Rapira)]
struct UserV2 {
username: String,
migrated: bool,
}
#[derive(Clone, Debug, GetRefs, GetType, Rapira)]
struct LegacyUser {
username: Option<String>,
}
impl CollectionMeta for LegacyUser {
type SelfId = UserId;
const NAME: &'static str = "format_unique_legacy_users";
}
#[derive(Clone, Debug, GetRefs, GetType, Rapira)]
struct ForeignUser;
impl CollectionMeta for ForeignUser {
type SelfId = UserId;
const NAME: &'static str = "format_unique_foreign_users";
}
const USER_FORMAT: FormatMigration = armdb::format_step!(
1,
UserV1 => UserV2,
RapiraCodec,
|old| Ok::<_, armdb::DbError>(Some(UserV2 { username: old.username, migrated: true }))
);
impl CollectionMeta for UserV2 {
type SelfId = UserId;
const NAME: &'static str = "format_unique_users";
const VERSION: u16 = 2;
const FORMAT_MIGRATIONS: &'static [FormatMigration] = &[USER_FORMAT];
}
fn open_usernames(db: &Arc<Db>) -> Arc<UniqueIndex<FormatUsernames>> {
db.open_unique_index(armdb::FixedConfig::test())
.expect("open namespace")
}
fn open_v1(
db: &Arc<Db>,
usernames: &Arc<UniqueIndex<FormatUsernames>>,
) -> Arc<armdb::typed_map!(UserV1, RapiraCodec, Projected<UserId, UserV1, NoHook>)> {
let namespace = Arc::clone(usernames);
let hook =
Projected::<UserId, UserV1, NoHook>::new(IndexTargets::new(), |_id, _user, _sink| Ok(()))
.and_unique(
UniqueTargets::new().with(usernames),
move |_id, user: &UserV1, sink: &mut ClaimSink<'_>| {
sink.claim(&namespace, UsernameKey::new(&user.username))
},
);
db.open_typed_map(Config::test(), hook, &[])
.expect("open v1")
}
fn open_v2(
db: &Arc<Db>,
usernames: &Arc<UniqueIndex<FormatUsernames>>,
) -> Arc<armdb::typed_map!(UserV2, RapiraCodec, Projected<UserId, UserV2, NoHook>)> {
let namespace = Arc::clone(usernames);
let hook =
Projected::<UserId, UserV2, NoHook>::new(IndexTargets::new(), |_id, _user, _sink| Ok(()))
.and_unique(
UniqueTargets::new().with(usernames),
move |_id, user: &UserV2, sink: &mut ClaimSink<'_>| {
sink.claim(&namespace, UsernameKey::new(&user.username))
},
);
db.open_typed_map(Config::test(), hook, &[])
.expect("open v2")
}
#[test]
fn format_migration_unique_drift_requires_registry_repair_before_it_clears() {
let dir = tempfile::tempdir().expect("tempdir");
{
let db = Arc::new(Db::open_test(dir.path()).expect("open v1 db"));
let usernames = open_usernames(&db);
let users = open_v1(&db, &usernames);
db.tx()
.put(
&users,
&id(1),
&UserV1 {
username: "rail".into(),
},
)
.expect("stage")
.commit()
.expect("commit");
assert!(
usernames.contains(&UsernameKey::new("rail")),
"sanity: claim exists"
);
db.close().expect("close v1");
}
let db = Arc::new(Db::open_test(dir.path()).expect("open v2 db"));
let usernames = open_usernames(&db);
let users = open_v2(&db, &usernames);
assert_eq!(users.get(&id(1)).expect("migrated row").username, "rail");
assert!(
db.unique_dirty(UserV2::NAME),
"format migration records conservative drift"
);
let mut registry = SchemaRegistry::new();
registry.register_external(usernames.name(), CollectionKind::UniqueIndex);
let namespace = Arc::clone(&usernames);
let _ = registry.unique_index::<UserV2, _>(
UniqueTargets::new().with(&usernames),
move |_, user: &UserV2, sink: &mut ClaimSink<'_>| -> DbResult<()> {
sink.claim(&namespace, UsernameKey::new(&user.username))
},
);
registry.register_entity(&users);
let registry = registry.finish().expect("registry");
let report = validate(&db, ®istry);
assert!(report.findings.iter().any(|finding| matches!(
finding,
Finding::UniqueDirty { collection } if collection == UserV2::NAME
) && finding.is_error()));
repair(&db, ®istry, RepairOptions::default()).expect("repair");
assert!(
!db.unique_dirty(UserV2::NAME),
"complete registered repair clears marker"
);
}
#[test]
fn seeded_unique_dirty_repair_failure_keeps_dirty_marker() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let usernames = open_usernames(&db);
let users = open_v1(&db, &usernames);
db.db_info_update(|info| {
info.collections
.get_mut(UserV1::NAME)
.expect("opened source")
.unique_dirty = true;
})
.expect("mark dirty");
let mut registry = SchemaRegistry::new();
registry.register_external(usernames.name(), CollectionKind::UniqueIndex);
let namespace = Arc::clone(&usernames);
let _ = registry.unique_index::<UserV1, _>(
UniqueTargets::new().with(&usernames),
move |_, user: &UserV1, sink: &mut ClaimSink<'_>| -> DbResult<()> {
sink.claim(&namespace, UsernameKey::new(&user.username))
},
);
registry.register_entity(&users);
let registry = registry.finish().expect("registry");
debug_fail_next_namespace_repair();
let err = repair(&db, ®istry, RepairOptions::default()).expect_err("injected failure");
assert!(format!("{err}").contains("injected namespace repair failure"));
assert!(
db.unique_dirty(UserV1::NAME),
"failed repair must not retire marker"
);
}
#[test]
fn seeded_unique_dirty_corrupt_claim_keeps_dirty_marker() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let usernames = open_usernames(&db);
let users = open_v1(&db, &usernames);
db.tx()
.put(
&users,
&id(1),
&UserV1 {
username: "rail".into(),
},
)
.expect("stage")
.commit()
.expect("commit");
armdb::testing::force_put(
&usernames,
&UsernameKey::new("rail"),
armdb::testing::corrupt_unique_row(),
)
.expect("corrupt claimed namespace row");
db.db_info_update(|info| {
info.collections
.get_mut(UserV1::NAME)
.expect("opened source")
.unique_dirty = true;
})
.expect("mark dirty");
let mut registry = SchemaRegistry::new();
registry.register_external(usernames.name(), CollectionKind::UniqueIndex);
let namespace = Arc::clone(&usernames);
let _ = registry.unique_index::<UserV1, _>(
UniqueTargets::new().with(&usernames),
move |_, user: &UserV1, sink: &mut ClaimSink<'_>| -> DbResult<()> {
sink.claim(&namespace, UsernameKey::new(&user.username))
},
);
registry.register_entity(&users);
let registry = registry.finish().expect("registry");
repair(&db, ®istry, RepairOptions::default()).expect("repair returns finding state");
assert!(
db.unique_dirty(UserV1::NAME),
"a corrupt namespace row covering this source's claim must block marker retirement"
);
}
#[test]
fn seeded_unique_dirty_renamed_username_stale_claim_keeps_dirty_until_deleted() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let usernames = open_usernames(&db);
let users = db
.open_typed_map::<UserV1, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open users without enforcing hook");
users
.put(
&id(1),
UserV1 {
username: "new-name".into(),
},
)
.expect("write renamed source row");
let owner = armdb::testing::unique_owner_for::<UserV1>(armdb::Key::as_bytes(&id(1)));
armdb::testing::force_put(
&usernames,
&UsernameKey::new("old-name"),
armdb::testing::unique_row_for(owner),
)
.expect("seed stale old username claim");
db.db_info_update(|info| {
info.collections
.get_mut(UserV1::NAME)
.expect("opened source")
.unique_dirty = true;
})
.expect("mark unique dirty");
let mut registry = SchemaRegistry::new();
registry.register_external(usernames.name(), CollectionKind::UniqueIndex);
registry.complete_namespace(&usernames);
let namespace = Arc::clone(&usernames);
let _ = registry.unique_index::<UserV1, _>(
UniqueTargets::new().with(&usernames),
move |_, user: &UserV1, sink: &mut ClaimSink<'_>| -> DbResult<()> {
sink.claim(&namespace, UsernameKey::new(&user.username))
},
);
registry.register_entity(&users);
let registry = registry.finish().expect("registry");
let report = repair(&db, ®istry, RepairOptions::default()).expect("default repair");
assert!(usernames.contains(&UsernameKey::new("new-name")));
assert!(usernames.contains(&UsernameKey::new("old-name")));
assert_eq!(report.per_namespace[0].declined_orphans, 1, "{report:?}");
assert!(
db.unique_dirty(UserV1::NAME),
"an unresolved stale claim must retain its source's repair obligation"
);
repair(
&db,
®istry,
RepairOptions {
delete_orphans: true,
},
)
.expect("authoritative destructive repair");
assert!(!usernames.contains(&UsernameKey::new("old-name")));
assert!(
!db.unique_dirty(UserV1::NAME),
"the marker clears only after the authoritative namespace is clean"
);
}
#[test]
fn seeded_unique_dirty_three_source_conflict_keeps_every_source_dirty() {
let dir = tempfile::tempdir().expect("tempdir");
let db = Arc::new(Db::open_test(dir.path()).expect("open db"));
let usernames = open_usernames(&db);
let users = db
.open_typed_map::<UserV1, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open current users");
let legacy = db
.open_typed_map::<LegacyUser, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open legacy users");
let foreign = db
.open_typed_map::<ForeignUser, RapiraCodec, _>(Config::test(), NoHook, &[])
.expect("open foreign users");
users
.put(
&id(1),
UserV1 {
username: "rail".into(),
},
)
.expect("write current claimant");
legacy
.put(
&id(2),
LegacyUser {
username: Some("rail".into()),
},
)
.expect("write second claimant");
let legacy_key = id(9);
let foreign_owner =
armdb::testing::unique_owner_for::<ForeignUser>(armdb::Key::as_bytes(&legacy_key));
armdb::testing::force_put(
&usernames,
&UsernameKey::new("rail"),
armdb::testing::unique_row_for(foreign_owner),
)
.expect("seed foreign owner");
db.db_info_update(|info| {
info.collections
.get_mut(UserV1::NAME)
.expect("current source")
.unique_dirty = true;
info.collections
.get_mut(LegacyUser::NAME)
.expect("second claimant")
.unique_dirty = true;
info.collections
.get_mut(ForeignUser::NAME)
.expect("foreign source")
.unique_dirty = true;
})
.expect("mark both sources dirty");
let mut registry = SchemaRegistry::new();
registry.register_external(usernames.name(), CollectionKind::UniqueIndex);
registry.complete_namespace(&usernames);
let user_ns = Arc::clone(&usernames);
let _ = registry.unique_index::<UserV1, _>(
UniqueTargets::new().with(&usernames),
move |_, user: &UserV1, sink: &mut ClaimSink<'_>| -> DbResult<()> {
sink.claim(&user_ns, UsernameKey::new(&user.username))
},
);
let legacy_ns = Arc::clone(&usernames);
let _ = registry.unique_index::<LegacyUser, _>(
UniqueTargets::new().with(&usernames),
move |_, user: &LegacyUser, sink: &mut ClaimSink<'_>| -> DbResult<()> {
if let Some(username) = &user.username {
sink.claim(&legacy_ns, UsernameKey::new(username))?;
}
Ok(())
},
);
registry.register_entity(&users);
registry.register_entity(&legacy);
registry.register_entity(&foreign);
let registry = registry.finish().expect("registry");
let report = repair(
&db,
®istry,
RepairOptions {
delete_orphans: true,
},
)
.expect("repair reports conflict");
let stats = report
.per_namespace
.iter()
.find(|stats| stats.namespace == usernames.name())
.expect("namespace stats");
assert_eq!(stats.conflicts.len(), 1, "{report:?}");
assert!(
db.unique_dirty(UserV1::NAME),
"first claimant remains unresolved"
);
assert!(
db.unique_dirty(LegacyUser::NAME),
"second claimant remains unresolved"
);
assert!(
db.unique_dirty(ForeignUser::NAME),
"the registered dirty foreign stored owner also remains unresolved"
);
}