beck_host/sequence.rs
1//! The merge point's rules, with nothing around them.
2//!
3//! ```text
4//! proposals ──▶ [ de-duplicate ─▶ validate ─▶ check storable ─▶ fold ] ──▶ events to append
5//! ▲ against the batch's own
6//! │ speculative state
7//! the answer to a
8//! retry is the first
9//! attempt's position
10//! ```
11//!
12//! Everything §3.7 calls "the single writer" that is not *writing*: which proposals become events,
13//! in what order, and what each proposer is told. A host supplies the queue, the durable append and
14//! the reply channel; those are the parts that differ between a process with a Postgres log and a
15//! browser tab with an array, and they are the parts that are not the semantics.
16//!
17//! Two rules here were learned the expensive way and are the reason this is one function rather
18//! than one per host:
19//!
20//! * **A retry is acknowledged with the position the first attempt got**, never refused. Answering
21//! "duplicate" to a command that is in the log tells a client replaying an offline queue that its
22//! work was rejected, and it takes that work back off the page one card at a time
23//! ([`docs/94`](../../../../../docs/94-the-client-report.md) §94.10).
24//! * **Validation sees the batch it is inside.** `Add(x)` followed by `Toggle(x)` in one batch must
25//! work, so each command is validated against the state the previous ones produced
26//! (`docs/18` §18.5 item 5).
27
28use std::collections::VecDeque;
29
30use beck_core::Value;
31
32use crate::program::{Runtime, Viewer};
33use crate::record::{Instant, Pending, Seq};
34
35/// One client's proposal, as the merge point sees it.
36///
37/// The reply channel is not here: what a host does with a [`Decision`] — a `oneshot`, a returned
38/// frame, a `postMessage` — is the host's business, and is the one part of ingress that genuinely
39/// differs between a server and a tab.
40pub struct Proposal<'a> {
41 /// The idempotency key that makes a retry after a reconnect safe (§4.3).
42 pub id: String,
43 /// When the merge point admitted it — the one place time enters (§3.7), read by the host from
44 /// the clock it was configured with and data from here onwards.
45 pub at: Instant,
46 /// Who is proposing. A `&dyn` rather than a type parameter because the two hosts genuinely
47 /// have different ones: a connection supplies a verified actor with claims, and a test or a tab
48 /// supplies a name.
49 pub actor: &'a dyn Viewer,
50 /// The command, already decoded against the program's own `Command` union.
51 pub command: Value,
52}
53
54/// What one proposal became.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum Decision {
57 /// This id is already in the log. The position is the **first** attempt's, which is what makes
58 /// the answer to a retry an acknowledgement rather than a refusal.
59 Duplicate(Seq),
60 /// Accepted. Its last event lands at `base + offset` once the batch is appended.
61 Accepted { offset: usize },
62 /// Refused, with the reason the program gave — or the reason the boundary gave, for an event
63 /// that cannot be written durably.
64 Refused { why: String },
65}
66
67/// The host's stopwatch.
68///
69/// `beck-host` may not read a clock — a `wasm32-unknown-unknown` build has none, and
70/// `std::time::Instant::now()` on that target is a panic rather than a number — so a host that
71/// wants to know what a fold cost passes one of these in. It is also the reason the fold is the
72/// only thing metered here: it is the one step whose cost is the program's rather than the
73/// machine's.
74pub trait Meter {
75 fn fold(&self, f: &mut dyn FnMut() -> anyhow::Result<Value>) -> anyhow::Result<Value>;
76}
77
78/// A host that is not measuring anything — a tab, a test, a replay.
79pub struct Untimed;
80
81impl Meter for Untimed {
82 fn fold(&self, f: &mut dyn FnMut() -> anyhow::Result<Value>) -> anyhow::Result<Value> {
83 f()
84 }
85}
86
87/// The batch, decided.
88pub struct Committed {
89 /// The accumulator after every accepted event. Speculative: it becomes the application's state
90 /// only once the host's append succeeds.
91 pub state: Value,
92 /// The events to append, in order, at `base + 1 …`.
93 pub pending: Vec<Pending>,
94 /// One per proposal, in the order they were given.
95 pub decisions: Vec<Decision>,
96}
97
98/// The ids this application has already sequenced, and where each landed.
99///
100/// A bounded memory rather than a set: idempotency is a property of a *recent* retry, and a client
101/// that reconnects after the window has moved on gets a second copy of its command — which is the
102/// trade every at-least-once channel makes and is worth saying out loud.
103pub struct Seen {
104 capacity: usize,
105 entries: VecDeque<(String, Seq)>,
106}
107
108impl Seen {
109 pub fn new(capacity: usize) -> Seen {
110 Seen {
111 capacity,
112 entries: VecDeque::with_capacity(capacity.min(1024)),
113 }
114 }
115
116 /// Where this id's command landed, if it is still remembered.
117 pub fn position(&self, id: &str) -> Option<Seq> {
118 self.entries
119 .iter()
120 .find(|(seen, _)| seen == id)
121 .map(|(_, at)| *at)
122 }
123
124 fn remember(&mut self, id: String, at: Seq) {
125 if self.entries.len() >= self.capacity {
126 self.entries.pop_front();
127 }
128 self.entries.push_back((id, at));
129 }
130}
131
132/// Decide a batch: what becomes an event, what each proposer is told, and the state that follows.
133///
134/// Pure with respect to the machine — it reads no clock and touches no store — but not with respect
135/// to `seen`, which is the application's idempotency memory and moves as commands are accepted.
136pub fn sequence(
137 runtime: &Runtime,
138 state: &Value,
139 base: Seq,
140 seen: &mut Seen,
141 batch: Vec<Proposal<'_>>,
142 meter: &dyn Meter,
143) -> Committed {
144 let mut speculative = state.clone();
145 let mut pending: Vec<Pending> = Vec::new();
146 let mut decisions = Vec::with_capacity(batch.len());
147
148 for p in batch {
149 if let Some(at) = seen.position(&p.id) {
150 decisions.push(Decision::Duplicate(at));
151 continue;
152 }
153 let proposal = runtime.proposal(p.actor, p.command);
154 let events = match runtime.validate(&speculative, &proposal) {
155 Ok(events) => events,
156 Err(why) => {
157 decisions.push(Decision::Refused { why });
158 continue;
159 }
160 };
161 if events.is_empty() {
162 decisions.push(Decision::Refused {
163 why: "no events".into(),
164 });
165 continue;
166 }
167 // The state and the events this command would add, held apart until every one of them has
168 // been folded: a command that fails half way through must add nothing at all, and the next
169 // command in the batch must not see the half.
170 let mut failure: Option<String> = None;
171 let mut folded = speculative.clone();
172 let mut added: Vec<Pending> = Vec::with_capacity(events.len());
173 for e in events {
174 let seq = base + pending.len() as u64 + added.len() as u64 + 1;
175 // Checked storable *before* the fold advances, so an event that cannot be written
176 // durably is refused rather than folded into a state the log cannot reproduce. A
177 // rejection here is a program that should not have compiled — `secure::storable` proves
178 // it cannot — but the boundary refuses rather than writing something lossy.
179 if let Err(why) = beck_core::repr::Repr::of(&e) {
180 failure = Some(why.to_string());
181 break;
182 }
183 let env = crate::record::Envelope {
184 seq,
185 at: p.at,
186 actor: p.actor.actor().to_string(),
187 body: e.clone(),
188 };
189 match meter.fold(&mut || runtime.fold(&folded, &env, e.clone())) {
190 Ok(next) => folded = next,
191 Err(err) => {
192 failure = Some(err.to_string());
193 break;
194 }
195 }
196 added.push(Pending {
197 at: p.at,
198 actor: p.actor.actor().to_string(),
199 body: e,
200 });
201 }
202 match failure {
203 Some(why) => decisions.push(Decision::Refused { why }),
204 None => {
205 speculative = folded;
206 pending.extend(added);
207 // The position is `base + pending.len()`: the last event this command produced,
208 // which is the seq its reply carries and the seq a retry will be answered with.
209 let offset = pending.len();
210 seen.remember(p.id, base + offset as u64);
211 decisions.push(Decision::Accepted { offset });
212 }
213 }
214 }
215
216 Committed {
217 state: speculative,
218 pending,
219 decisions,
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn a_retry_is_answered_with_the_position_the_first_attempt_got() {
229 let mut seen = Seen::new(4);
230 seen.remember("k1".into(), 7);
231 assert_eq!(seen.position("k1"), Some(7));
232 assert_eq!(seen.position("k2"), None);
233 }
234
235 /// The memory is bounded, and the oldest id is the one that goes.
236 #[test]
237 fn the_idempotency_memory_forgets_the_oldest_first() {
238 let mut seen = Seen::new(2);
239 seen.remember("a".into(), 1);
240 seen.remember("b".into(), 2);
241 seen.remember("c".into(), 3);
242 assert_eq!(seen.position("a"), None);
243 assert_eq!(seen.position("b"), Some(2));
244 assert_eq!(seen.position("c"), Some(3));
245 }
246}