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}