Converge remaining gossiped state: node-local news/reports/help and deterministic group registration
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
This commit is contained in:
parent
fef4590f48
commit
e23240bf52
4 changed files with 51 additions and 16 deletions
|
|
@ -93,7 +93,7 @@ pub enum Event {
|
||||||
ReportClosed { id: u64 },
|
ReportClosed { id: u64 },
|
||||||
ReportDeleted { id: u64 },
|
ReportDeleted { id: u64 },
|
||||||
// User groups (GroupServ). Global — groups are account identity.
|
// User groups (GroupServ). Global — groups are account identity.
|
||||||
GroupRegistered { name: String, founder: String, ts: u64 },
|
GroupRegistered { name: String, founder: String, ts: u64, #[serde(default)] home: String },
|
||||||
GroupDropped { name: String },
|
GroupDropped { name: String },
|
||||||
GroupFounderSet { name: String, founder: String },
|
GroupFounderSet { name: String, founder: String },
|
||||||
// Upsert a member (empty flags = a plain member, still present).
|
// Upsert a member (empty flags = a plain member, still present).
|
||||||
|
|
@ -221,19 +221,11 @@ impl Event {
|
||||||
| Event::ForbidRemoved { .. }
|
| Event::ForbidRemoved { .. }
|
||||||
| Event::NotifyAdded { .. }
|
| Event::NotifyAdded { .. }
|
||||||
| Event::NotifyRemoved { .. }
|
| Event::NotifyRemoved { .. }
|
||||||
| Event::NewsAdded { .. }
|
|
||||||
| Event::NewsDeleted { .. }
|
|
||||||
| Event::ReportFiled { .. }
|
|
||||||
| Event::ReportClosed { .. }
|
|
||||||
| Event::ReportDeleted { .. }
|
|
||||||
| Event::GroupRegistered { .. }
|
| Event::GroupRegistered { .. }
|
||||||
| Event::GroupDropped { .. }
|
| Event::GroupDropped { .. }
|
||||||
| Event::GroupFounderSet { .. }
|
| Event::GroupFounderSet { .. }
|
||||||
| Event::GroupFlagsSet { .. }
|
| Event::GroupFlagsSet { .. }
|
||||||
| Event::GroupMemberDel { .. }
|
| Event::GroupMemberDel { .. }
|
||||||
| Event::HelpRequested { .. }
|
|
||||||
| Event::HelpTaken { .. }
|
|
||||||
| Event::HelpClosed { .. }
|
|
||||||
| Event::OperGranted { .. }
|
| Event::OperGranted { .. }
|
||||||
| Event::OperRevoked { .. }
|
| Event::OperRevoked { .. }
|
||||||
| Event::SessionExceptionAdded { .. }
|
| Event::SessionExceptionAdded { .. }
|
||||||
|
|
@ -277,7 +269,19 @@ impl Event {
|
||||||
| Event::JupeAdded { .. }
|
| Event::JupeAdded { .. }
|
||||||
| Event::JupeRemoved { .. }
|
| Event::JupeRemoved { .. }
|
||||||
| Event::StatsSet { .. }
|
| Event::StatsSet { .. }
|
||||||
| Event::IncidentsSet { .. } => Scope::Local,
|
| Event::IncidentsSet { .. }
|
||||||
|
// News and the moderation queues (reports, help tickets) are node-local
|
||||||
|
// operational state: their ids are per-node counters, so gossiping them
|
||||||
|
// would collide id namespaces across nodes and never converge. Keep them
|
||||||
|
// local (like channels) — each node owns its own.
|
||||||
|
| Event::NewsAdded { .. }
|
||||||
|
| Event::NewsDeleted { .. }
|
||||||
|
| Event::ReportFiled { .. }
|
||||||
|
| Event::ReportClosed { .. }
|
||||||
|
| Event::ReportDeleted { .. }
|
||||||
|
| Event::HelpRequested { .. }
|
||||||
|
| Event::HelpTaken { .. }
|
||||||
|
| Event::HelpClosed { .. } => Scope::Local,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -745,10 +749,22 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
|
||||||
Event::ReportDeleted { id } => {
|
Event::ReportDeleted { id } => {
|
||||||
net.reports.retain(|r| r.id != id);
|
net.reports.retain(|r| r.id != id);
|
||||||
}
|
}
|
||||||
Event::GroupRegistered { name, founder, ts } => {
|
Event::GroupRegistered { name, founder, ts, home } => {
|
||||||
let k = key(&name);
|
let k = key(&name);
|
||||||
if !net.groups.iter().any(|g| key(&g.name) == k) {
|
let claim = Group { name, founder, ts, home, members: Vec::new() };
|
||||||
net.groups.push(Group { name, founder, ts, members: Vec::new() });
|
match net.groups.iter().position(|g| key(&g.name) == k) {
|
||||||
|
None => net.groups.push(claim),
|
||||||
|
// Same name claimed on two nodes: keep the earlier registration
|
||||||
|
// (ts, then origin) deterministically so every node converges —
|
||||||
|
// mirrors AccountRegistered / owns_over. Single-node replay never
|
||||||
|
// hits this (one GroupRegistered per live name).
|
||||||
|
Some(i) => {
|
||||||
|
let held = &net.groups[i];
|
||||||
|
let keep_existing = (held.ts, held.home.as_str()) < (claim.ts, claim.home.as_str());
|
||||||
|
if !keep_existing {
|
||||||
|
net.groups[i] = claim;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::GroupDropped { name } => {
|
Event::GroupDropped { name } => {
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,7 @@ pub struct Group {
|
||||||
pub name: String, // "!name", casefolded
|
pub name: String, // "!name", casefolded
|
||||||
pub founder: String,
|
pub founder: String,
|
||||||
pub ts: u64,
|
pub ts: u64,
|
||||||
|
pub home: String, // origin node that registered it, for deterministic conflict resolution
|
||||||
pub members: Vec<GroupMember>,
|
pub members: Vec<GroupMember>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1342,7 +1343,7 @@ impl Db {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for g in &self.net.groups {
|
for g in &self.net.groups {
|
||||||
snapshot.push(Event::GroupRegistered { name: g.name.clone(), founder: g.founder.clone(), ts: g.ts });
|
snapshot.push(Event::GroupRegistered { name: g.name.clone(), founder: g.founder.clone(), ts: g.ts, home: g.home.clone() });
|
||||||
for m in &g.members {
|
for m in &g.members {
|
||||||
snapshot.push(Event::GroupFlagsSet { name: g.name.clone(), account: m.account.clone(), flags: m.flags.clone() });
|
snapshot.push(Event::GroupFlagsSet { name: g.name.clone(), account: m.account.clone(), flags: m.flags.clone() });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -586,8 +586,9 @@ impl Db {
|
||||||
return Err(ChanError::Exists);
|
return Err(ChanError::Exists);
|
||||||
}
|
}
|
||||||
let ts = now();
|
let ts = now();
|
||||||
self.log.append(Event::GroupRegistered { name: name.to_string(), founder: founder.to_string(), ts }).map_err(|_| ChanError::Internal)?;
|
let home = self.log.origin.clone();
|
||||||
self.net.groups.push(Group { name: name.to_string(), founder: founder.to_string(), ts, members: Vec::new() });
|
self.log.append(Event::GroupRegistered { name: name.to_string(), founder: founder.to_string(), ts, home: home.clone() }).map_err(|_| ChanError::Internal)?;
|
||||||
|
self.net.groups.push(Group { name: name.to_string(), founder: founder.to_string(), ts, home, members: Vec::new() });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,23 @@
|
||||||
assert_eq!(converge(&a, &a), "A");
|
assert_eq!(converge(&a, &a), "A");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_registration_conflict_converges_deterministically() {
|
||||||
|
let reg = |founder: &str, ts: u64, home: &str| Event::GroupRegistered { name: "!g".into(), founder: founder.into(), ts, home: home.into() };
|
||||||
|
let converge = |first: Event, second: Event| {
|
||||||
|
let (mut acc, mut ch, mut gr, mut bo, mut hc, mut nd) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), NetData::default());
|
||||||
|
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, first);
|
||||||
|
apply(&mut acc, &mut ch, &mut gr, &mut bo, &mut hc, &mut nd, second);
|
||||||
|
nd.groups[0].founder.clone()
|
||||||
|
};
|
||||||
|
let a = reg("alice", 100, "nodeA");
|
||||||
|
let b = reg("bob", 100, "nodeB");
|
||||||
|
// Two nodes claim "!g": the earlier (ts, then origin) wins, same result in
|
||||||
|
// either apply order — so gossip peers converge instead of diverging.
|
||||||
|
assert_eq!(converge(a.clone(), b.clone()), "alice", "(100,nodeA) beats (100,nodeB)");
|
||||||
|
assert_eq!(converge(b, a), "alice", "commutative: converges to the same founder");
|
||||||
|
}
|
||||||
|
|
||||||
// A gossiped registration that wins the name (earlier ts) moves an account's
|
// A gossiped registration that wins the name (earlier ts) moves an account's
|
||||||
// home. That is a hostile takeover — logging the local user out — ONLY if the
|
// home. That is a hostile takeover — logging the local user out — ONLY if the
|
||||||
// credential differs; re-asserting the SAME account (same verifier) must not.
|
// credential differs; re-asserting the SAME account (same verifier) must not.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue