aboutsummaryrefslogtreecommitdiffstats
path: root/src/state/refused.rs
blob: b01a84a7aef56b311d838750ccac2e441815b421 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
//! 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`), and the node `NODE_FACTOR` times as many in
//! all; past either, the user's writes (past the node's, those of every user with one
//! refused within the minute) are answered `429` untried until the oldest leave it. A
//! user none of whose writes was refused within the minute writes on.

use std::collections::{HashMap, VecDeque};
use std::time::{Duration, Instant};

/// How long a refused write counts against its user, and the node.
const WINDOW: Duration = Duration::from_secs(60);

/// How many refused writes the node takes within a minute, in all, as many times the
/// limit of one user: ten users can't hold the write lock ten times as long.
pub(crate) const NODE_FACTOR: usize = 6;

/// The writes refused within the last `WINDOW`, oldest first: each user's, and all.
#[derive(Debug, Default)]
pub(crate) struct RefusedWrites {
    by_user: HashMap<String, VecDeque<Instant>>,
    all: VecDeque<Instant>,
}

/// `refused` without those that left the minute before `now`.
fn prune(refused: &mut VecDeque<Instant>, now: Instant) {
    while refused
        .front()
        .is_some_and(|at| now.duration_since(*at) >= WINDOW)
    {
        refused.pop_front();
    }
}

impl RefusedWrites {
    /// Whether `user` may try another write at `now`: fewer than `limit` of its writes
    /// refused within the last minute; and, if any was, fewer than `NODE_FACTOR` times
    /// `limit` of all users'.
    pub(crate) fn allows(&mut self, user: &str, limit: usize, now: Instant) -> bool {
        prune(&mut self.all, now);
        let Some(refused) = self.by_user.get_mut(user) else {
            return true;
        };
        prune(refused, now);
        if refused.is_empty() {
            self.by_user.remove(user);
            return true;
        }
        refused.len() < limit && self.all.len() < limit.saturating_mul(NODE_FACTOR)
    }

    /// One of `user`'s writes refused at `now`.
    pub(crate) fn note(&mut self, user: &str, now: Instant) {
        self.by_user
            .entry(user.to_owned())
            .or_default()
            .push_back(now);
        self.all.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));
    }

    /// Past the node's limit, every user with a write refused within the minute waits;
    /// one without writes on.
    #[test]
    fn refused_writes_per_node() {
        let mut refused = RefusedWrites::default();
        let start = Instant::now();
        // Two each, below a user's limit of three; the node's taken in all.
        for user in 0..3 * NODE_FACTOR / 2 {
            refused.note(&format!("u{user}"), start);
            refused.note(&format!("u{user}"), start);
        }
        assert!(!refused.allows("u0", 3, start));
        assert!(refused.allows("carol", 3, start));
        assert!(refused.allows("u0", 3, start + WINDOW));
    }
}