Skip to main content

superfluid_daemon/
idempotency.rs

1//! `Idempotency-Key`: a client that sends a POST again with the key it sent before gets the
2//! answer the first one got, whole, from its start, however the first one ended. The first
3//! request with a key runs to its end on its own and its response is recorded as it is made;
4//! every client with that key, the first included, reads the recording, so one whose connection
5//! dropped reconnects and reads the rest, and one that comes back after a restart reads a
6//! finished answer from disk. A request a restart cut short runs again under its key.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex};
11
12use axum::body::{Body, Bytes};
13use axum::extract::{Request, State};
14use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
15use axum::middleware::Next;
16use axum::response::{IntoResponse, Response};
17use sha2::{Digest, Sha256};
18use tokio_stream::StreamExt;
19
20pub const HEADER: &str = "idempotency-key";
21
22/// Set on an answer that was recorded for an earlier request with the same key.
23pub const REPLAYED: &str = "idempotent-replayed";
24
25/// How long a finished answer is kept for its key.
26const KEEP: std::time::Duration = std::time::Duration::from_secs(24 * 3600);
27
28/// How often a running server sweeps the answers kept past [`KEEP`] from disk.
29const SWEEP_EVERY: std::time::Duration = std::time::Duration::from_secs(3600);
30
31/// Whether what was done at `t` has been kept past [`KEEP`].
32fn past_keep(t: std::time::SystemTime) -> bool {
33    t.elapsed().is_ok_and(|age| age > KEEP)
34}
35
36/// The largest request body a key is taken with.
37const MAX_BODY: usize = 256 << 20;
38
39/// The headers a recording keeps: the body's framing is the replay's own.
40fn kept(name: &HeaderName) -> bool {
41    !matches!(name.as_str(), "content-length" | "transfer-encoding" | "connection")
42}
43
44/// The answers recorded under each key, live while their requests run and on disk after.
45pub struct Records {
46    dir: Option<PathBuf>,
47    live: Mutex<HashMap<String, Arc<Record>>>,
48    /// When `dir` was last swept.
49    swept: Mutex<Option<std::time::Instant>>,
50}
51
52struct Record {
53    request: [u8; 32],
54    state: Mutex<Recording>,
55    changed: tokio::sync::Notify,
56    /// When it was done, for one kept in memory alone: by the wall clock, as one on disk is aged
57    /// by its file's time.
58    done_at: Mutex<Option<std::time::SystemTime>>,
59}
60
61impl Record {
62    fn new(request: [u8; 32], recording: Recording) -> Arc<Record> {
63        Arc::new(Record { request, state: Mutex::new(recording), changed: tokio::sync::Notify::new(), done_at: Mutex::new(None) })
64    }
65
66    fn expired(&self) -> bool {
67        self.done_at.lock().expect("record").is_some_and(past_keep)
68    }
69}
70
71#[derive(Default, serde::Serialize, serde::Deserialize)]
72struct Recording {
73    status: Option<u16>,
74    headers: Vec<(String, String)>,
75    #[serde(skip)]
76    body: Vec<u8>,
77    done: bool,
78}
79
80#[derive(serde::Serialize, serde::Deserialize)]
81struct Stored {
82    request: String,
83    #[serde(flatten)]
84    recording: Recording,
85}
86
87impl Records {
88    /// Records kept under `dir`, which a restart reads back; in memory alone without one.
89    pub fn new(dir: Option<PathBuf>) -> Records {
90        let swept = Mutex::new(dir.is_some().then(std::time::Instant::now));
91        let records = Records { dir, live: Mutex::new(HashMap::new()), swept };
92        if let Some(dir) = &records.dir {
93            sweep(dir, &records.live);
94        }
95        records
96    }
97
98    /// Sweeps `dir` again, off the request's thread, once [`SWEEP_EVERY`] has passed since the
99    /// last time, so a server that stays up drops what it has kept past [`KEEP`].
100    fn sweep_when_due(self: &Arc<Self>) {
101        let Some(dir) = self.dir.clone() else { return };
102        {
103            let mut swept = self.swept.lock().expect("records");
104            if swept.is_some_and(|t| t.elapsed() < SWEEP_EVERY) {
105                return;
106            }
107            *swept = Some(std::time::Instant::now());
108        }
109        let records = Arc::clone(self);
110        tokio::task::spawn_blocking(move || sweep(&dir, &records.live));
111    }
112
113    fn paths(&self, id: &str) -> Option<(PathBuf, PathBuf)> {
114        self.dir.as_ref().map(|d| (d.join(format!("{id}.json")), d.join(format!("{id}.body"))))
115    }
116
117    /// A finished answer recorded on disk, unless it has been kept past [`KEEP`].
118    fn stored(&self, id: &str) -> Option<(String, Recording)> {
119        let (meta, body) = self.paths(id)?;
120        if std::fs::metadata(&meta).and_then(|m| m.modified()).is_ok_and(past_keep) {
121            return None;
122        }
123        let stored: Stored = serde_json::from_slice(&std::fs::read(meta).ok()?).ok()?;
124        if !stored.recording.done {
125            return None;
126        }
127        let mut recording = stored.recording;
128        recording.body = std::fs::read(body).ok()?;
129        Some((stored.request, recording))
130    }
131
132    fn persist(&self, id: &str, rec: &Record, recording: &Recording) {
133        let Some((meta, _)) = self.paths(id) else { return };
134        let stored = Stored {
135            request: hex(&rec.request),
136            recording: Recording { status: recording.status, headers: recording.headers.clone(), body: Vec::new(), done: recording.done },
137        };
138        if let Ok(bytes) = serde_json::to_vec(&stored) {
139            let _ = std::fs::write(meta, bytes);
140        }
141    }
142
143    fn append(&self, id: &str, bytes: &[u8]) {
144        let Some((_, body)) = self.paths(id) else { return };
145        use std::io::Write;
146        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(body) {
147            let _ = f.write_all(bytes);
148        }
149    }
150
151    fn start(&self, id: &str) {
152        let Some((meta, body)) = self.paths(id) else { return };
153        if let Some(dir) = meta.parent() {
154            let _ = std::fs::create_dir_all(dir);
155        }
156        let _ = std::fs::remove_file(meta);
157        let _ = std::fs::remove_file(body);
158    }
159}
160
161/// Drops what has been kept past [`KEEP`], but not the files of a key in `live`: a request
162/// reusing an expired key takes it there before it clears and rewrites them. With `live` held,
163/// a file is looked at again before it goes, so one rewritten and answered since the first look
164/// (its key out of `live` again) is not taken for the old one.
165fn sweep(dir: &std::path::Path, live: &Mutex<HashMap<String, Arc<Record>>>) {
166    let Ok(rd) = std::fs::read_dir(dir) else { return };
167    for e in rd.flatten() {
168        if e.metadata().and_then(|m| m.modified()).is_ok_and(past_keep) {
169            let path = e.path();
170            #[cfg(test)]
171            if let Some(between) = AFTER_FIRST_LOOK.with(std::cell::Cell::get) {
172                between(&path);
173            }
174            let live = live.lock().expect("records");
175            if path.file_stem().and_then(|s| s.to_str()).is_some_and(|id| live.contains_key(id)) {
176                continue;
177            }
178            if std::fs::metadata(&path).and_then(|m| m.modified()).is_ok_and(past_keep) {
179                let _ = std::fs::remove_file(path);
180            }
181        }
182    }
183}
184
185#[cfg(test)]
186thread_local! {
187    /// Run by this thread's sweeps between the first look at an old file and the locked one.
188    static AFTER_FIRST_LOOK: std::cell::Cell<Option<fn(&std::path::Path)>> = const { std::cell::Cell::new(None) };
189}
190
191fn hex(bytes: &[u8]) -> String {
192    bytes.iter().map(|b| format!("{b:02x}")).collect()
193}
194
195/// A key belongs to the credential it came with, so one client cannot read another's answer.
196fn record_id(headers: &HeaderMap, key: &str) -> String {
197    let mut h = Sha256::new();
198    for name in ["authorization", "x-api-key"] {
199        h.update(headers.get(name).map(HeaderValue::as_bytes).unwrap_or_default());
200        h.update([0]);
201    }
202    h.update(key.as_bytes());
203    hex(&h.finalize())
204}
205
206fn refused(status: StatusCode, message: &str) -> Response {
207    let body = serde_json::json!({"error": {"message": message, "type": "invalid_request_error", "param": HEADER, "code": "idempotency_key"}});
208    (status, axum::Json(body)).into_response()
209}
210
211pub async fn layer(State(records): State<Arc<Records>>, req: Request, next: Next) -> Response {
212    if req.method() != axum::http::Method::POST {
213        return next.run(req).await;
214    }
215    let Some(key) = req.headers().get(HEADER).and_then(|v| v.to_str().ok()).map(str::to_string) else {
216        return next.run(req).await;
217    };
218    if key.is_empty() || key.len() > 255 {
219        return refused(StatusCode::BAD_REQUEST, "an Idempotency-Key is 1 to 255 characters");
220    }
221    let id = record_id(req.headers(), &key);
222    let (parts, body) = req.into_parts();
223    let Ok(bytes) = axum::body::to_bytes(body, MAX_BODY).await else {
224        return refused(StatusCode::PAYLOAD_TOO_LARGE, "a request this large is not taken with an Idempotency-Key");
225    };
226    let request: [u8; 32] = {
227        let mut h = Sha256::new();
228        h.update(parts.uri.path().as_bytes());
229        h.update([0]);
230        h.update(&bytes);
231        h.finalize().into()
232    };
233    let other = || refused(StatusCode::UNPROCESSABLE_ENTITY, "this Idempotency-Key was sent with another request; use a new key for it");
234
235    let live = { records.live.lock().expect("records").get(&id).filter(|r| !r.expired()).cloned() };
236    if let Some(rec) = live {
237        return if rec.request == request { replay(rec, true).await } else { other() };
238    }
239    if let Some((stored, recording)) = records.stored(&id) {
240        if stored != hex(&request) {
241            return other();
242        }
243        return replay(Record::new(request, recording), true).await;
244    }
245
246    // Another request with this key may have got here first.
247    let (rec, first) = {
248        let mut live = records.live.lock().expect("records");
249        live.retain(|_, r| !r.expired());
250        match live.get(&id) {
251            Some(rec) => (Arc::clone(rec), false),
252            None => {
253                let rec = Record::new(request, Recording::default());
254                live.insert(id.clone(), Arc::clone(&rec));
255                (rec, true)
256            }
257        }
258    };
259    if !first {
260        return if rec.request == request { replay(rec, true).await } else { other() };
261    }
262    records.sweep_when_due();
263    records.start(&id);
264    let req = Request::from_parts(parts, Body::from(bytes));
265    let (records2, rec2) = (Arc::clone(&records), Arc::clone(&rec));
266    // Runs to its end whoever is reading, so a client that drops can come back for the rest.
267    tokio::spawn(async move {
268        let resp = next.run(req).await;
269        let (parts, body) = resp.into_parts();
270        {
271            let mut st = rec2.state.lock().expect("recording");
272            st.status = Some(parts.status.as_u16());
273            st.headers = parts
274                .headers
275                .iter()
276                .filter(|(n, _)| kept(n))
277                .filter_map(|(n, v)| Some((n.as_str().to_string(), v.to_str().ok()?.to_string())))
278                .collect();
279            records2.persist(&id, &rec2, &st);
280        }
281        rec2.changed.notify_waiters();
282        let mut stream = body.into_data_stream();
283        while let Some(chunk) = stream.next().await {
284            let Ok(chunk) = chunk else { break };
285            records2.append(&id, &chunk);
286            rec2.state.lock().expect("recording").body.extend_from_slice(&chunk);
287            rec2.changed.notify_waiters();
288        }
289        {
290            let mut st = rec2.state.lock().expect("recording");
291            st.done = true;
292            records2.persist(&id, &rec2, &st);
293            // On disk it is read back from there, where its age is checked, and leaves the live
294            // map before anyone can read that it is done; in memory alone it is kept until it
295            // expires.
296            if records2.dir.is_some() {
297                records2.live.lock().expect("records").remove(&id);
298            } else {
299                *rec2.done_at.lock().expect("record") = Some(std::time::SystemTime::now());
300            }
301        }
302        rec2.changed.notify_waiters();
303    });
304    replay(rec, false).await
305}
306
307/// The recording as a response: its status and headers once they are known, then its body
308/// from the start, followed as it grows until it is done.
309async fn replay(rec: Arc<Record>, again: bool) -> Response {
310    let (status, headers) = loop {
311        let changed = rec.changed.notified();
312        {
313            let st = rec.state.lock().expect("recording");
314            if let Some(status) = st.status {
315                break (status, st.headers.clone());
316            }
317        }
318        changed.await;
319    };
320    let body = futures_util::stream::unfold((rec, 0usize), |(rec, at)| async move {
321        loop {
322            let watch = Arc::clone(&rec);
323            let changed = watch.changed.notified();
324            let ready = {
325                let st = rec.state.lock().expect("recording");
326                if st.body.len() > at {
327                    Some(Some((Bytes::copy_from_slice(&st.body[at..]), st.body.len())))
328                } else if st.done {
329                    Some(None)
330                } else {
331                    None
332                }
333            };
334            match ready {
335                Some(Some((chunk, next))) => return Some((Ok::<_, std::io::Error>(chunk), (rec, next))),
336                Some(None) => return None,
337                None => changed.await,
338            }
339        }
340    });
341    let mut resp = Response::new(Body::from_stream(body));
342    *resp.status_mut() = StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
343    for (n, v) in headers {
344        if let (Ok(n), Ok(v)) = (HeaderName::try_from(n), HeaderValue::try_from(v)) {
345            resp.headers_mut().append(n, v);
346        }
347    }
348    if again {
349        resp.headers_mut().insert(REPLAYED, HeaderValue::from_static("true"));
350    }
351    resp
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use std::sync::atomic::{AtomicU64, Ordering};
358
359    /// A server behind the layer, answering each request that reaches it with how many have.
360    fn server(records: Arc<Records>) -> (String, tokio::runtime::Runtime) {
361        let calls = Arc::new(AtomicU64::new(0));
362        let app = axum::Router::new()
363            .route(
364                "/x",
365                axum::routing::post(move || {
366                    let n = calls.fetch_add(1, Ordering::Relaxed) + 1;
367                    async move { n.to_string() }
368                }),
369            )
370            .layer(axum::middleware::from_fn_with_state(records, layer));
371        let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
372        let listener = rt.block_on(tokio::net::TcpListener::bind("127.0.0.1:0")).unwrap();
373        let addr = listener.local_addr().unwrap().to_string();
374        rt.spawn(async move { axum::serve(listener, app).await });
375        (addr, rt)
376    }
377
378    /// Whether the answer was replayed, and the answer.
379    fn post(addr: &str, key: &str) -> (bool, String) {
380        let agent: ureq::Agent = ureq::Agent::config_builder()
381            .timeout_global(Some(std::time::Duration::from_secs(20)))
382            .http_status_as_error(false)
383            .build()
384            .new_agent();
385        let mut resp = agent.post(&format!("http://{addr}/x")).header(HEADER, key).send("the request").unwrap();
386        (resp.headers().contains_key(REPLAYED), resp.body_mut().read_to_string().unwrap())
387    }
388
389    /// Waits for `what` to hold, for up to 20 s.
390    fn until(what: impl Fn() -> bool) -> bool {
391        let t = std::time::Instant::now();
392        while !what() {
393            if t.elapsed() > std::time::Duration::from_secs(20) {
394                return false;
395            }
396            std::thread::sleep(std::time::Duration::from_millis(5));
397        }
398        true
399    }
400
401    #[test]
402    fn an_answer_kept_in_memory_past_its_day_is_not_replayed() {
403        let records = Arc::new(Records::new(None));
404        let (addr, _rt) = server(Arc::clone(&records));
405        assert_eq!(post(&addr, "k"), (false, "1".into()));
406        let rec = records.live.lock().unwrap().get(&record_id(&HeaderMap::new(), "k")).cloned().unwrap();
407        assert!(until(|| rec.done_at.lock().unwrap().is_some()), "the answer is done");
408        assert_eq!(post(&addr, "k"), (true, "1".into()));
409        // As a day and an hour after it was done.
410        *rec.done_at.lock().unwrap() = Some(std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600));
411        assert_eq!(post(&addr, "k"), (false, "2".into()), "run again, not the answer kept a day ago");
412    }
413
414    #[test]
415    fn a_running_server_sweeps_what_it_kept_past_its_day_from_disk() {
416        let dir = std::env::temp_dir().join(format!("superfluid-idem-sweep-{}", std::process::id()));
417        std::fs::create_dir_all(&dir).unwrap();
418        let records = Arc::new(Records::new(Some(dir.clone())));
419        let (addr, _rt) = server(Arc::clone(&records));
420        // A record no one asks for again, kept a day and an hour ago.
421        let old = [dir.join("old.json"), dir.join("old.body")];
422        for f in &old {
423            std::fs::write(f, "{}").unwrap();
424            let then = std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600);
425            std::fs::File::options().write(true).open(f).unwrap().set_modified(then).unwrap();
426        }
427        // Swept when the server started: not again within the hour.
428        post(&addr, "a");
429        assert!(old.iter().all(|f| f.exists()));
430        // An hour on, the next new key sweeps it.
431        *records.swept.lock().unwrap() = None;
432        post(&addr, "b");
433        assert!(until(|| old.iter().all(|f| !f.exists())), "a record kept past its day is swept while the server runs");
434        assert_eq!(post(&addr, "a"), (true, "1".into()), "what is kept within its day stays");
435        let _ = std::fs::remove_dir_all(&dir);
436    }
437
438    #[test]
439    fn a_sweep_leaves_the_files_of_a_key_being_answered_again() {
440        let dir = std::env::temp_dir().join(format!("superfluid-idem-live-{}", std::process::id()));
441        std::fs::create_dir_all(&dir).unwrap();
442        let records = Records::new(None);
443        // An expired key taken again: in the live map, its files not yet cleared.
444        records.live.lock().unwrap().insert("again".into(), Record::new([0; 32], Recording::default()));
445        let files = [dir.join("again.json"), dir.join("again.body"), dir.join("gone.json"), dir.join("gone.body")];
446        for f in &files {
447            std::fs::write(f, "{}").unwrap();
448            let then = std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600);
449            std::fs::File::options().write(true).open(f).unwrap().set_modified(then).unwrap();
450        }
451        sweep(&dir, &records.live);
452        assert!(files[..2].iter().all(|f| f.exists()), "the key being answered again keeps its files");
453        assert!(files[2..].iter().all(|f| !f.exists()), "a key no one holds is swept");
454        let _ = std::fs::remove_dir_all(&dir);
455    }
456
457    #[test]
458    fn a_sweep_leaves_a_record_answered_again_since_it_first_looked() {
459        // Between the sweep's first look at an expired key's file and its removal, a request takes
460        // the key, rewrites the file, answers and leaves the live map.
461        let dir = std::env::temp_dir().join(format!("superfluid-idem-again-{}", std::process::id()));
462        std::fs::create_dir_all(&dir).unwrap();
463        let records = Records::new(None);
464        let files = [dir.join("again.json"), dir.join("again.body")];
465        for f in &files {
466            std::fs::write(f, "{}").unwrap();
467            let then = std::time::SystemTime::now() - KEEP - std::time::Duration::from_secs(3600);
468            std::fs::File::options().write(true).open(f).unwrap().set_modified(then).unwrap();
469        }
470        fn answered_again(path: &std::path::Path) {
471            std::fs::write(path, "{\"answered\":\"again\"}").unwrap();
472        }
473        AFTER_FIRST_LOOK.with(|f| f.set(Some(answered_again)));
474        sweep(&dir, &records.live);
475        AFTER_FIRST_LOOK.with(|f| f.set(None));
476        assert!(files.iter().all(|f| f.exists()), "a record answered again since the sweep first looked is kept");
477        let _ = std::fs::remove_dir_all(&dir);
478    }
479}