Skip to main content

ojak/
kv.rs

1//! The key-value store Ojak keeps its caches in: a backend the application
2//! picks.
3//!
4//! What Ojak remembers between requests — remote public keys, the IDs of
5//! activities already processed, which signature scheme a host accepts — is
6//! a cache: losing it costs a refetch, never a fact. It lives in a
7//! [`KvStore`], pluggable as in Fedify: *ojak-postgres* keeps it in a table,
8//! and [`MemoryKvStore`] keeps it in memory for tests and for servers with
9//! one process.
10//!
11//! A key is a list of segments, such as `["key", "https://a.example/users/bob#main-key"]`,
12//! so that one store holds every kind of entry without their keys colliding.
13//! A value is JSON, and may be set to expire.
14
15use serde_json::Value;
16use std::collections::HashMap;
17use std::fmt;
18use std::future::Future;
19use std::sync::Mutex;
20use std::time::{Duration, Instant};
21
22/// A failure in the store backend itself.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct KvError(pub String);
25
26impl fmt::Display for KvError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(&self.0)
29    }
30}
31
32impl std::error::Error for KvError {}
33
34/// A key-value store backend.
35///
36/// An entry past its expiry is absent, whether or not the backend has
37/// removed it yet.
38pub trait KvStore: Send + Sync + 'static {
39    /// The value under `key`.
40    fn get(&self, key: &[&str]) -> impl Future<Output = Result<Option<Value>, KvError>> + Send;
41
42    /// Put `value` under `key`, replacing what was there, to expire after
43    /// `ttl` if given.
44    fn set(
45        &self,
46        key: &[&str],
47        value: Value,
48        ttl: Option<Duration>,
49    ) -> impl Future<Output = Result<(), KvError>> + Send;
50
51    /// Put `value` under `key` only if nothing is there, and say whether it
52    /// was put. Of any number of callers at once, in any number of
53    /// processes, exactly one is told it was: this is what makes "has this
54    /// activity been processed?" safe to ask from two workers.
55    fn insert(
56        &self,
57        key: &[&str],
58        value: Value,
59        ttl: Option<Duration>,
60    ) -> impl Future<Output = Result<bool, KvError>> + Send;
61
62    /// Remove what is under `key`, if anything is.
63    fn delete(&self, key: &[&str]) -> impl Future<Output = Result<(), KvError>> + Send;
64}
65
66/// A [`KvStore`] in memory.
67///
68/// An entry that has expired is removed as the store is written to, not only
69/// when its own key is next asked for: most keys are never asked for again —
70/// an actor seen once, an activity processed once — and a store that waited
71/// for that would grow for as long as its process ran.
72///
73/// [`MemoryKvStore::with_capacity`] also bounds how many entries it holds,
74/// dropping those nearest to expiring first. Everything Ojak keeps here is a
75/// cache, so an entry dropped early costs a refetch, or for an activity's ID,
76/// a duplicate the application's own writes have to tolerate anyway.
77#[derive(Debug, Default)]
78pub struct MemoryKvStore {
79    entries: Mutex<Entries>,
80    capacity: Option<usize>,
81}
82
83#[derive(Debug, Default)]
84struct Entries {
85    map: HashMap<Vec<String>, Entry>,
86    /// Writes since expired entries were last swept out.
87    writes: usize,
88}
89
90#[derive(Debug)]
91struct Entry {
92    value: Value,
93    expires: Option<Instant>,
94}
95
96impl Entry {
97    fn live(&self, now: Instant) -> bool {
98        self.expires.is_none_or(|expires| expires > now)
99    }
100}
101
102/// Sweep at least this rarely, however small the store.
103const SWEEP_EVERY: usize = 1_024;
104
105impl Entries {
106    /// Put an entry, then keep the store to what has not expired and to
107    /// `capacity`. A sweep visits every entry, so it waits until the writes
108    /// since the last one are half as many as the entries: each write pays for
109    /// a bounded share of it.
110    fn put(&mut self, key: Vec<String>, entry: Entry, capacity: Option<usize>) {
111        self.map.insert(key, entry);
112        self.writes += 1;
113        let now = Instant::now();
114        if self.writes >= SWEEP_EVERY.max(self.map.len() / 2) {
115            self.map.retain(|_, entry| entry.live(now));
116            self.writes = 0;
117        }
118        if let Some(capacity) = capacity
119            && self.map.len() > capacity
120        {
121            self.map.retain(|_, entry| entry.live(now));
122            self.shrink_to(capacity - capacity / 10);
123        }
124    }
125
126    /// Drop the entries nearest to expiring until `target` remain. Evicting a
127    /// tenth below capacity at a time, rather than one entry per write, keeps
128    /// eviction's cost off every write once the store is full.
129    fn shrink_to(&mut self, target: usize) {
130        let excess = self.map.len().saturating_sub(target);
131        if excess == 0 {
132            return;
133        }
134        // Never-expiring entries sort last, so they go only when nothing else
135        // is left to drop.
136        let mut by_expiry: Vec<(Option<Instant>, &Vec<String>)> = self
137            .map
138            .iter()
139            .map(|(key, entry)| (entry.expires, key))
140            .collect();
141        by_expiry.select_nth_unstable_by(excess - 1, |a, b| match (a.0, b.0) {
142            (Some(a), Some(b)) => a.cmp(&b),
143            (Some(_), None) => std::cmp::Ordering::Less,
144            (None, Some(_)) => std::cmp::Ordering::Greater,
145            (None, None) => std::cmp::Ordering::Equal,
146        });
147        let doomed: Vec<Vec<String>> = by_expiry[..excess]
148            .iter()
149            .map(|(_, key)| (*key).clone())
150            .collect();
151        for key in doomed {
152            self.map.remove(&key);
153        }
154    }
155}
156
157impl MemoryKvStore {
158    #[must_use]
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    /// A store that holds at most `capacity` entries.
164    ///
165    /// # Panics
166    /// If `capacity` is zero.
167    #[must_use]
168    pub fn with_capacity(capacity: usize) -> Self {
169        assert!(capacity > 0, "a memory kv store holds at least one entry");
170        Self {
171            entries: Mutex::default(),
172            capacity: Some(capacity),
173        }
174    }
175
176    /// How many entries it holds, expired ones not yet swept out included.
177    #[must_use]
178    pub fn len(&self) -> usize {
179        self.lock().map.len()
180    }
181
182    /// Whether it holds no entries at all.
183    #[must_use]
184    pub fn is_empty(&self) -> bool {
185        self.len() == 0
186    }
187
188    fn lock(&self) -> std::sync::MutexGuard<'_, Entries> {
189        self.entries.lock().expect("memory kv lock")
190    }
191}
192
193fn owned(key: &[&str]) -> Vec<String> {
194    key.iter().map(|segment| (*segment).to_owned()).collect()
195}
196
197fn expiry(ttl: Option<Duration>) -> Option<Instant> {
198    ttl.map(|ttl| Instant::now() + ttl)
199}
200
201impl KvStore for MemoryKvStore {
202    async fn get(&self, key: &[&str]) -> Result<Option<Value>, KvError> {
203        let mut entries = self.lock();
204        let key = owned(key);
205        match entries.map.get(&key) {
206            Some(entry) if entry.live(Instant::now()) => Ok(Some(entry.value.clone())),
207            Some(_) => {
208                entries.map.remove(&key);
209                Ok(None)
210            }
211            None => Ok(None),
212        }
213    }
214
215    async fn set(&self, key: &[&str], value: Value, ttl: Option<Duration>) -> Result<(), KvError> {
216        self.lock().put(
217            owned(key),
218            Entry {
219                value,
220                expires: expiry(ttl),
221            },
222            self.capacity,
223        );
224        Ok(())
225    }
226
227    async fn insert(
228        &self,
229        key: &[&str],
230        value: Value,
231        ttl: Option<Duration>,
232    ) -> Result<bool, KvError> {
233        let mut entries = self.lock();
234        let key = owned(key);
235        if entries
236            .map
237            .get(&key)
238            .is_some_and(|entry| entry.live(Instant::now()))
239        {
240            return Ok(false);
241        }
242        entries.put(
243            key,
244            Entry {
245                value,
246                expires: expiry(ttl),
247            },
248            self.capacity,
249        );
250        Ok(true)
251    }
252
253    async fn delete(&self, key: &[&str]) -> Result<(), KvError> {
254        self.lock().map.remove(&owned(key));
255        Ok(())
256    }
257}