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}