aboutsummaryrefslogtreecommitdiffstats
path: root/src/state/refused.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 19:26:29 +0000
committertv <tv@krebsco.de>2026-11-08 20:27:20 +0100
commit83171090a0ff9cb9aeca3c1f91edff769076a5a7 (patch)
tree4a64910e841247649a516d7158acb7f87d65a251 /src/state/refused.rs
parentc9c7807bf0f068474ce5c853244bcb392bdfcce3 (diff)
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.
Diffstat (limited to 'src/state/refused.rs')
-rw-r--r--src/state/refused.rs59
1 files changed, 59 insertions, 0 deletions
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<String, VecDeque<Instant>>);
+
+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));
+ }
+}