Skip to main content

nomoreide_remote_protocol/
idempotency.rs

1//! What happens when the same request id arrives twice.
2//!
3//! The rule that matters is the one about *not* retrying. The dangerous failure
4//! in remote machine control is not a command that fails — it is a command that
5//! ran, answered nothing because the socket died, and gets sent again by
6//! something helpful. `restart the database` twice is a different outcome from
7//! `restart the database` once, and no amount of care at the call site prevents
8//! it if the transport is allowed to be helpful.
9//!
10//! So: **the request id is the idempotency key**, a mutation executes at most
11//! once per id, and no layer of this system automatically re-sends a mutation
12//! whose outcome it does not know. Ambiguity is escalated to a human looking at
13//! the machine's real state, which is exactly what a phone is good for.
14//!
15//! Reads are exempt. Re-asking for a service list is free, and pretending
16//! otherwise would mean a phone that scrolled back could not refresh.
17
18use super::device_bound::DeviceBound;
19
20/// What to do with a frame whose id has been seen before inside
21/// [`super::limits::REQUEST_ID_DEDUP_WINDOW`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Disposition {
24    /// Run it. Either the id is new, or the command is a read and repeating it
25    /// costs nothing.
26    Execute,
27    /// The first attempt finished and its answer is still held. Send that
28    /// answer again rather than doing the work twice.
29    ReplayRecordedResponse,
30    /// The first attempt is still running. Refuse — two answers to one id is
31    /// worse than one refusal, because the caller cannot tell them apart.
32    Refuse,
33}
34
35/// Whether a repeat of this command may simply run again.
36///
37/// Reads may; mutations may not. Written against the command rather than a
38/// per-call flag so that adding a variant to the union forces the question to
39/// be answered in [`DeviceBound::mutating`], where it is visible.
40pub fn repeatable(command: &DeviceBound) -> bool {
41    !command.mutating()
42}
43
44/// The state a ledger holds about an id it has already seen.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Seen {
47    /// Never seen inside the window.
48    Never,
49    /// Seen, and still running.
50    InFlight,
51    /// Seen, finished, and the answer is still cached.
52    Answered,
53    /// Seen and finished, but the answer is gone — evicted, or the process
54    /// restarted.
55    AnswerLost,
56}
57
58/// The whole rule, as one function.
59///
60/// A pure decision so it can be tested exhaustively without a ledger, a socket
61/// or a clock; the ledger that supplies [`Seen`] arrives with the connector.
62pub fn decide(command: &DeviceBound, seen: Seen) -> Disposition {
63    match seen {
64        Seen::Never => Disposition::Execute,
65        _ if repeatable(command) => Disposition::Execute,
66        Seen::InFlight => Disposition::Refuse,
67        Seen::Answered => Disposition::ReplayRecordedResponse,
68        // The worst case, and the reason it refuses rather than re-running: the
69        // command definitely executed, and this end no longer knows what
70        // happened. Running it again would be the double mutation.
71        Seen::AnswerLost => Disposition::Refuse,
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::super::device_bound::{Empty, ServiceAction, ServiceActionRequest};
78    use super::*;
79
80    fn a_read() -> DeviceBound {
81        DeviceBound::ServiceList(Empty {})
82    }
83
84    fn a_mutation() -> DeviceBound {
85        DeviceBound::ServiceAction(ServiceActionRequest {
86            service: "api".into(),
87            action: ServiceAction::Restart,
88        })
89    }
90
91    #[test]
92    fn a_read_always_runs_however_often_it_arrives() {
93        for seen in [
94            Seen::Never,
95            Seen::InFlight,
96            Seen::Answered,
97            Seen::AnswerLost,
98        ] {
99            assert_eq!(decide(&a_read(), seen), Disposition::Execute);
100        }
101    }
102
103    #[test]
104    fn a_first_mutation_runs() {
105        assert_eq!(decide(&a_mutation(), Seen::Never), Disposition::Execute);
106    }
107
108    /// The three cases that must never be `Execute`. If any of them ever is, a
109    /// phone with a flaky connection can restart a service twice.
110    #[test]
111    fn a_repeated_mutation_never_runs_again() {
112        for seen in [Seen::InFlight, Seen::Answered, Seen::AnswerLost] {
113            assert_ne!(
114                decide(&a_mutation(), seen),
115                Disposition::Execute,
116                "{seen:?} re-executed a mutation"
117            );
118        }
119    }
120
121    #[test]
122    fn a_finished_mutation_replays_its_answer() {
123        assert_eq!(
124            decide(&a_mutation(), Seen::Answered),
125            Disposition::ReplayRecordedResponse
126        );
127    }
128
129    /// A lost answer is the ambiguous case, and ambiguity refuses.
130    #[test]
131    fn a_mutation_whose_answer_is_gone_refuses() {
132        assert_eq!(decide(&a_mutation(), Seen::AnswerLost), Disposition::Refuse);
133    }
134
135    /// Every command in the union is classified, and the classification agrees
136    /// with the union's own `mutating`. This is the link that keeps a new
137    /// variant from defaulting to "repeatable" by omission.
138    #[test]
139    fn repeatable_is_exactly_the_non_mutating_half() {
140        for command in super::super::fixtures::every_command() {
141            assert_eq!(
142                repeatable(&command),
143                !command.mutating(),
144                "{}",
145                command.kind()
146            );
147        }
148    }
149}