markread: persist account-keyed read markers to disk and restore them at startup

This commit is contained in:
Jean Chevronnet 2026-08-17 02:12:17 +00:00
parent 3f9aa8c6eb
commit 0f28083855
3 changed files with 97 additions and 0 deletions

View file

@ -239,6 +239,10 @@ amu_target = both
# member list AND a restart (its modes, topic, TS and ban lists are saved). ---
# permchannels_database = permchannels.db # default: <conf>.permchannels
# --- read markers (IRCv3 draft/read-marker): account-keyed "last read" positions
# are persisted so they survive a restart, not just reconnects. ---
# markread_database = markread.db # default: <conf>.markread
# --- whoisport: opers see the target's listener port in WHOIS (always on) ---
# --- ircv3_network_icon: advertise a network icon via draft/ICON ISUPPORT ---
# network_icon = https://example.org/icon.png

View file

@ -132,6 +132,7 @@ impl Ircd {
crate::modules::metadata::load(&mut server); // restore channel metadata
crate::modules::reputation::load(&mut server); // restore per-IP reputation
crate::modules::permchannels::load(&mut server); // recreate +P channels (pre-link)
crate::modules::markread::load(&mut server); // restore account-keyed read markers
crate::modules::geoip::init(&mut server); // load the GeoIP database
crate::modules::customprefix::init(&server); // load prefix config
crate::mode::init_custom_prefixes(); // register any config-defined prefix modes

View file

@ -25,6 +25,70 @@ pub fn marker_id(s: &Server, uid: Uid) -> String {
.unwrap_or_else(|| format!("~{uid}"))
}
/// The read-marker database path: the `markread_database` conf key, or `<conf>.markread`.
fn db_path(s: &Server) -> String {
match s.conf("markread_database") {
Some(p) if !p.is_empty() => p.to_string(),
_ => format!("{}.markread", s.conf_path),
}
}
/// Serialise the durable (account-keyed) markers as `identity target ts` lines.
/// Session `~uid` keys are dropped — a uid doesn't outlive the connection, let
/// alone a restart. Output is sorted so it's stable across coalesced writes.
fn dump_markers(m: &ReadMarkers) -> String {
let mut out = String::from("# echoircd read markers — auto-generated; account-keyed only\n");
let mut ids: Vec<&String> = m.0.keys().filter(|k| !k.starts_with('~')).collect();
ids.sort();
for id in ids {
if let Some(targets) = m.0.get(id) {
let mut tk: Vec<&String> = targets.keys().collect();
tk.sort();
for t in tk {
out.push_str(&format!("{id} {t} {}\n", targets[t]));
}
}
}
out
}
/// Parse the on-disk format back into a store (identity/target names carry no
/// spaces, so a 3-way split is unambiguous).
fn load_str(store: &mut ReadMarkers, text: &str) {
for line in text.lines() {
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut it = line.splitn(3, ' ');
if let (Some(id), Some(target), Some(ts)) = (it.next(), it.next(), it.next()) {
if let Ok(ts) = ts.parse::<u64>() {
store
.0
.entry(id.to_string())
.or_default()
.insert(target.to_string(), ts);
}
}
}
}
/// Persist the account-keyed markers. Off-core via `disk_write`, which coalesces
/// repeated writes to the same path, so frequent MARKREADs stay cheap.
pub fn save(s: &Server) {
if let Some(m) = s.ext.get::<ReadMarkers>() {
s.disk_write(db_path(s), dump_markers(m));
}
}
/// Restore account-keyed markers at startup so read positions survive a restart.
pub fn load(s: &mut Server) {
let Ok(text) = std::fs::read_to_string(db_path(s)) else {
return;
};
let store = s.ext.get_or_insert_with::<ReadMarkers>(ReadMarkers::default);
load_str(store, &text);
}
/// Cleanup hook: drop a user's session markers on disconnect (account-keyed
/// markers are intentionally kept so they persist across reconnects).
pub struct MarkRead;
@ -85,6 +149,9 @@ impl Command for MarkReadCmd {
for p in recips {
s.send(p, line.clone());
}
if !id.starts_with('~') {
save(s); // account markers are durable — persist across restarts
}
}
None => {
let val = s
@ -101,3 +168,28 @@ impl Command for MarkReadCmd {
CmdResult::Ok
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markers_round_trip_and_drop_session_keys() {
let mut m = ReadMarkers::default();
m.0.entry("alice".into()).or_default().insert("#chan".into(), 1700);
m.0.entry("alice".into()).or_default().insert("bob".into(), 42);
m.0.entry("~7".into()).or_default().insert("#chan".into(), 9999); // session: ephemeral
let text = dump_markers(&m);
assert!(text.contains("alice #chan 1700"));
assert!(text.contains("alice bob 42"));
assert!(!text.contains("~7"), "session keys must not be persisted");
// reload into a fresh store — the account markers come back, the session one doesn't
let mut restored = ReadMarkers::default();
load_str(&mut restored, &text);
assert_eq!(restored.0.get("alice").and_then(|t| t.get("#chan")), Some(&1700));
assert_eq!(restored.0.get("alice").and_then(|t| t.get("bob")), Some(&42));
assert!(!restored.0.contains_key("~7"));
}
}