statserv: persist per-channel activity across restart too
All checks were successful
CI / check (push) Successful in 3m49s

The earlier StatsSet fix persisted only the shared namespaced counters. The
per-channel BOTSTATS view (lines seen + top talkers, from /statserv #chan) lived
on the LiveChannel struct, which is rebuilt from each burst — so it reset to zero
on restart. Move it to a name-keyed Network.chan_activity map (independent of live
membership, seedable before the burst) and carry it in the StatsSet snapshot
(new serde-default `channels` field) alongside the counters. Reworded the
notice from 'seen this session' now that it persists.
This commit is contained in:
Jean Chevronnet 2026-07-16 17:50:11 +00:00
parent 8a9b6a37ca
commit 1f6a11dba2
No known key found for this signature in database
8 changed files with 91 additions and 26 deletions

View file

@ -136,9 +136,15 @@ pub enum Event {
// by minting a fake one on `sid`), so Local scope but still persisted.
JupeAdded { name: String, sid: String, reason: String },
JupeRemoved { name: String },
// A full snapshot of the shared stat counters (StatServ / gRPC Stats),
// written periodically and on shutdown so they survive a restart.
StatsSet { counters: Vec<(String, u64)> },
// A full snapshot of the stats (StatServ / gRPC Stats), written periodically
// and on shutdown so they survive a restart: shared namespaced counters, plus
// per-channel activity (channel, line count, top talkers). `channels` defaults
// for log entries written before it existed.
StatsSet {
counters: Vec<(String, u64)>,
#[serde(default)]
channels: super::ChanStats,
},
}
// Whether an event replicates across the federation. Account identity is Global
@ -627,9 +633,10 @@ pub(crate) fn apply(accounts: &mut HashMap<String, Account>, channels: &mut Hash
Event::JupeRemoved { name } => {
net.jupes.retain(|j| !j.name.eq_ignore_ascii_case(&name));
}
Event::StatsSet { counters } => {
Event::StatsSet { counters, channels } => {
// A full snapshot: replace, so replaying the newest wins.
net.stats = counters.into_iter().collect();
net.chan_stats = channels;
}
Event::AccountExpiryWarned { account } => {
if let Some(a) = accounts.get_mut(&key(&account)) {

View file

@ -282,6 +282,9 @@ pub struct Report {
// The network-wide lists that aren't accounts/channels/grouped/bots: the network
// bans and the news items. Bundled so `apply` threads one parameter for them
// (and stays within its argument budget as more are added).
// Persisted per-channel BOTSTATS activity: (channel, line count, top talkers).
pub type ChanStats = Vec<(String, u64, Vec<(String, u64)>)>;
#[derive(Debug, Clone, Default)]
pub struct NetData {
pub akills: Vec<Akill>,
@ -310,6 +313,8 @@ pub struct NetData {
// Persisted shared stat counters (StatServ / gRPC Stats), snapshotted so they
// survive a restart (live gauges are re-derived, not stored here).
pub stats: std::collections::BTreeMap<String, u64>,
// Persisted per-channel BOTSTATS activity: (channel, lines, top talkers).
pub chan_stats: ChanStats,
}
// A session-limit exception: an IP-mask glob and the session allowance for IPs
@ -1168,8 +1173,11 @@ impl Db {
if self.net.default_bot.is_some() {
snapshot.push(Event::DefaultBotSet { bot: self.net.default_bot.clone() });
}
if !self.net.stats.is_empty() {
snapshot.push(Event::StatsSet { counters: self.net.stats.iter().map(|(k, v)| (k.clone(), *v)).collect() });
if !self.net.stats.is_empty() || !self.net.chan_stats.is_empty() {
snapshot.push(Event::StatsSet {
counters: self.net.stats.iter().map(|(k, v)| (k.clone(), *v)).collect(),
channels: self.net.chan_stats.clone(),
});
}
for host in &self.host_cfg.offers {
snapshot.push(Event::VhostOfferAdded { host: host.clone() });

View file

@ -250,10 +250,24 @@ impl Db {
self.net.stats.clone()
}
/// Snapshot the live stat counters to the log so they survive a restart.
pub fn persist_stats(&mut self, counters: &std::collections::BTreeMap<String, u64>) -> std::io::Result<()> {
self.log.append(Event::StatsSet { counters: counters.iter().map(|(k, v)| (k.clone(), *v)).collect() })?;
/// The persisted per-channel activity, to seed BOTSTATS on startup.
pub fn persisted_chan_stats(&self) -> ChanStats {
self.net.chan_stats.clone()
}
/// Snapshot the live stats (shared counters + per-channel activity) to the log
/// so they survive a restart.
pub fn persist_stats(
&mut self,
counters: &std::collections::BTreeMap<String, u64>,
chan_stats: ChanStats,
) -> std::io::Result<()> {
self.log.append(Event::StatsSet {
counters: counters.iter().map(|(k, v)| (k.clone(), *v)).collect(),
channels: chan_stats.clone(),
})?;
self.net.stats = counters.clone();
self.net.chan_stats = chan_stats;
Ok(())
}

View file

@ -532,13 +532,15 @@
let p = tmp("statspersist");
let counters: std::collections::BTreeMap<String, u64> =
[("chanserv.register".to_string(), 7u64), ("nickserv.identify".to_string(), 42u64)].into_iter().collect();
let chans = vec![("#devs".to_string(), 6u64, vec![("reverse".to_string(), 5u64), ("Keiko".to_string(), 1u64)])];
{
let mut db = Db::open(&p, "N1");
db.persist_stats(&counters).unwrap();
db.persist_stats(&counters, chans.clone()).unwrap();
assert_eq!(db.persisted_stats(), counters, "stored while live");
}
let db = Db::open(&p, "N1");
assert_eq!(db.persisted_stats(), counters, "counters replay from the log after a restart");
assert_eq!(db.persisted_chan_stats(), chans, "per-channel activity replays too");
}
// Fold parity: the live state after a broad sequence of writes must equal the