From 83171090a0ff9cb9aeca3c1f91edff769076a5a7 Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 19:26:29 +0000 Subject: state: limit refused writes per user A write every node would refuse is rolled back by forking the document at the heads before it, under the write lock, at a cost that grows with the document's history. A user could ask for such writes in a loop. Count each user's refused writes over the last minute. Past --refused-writes-per-minute (default 10), its writes are answered 429 without being tried, until the oldest refusal leaves the minute. --- src/state/refused.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/state/refused.rs (limited to 'src/state/refused.rs') diff --git a/src/state/refused.rs b/src/state/refused.rs new file mode 100644 index 0000000..bb37af0 --- /dev/null +++ b/src/state/refused.rs @@ -0,0 +1,59 @@ +//! Writes every node would refuse, counted by user (`AppState::mutate_doc_as`): each is +//! rolled back by forking the document at the heads before it, which costs as much as +//! the document's history, under the write lock. A user may have so many refused within +//! a minute (`--refused-writes-per-minute`); past it, its writes are answered `429` +//! untried until the oldest leave the minute. + +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant}; + +/// How long a refused write counts against its user. +const WINDOW: Duration = Duration::from_secs(60); + +/// Each user's writes refused within the last `WINDOW`, oldest first. +#[derive(Debug, Default)] +pub(crate) struct RefusedWrites(HashMap>); + +impl RefusedWrites { + /// Whether `user` may try another write at `now`: fewer than `limit` of its writes + /// refused within the last minute. + pub(crate) fn allows(&mut self, user: &str, limit: usize, now: Instant) -> bool { + let Some(refused) = self.0.get_mut(user) else { + return true; + }; + while refused + .front() + .is_some_and(|at| now.duration_since(*at) >= WINDOW) + { + refused.pop_front(); + } + if refused.is_empty() { + self.0.remove(user); + return true; + } + refused.len() < limit + } + + /// One of `user`'s writes refused at `now`. + pub(crate) fn note(&mut self, user: &str, now: Instant) { + self.0.entry(user.to_owned()).or_default().push_back(now); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Past the limit within a minute, a user waits; others don't; a minute on, it may. + #[test] + fn refused_writes_per_user() { + let mut refused = RefusedWrites::default(); + let start = Instant::now(); + assert!(refused.allows("alice", 2, start)); + refused.note("alice", start); + refused.note("alice", start); + assert!(!refused.allows("alice", 2, start)); + assert!(refused.allows("bob", 2, start)); + assert!(refused.allows("alice", 2, start + WINDOW)); + } +} -- cgit v1.3.1