db: compaction was silently dropping channel and jupe state
All checks were successful
CI / check (push) Successful in 3m52s

compact() rebuilds channels field-by-field, and it omitted several fields, so
automatic log compaction (should_compact -> compact) silently deleted them:
channel url, email, successor, noexpire, the restricted/noautoop/bot_greet/
nobot settings, and every jupe. Emits them all now (settings gated on !=
default via a new PartialEq). Adds a compaction round-trip property test
(compact()+reopen == before) alongside the fold-parity one; both share the
broad build/snapshot helpers.
This commit is contained in:
Jean Chevronnet 2026-07-16 11:08:55 +00:00
parent 8c55347d80
commit 289470ca0c
No known key found for this signature in database
2 changed files with 88 additions and 47 deletions

View file

@ -362,7 +362,7 @@ pub struct AjoinEntry {
// A channel's on/off options (ChanServ SET). Typed, not a bag of string flags, // A channel's on/off options (ChanServ SET). Typed, not a bag of string flags,
// so a new option is one field and the compiler proves every place handles it. // so a new option is one field and the compiler proves every place handles it.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChanSettings { pub struct ChanSettings {
// Append "(requested by <nick>)" to ChanServ KICK reasons. // Append "(requested by <nick>)" to ChanServ KICK reasons.
#[serde(default)] #[serde(default)]
@ -1101,7 +1101,19 @@ impl Db {
if !c.entrymsg.is_empty() { if !c.entrymsg.is_empty() {
snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() }); snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() });
} }
if c.settings.signkick || c.settings.private || c.settings.peace || c.settings.secureops || c.settings.keeptopic || c.settings.topiclock { if !c.url.is_empty() {
snapshot.push(Event::ChannelUrlSet { channel: c.name.clone(), url: c.url.clone() });
}
if !c.email.is_empty() {
snapshot.push(Event::ChannelEmailSet { channel: c.name.clone(), email: c.email.clone() });
}
if let Some(s) = &c.successor {
snapshot.push(Event::ChannelSuccessorSet { channel: c.name.clone(), successor: Some(s.clone()) });
}
if c.noexpire {
snapshot.push(Event::ChannelNoExpire { channel: c.name.clone(), on: true });
}
if c.settings != ChanSettings::default() {
snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings }); snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings });
} }
if !c.topic.is_empty() { if !c.topic.is_empty() {
@ -1182,6 +1194,9 @@ impl Db {
for e in &self.net.sess_exceptions { for e in &self.net.sess_exceptions {
snapshot.push(Event::SessionExceptionAdded { mask: e.mask.clone(), limit: e.limit, reason: e.reason.clone() }); snapshot.push(Event::SessionExceptionAdded { mask: e.mask.clone(), limit: e.limit, reason: e.reason.clone() });
} }
for j in &self.net.jupes {
snapshot.push(Event::JupeAdded { name: j.name.clone(), sid: j.sid.clone(), reason: j.reason.clone() });
}
self.log.compact(snapshot)?; self.log.compact(snapshot)?;
tracing::info!(before, after = self.log.len(), "compacted event log"); tracing::info!(before, after = self.log.len(), "compacted event log");
Ok(()) Ok(())

View file

@ -480,45 +480,44 @@
// Fold parity: the live state after a broad sequence of writes must equal the // Fold parity: the live state after a broad sequence of writes must equal the
// state rebuilt by replaying the same log — every event's manual live mutation // state rebuilt by replaying the same log — every event's manual live mutation
// has to match its `apply()` arm, or a restart silently changes the data. // has to match its `apply()` arm, or a restart silently changes the data.
#[test] // A canonical Debug snapshot of every persisted collection, sorted so the
fn live_state_matches_replay_after_a_broad_sequence() { // string is order-independent. Shared by the fold-parity and compaction tests.
let p = tmp("foldparity"); fn broad_snapshot(db: &Db) -> String {
let snapshot = |db: &Db| -> String { let mut a: Vec<_> = db.accounts().cloned().collect();
let mut a: Vec<_> = db.accounts().cloned().collect(); a.sort_by(|x, y| x.name.cmp(&y.name));
a.sort_by(|x, y| x.name.cmp(&y.name)); let mut c: Vec<_> = db.channels().cloned().collect();
let mut c: Vec<_> = db.channels().cloned().collect(); c.sort_by(|x, y| x.name.cmp(&y.name));
c.sort_by(|x, y| x.name.cmp(&y.name)); let mut b: Vec<_> = db.bots().cloned().collect();
let mut b: Vec<_> = db.bots().cloned().collect(); b.sort_by(|x, y| x.nick.cmp(&y.nick));
b.sort_by(|x, y| x.nick.cmp(&y.nick)); let mut gn = db.groups();
let mut gn = db.groups(); gn.sort();
gn.sort(); let gv: Vec<_> = gn.iter().filter_map(|n| db.group(n)).collect();
let gv: Vec<_> = gn.iter().filter_map(|n| db.group(n)).collect(); let mut ak = db.akills();
let mut ak = db.akills(); ak.sort_by(|x, y| x.mask.cmp(&y.mask));
ak.sort_by(|x, y| x.mask.cmp(&y.mask)); let mut fb = db.forbids();
let mut fb = db.forbids(); fb.sort_by(|x, y| x.mask.cmp(&y.mask));
fb.sort_by(|x, y| x.mask.cmp(&y.mask)); let mut of = db.vhost_offers().to_vec();
let mut of = db.vhost_offers().to_vec(); of.sort();
of.sort(); let mut vf = db.vhost_forbidden().to_vec();
let mut vf = db.vhost_forbidden().to_vec(); vf.sort();
vf.sort(); let mut jp = db.jupes();
let mut jp = db.jupes(); jp.sort();
jp.sort(); let mut nw = db.news("logon");
let mut nw = db.news("logon"); nw.extend(db.news("oper"));
nw.extend(db.news("oper")); let rp = db.reports(false);
let rp = db.reports(false); let hp = db.help_tickets(false);
let hp = db.help_tickets(false); let mut se = db.session_exceptions();
let mut se = db.session_exceptions(); se.sort();
se.sort(); let mut op = db.opers_list();
let mut op = db.opers_list(); op.sort();
op.sort(); let vt = db.vhost_template().map(str::to_string);
let vt = db.vhost_template().map(str::to_string); format!("{a:?}\n{c:?}\n{b:?}\n{gv:?}\n{ak:?}\n{fb:?}\n{of:?}\n{vf:?}\n{jp:?}\n{nw:?}\n{rp:?}\n{hp:?}\n{se:?}\n{op:?}\n{vt:?}")
format!("{a:?}\n{c:?}\n{b:?}\n{gv:?}\n{ak:?}\n{fb:?}\n{of:?}\n{vf:?}\n{jp:?}\n{nw:?}\n{rp:?}\n{hp:?}\n{se:?}\n{op:?}\n{vt:?}") }
};
let live = { // Exercise ~45 event types, ending with a drop that must purge references.
let mut db = Db::open(&p, "N1"); fn build_broad_state(db: &mut Db) {
db.scram_iterations = 4096; db.scram_iterations = 4096;
db.register("alice", "pw", Some("a@x.io".into())).unwrap(); db.register("alice", "pw", Some("a@x.io".into())).unwrap();
db.register("bob", "pw", None).unwrap(); db.register("bob", "pw", None).unwrap();
db.register("carol", "pw", None).unwrap(); db.register("carol", "pw", None).unwrap();
db.register_channel("#chan", "bob").unwrap(); db.register_channel("#chan", "bob").unwrap();
@ -569,17 +568,44 @@
db.help_take(hid, "bob"); db.help_take(hid, "bob");
db.oper_grant("carol", vec!["admin".into()], None); db.oper_grant("carol", vec!["admin".into()], None);
db.session_except_add("*@trusted.host", 10, "trusted"); db.session_except_add("*@trusted.host", 10, "trusted");
db.group_set_founder("!other", "carol").unwrap(); db.group_set_founder("!other", "carol").unwrap();
// The compound one: dropping alice must purge her access, successorship // The compound one: dropping alice must purge her access, successorship
// and group standing — identically in the live path and on replay. // and group standing — identically in the live path and on replay.
db.drop_account("alice").unwrap(); db.drop_account("alice").unwrap();
db.drop_channel("#other").unwrap(); db.drop_channel("#other").unwrap();
snapshot(&db) }
// Fold parity: live state after the sequence == state replayed from the log.
#[test]
fn live_state_matches_replay_after_a_broad_sequence() {
let p = tmp("foldparity");
let live = {
let mut db = Db::open(&p, "N1");
build_broad_state(&mut db);
broad_snapshot(&db)
}; };
let replayed = snapshot(&Db::open(&p, "N1")); let replayed = broad_snapshot(&Db::open(&p, "N1"));
assert_eq!(live, replayed, "live state must equal the state replayed from the log"); assert_eq!(live, replayed, "live state must equal the state replayed from the log");
} }
// Compaction parity: rewriting the log to a snapshot must not lose anything —
// the state after compact()+reopen must equal the state before.
#[test]
fn compaction_preserves_all_state() {
let p = tmp("compactparity");
{
let mut db = Db::open(&p, "N1");
build_broad_state(&mut db);
}
let before = broad_snapshot(&Db::open(&p, "N1"));
{
let mut db = Db::open(&p, "N1");
db.compact().unwrap();
}
let after = broad_snapshot(&Db::open(&p, "N1"));
assert_eq!(before, after, "compaction must preserve every persisted field");
}
// Dropping an account erases every reference to it — channel access and // Dropping an account erases every reference to it — channel access and
// successorship, group membership and foundership — so a later re-registration // successorship, group membership and foundership — so a later re-registration
// of the same name can't inherit its standing. Holds live and after replay. // of the same name can't inherit its standing. Holds live and after replay.