beck_rt/
app.rs

1//! The application: one merge point, one sequencer, one durable fold, N per-session views.
2//!
3//! ```text
4//!  sockets ──▶ ingress channel ──▶ [ validate ─▶ append ─▶ fold ] ──▶ version watch ──▶ diff per subscriber
5//!             (merge_clients:            the single writer;              coalescing:
6//!              the one place time         seq assigned once              a slow client gets
7//!              enters, §3.7)              (§3.7)                         fewer, bigger patches)
8//! ```
9//!
10//! Phase 0 wrote this by hand to find out what it costs; Phase 1 keeps the shape — `docs/18`
11//! §18.7 item 1: "Keep the sequencer shape. One merge point, one writer, group commit, fold under
12//! the same lock as the append. It is simple, it is fast enough, and every property in §18.3.6
13//! depends on it" — and drives it from a *compiled program* instead of from hand-written Rust.
14//!
15//! What changed: `validate`, `apply_event` and `view` are now `Core` the splitter handed over,
16//! prepared by whichever [`beck_core::backend::Backend`] the process chose. Everything else — the
17//! batching, the ordering, the ack-versus-frame protocol rule Phase 0 learned the hard way — is
18//! unchanged, because it was never domain-specific.
19
20use std::sync::atomic::{AtomicU64, Ordering};
21use std::sync::Arc;
22
23use anyhow::{bail, Result};
24use beck_core::Value;
25use tokio::sync::{mpsc, oneshot, watch, RwLock};
26
27use crate::log::{Instant, LogStore, Seq, Snapshot};
28use crate::program::Runtime;
29use crate::telemetry::{telemetry, timed};
30use beck_host::sequence::Seen;
31
32#[derive(Clone, Debug)]
33pub struct AppConfig {
34    /// Snapshot the fold every N events. "`durable` is the entire database administration story".
35    pub snapshot_every: u64,
36    /// Upper bound on group commit: the ingress task drains everything queued up to this many
37    /// commands and appends their events in one statement. Phase 0 measured 11× for this.
38    pub max_batch: usize,
39    /// How many recent command ids to remember for idempotency (§4.3).
40    pub dedup_capacity: usize,
41    /// Whether a subscription maintains its view by delta rather than recomputing it (§5.3).
42    ///
43    /// On by default, because it is what §3.8 asks for and it is ~5× faster per event. It is a
44    /// *switch* rather than a fact because it is also a memory-for-time trade — about 4× the bytes
45    /// a subscription already held for its page
46    /// ([`docs/23-incremental-views-report.md`](../../../../../docs/23-incremental-views-report.md)
47    /// §23.8) — and an operator running a fanout of a hundred thousand idle sessions over a large
48    /// accumulator should be able to decide that differently without recompiling.
49    pub maintain_views: bool,
50    /// Whether `/beck.css` carries the stylesheet the compiler derived from the program's own
51    /// classes, or nothing at all
52    /// ([`docs/104`](../../../../../docs/104-styling-and-the-component-library.md) §104.4's
53    /// `styles = none`).
54    ///
55    /// On by default, because a page whose classes have no rules behind them is not styled. It is
56    /// a *switch* rather than a fact because the sheet is an opinion — a preflight and Tailwind's
57    /// design system — and a deployment that ships its own stylesheet should be able to say so
58    /// without the compiler arguing. Off, `beck_core::style` still runs and `beck explain style`
59    /// still answers; what changes is that nothing is served.
60    pub styles: bool,
61    /// Whether the operators that do not read the session are held **once** for every subscriber
62    /// rather than once per subscriber (§5.3).
63    ///
64    /// On by default. It costs one lock acquisition per render — a read lock, so subscribers do not
65    /// block each other — and saves every subscriber the arrangements below the accumulator that
66    /// are the same computation for all of them. How much that is depends entirely on the program:
67    /// a view that filters by the session immediately below the fold shares almost nothing, and one
68    /// that sorts a public feed and personalises only the greeting shares almost everything
69    /// ([`docs/23-incremental-views-report.md`](../../../../../docs/23-incremental-views-report.md)).
70    ///
71    /// Ignored when `maintain_views` is off: there are no arrangements to share.
72    pub share_arrangements: bool,
73    /// Whether a list of numbers is held as a **column** rather than as boxed values
74    /// ([`beck_core::seq`], [`docs/105`](../../../../../docs/105-the-ecosystem-answer.md) §105.10).
75    ///
76    /// On by default, because it halves what a list of numbers occupies and is the only thing in
77    /// this language a kernel or an Arrow reader can be handed a pointer to. It is a *switch*
78    /// rather than a fact for the reason every switch here is one: nothing observable changes — the
79    /// order, the equality, the digest and the wire bytes are the same either way — so the only
80    /// thing it can be wrong about is a trade, and a deployment that has measured its own trade
81    /// should be able to say so without recompiling.
82    ///
83    /// **Process-wide**, unlike every other field here: a list is built in a hundred places that
84    /// have no configuration in scope, so [`App::start`] applies this to
85    /// [`beck_core::seq::set_columns`] rather than carrying it. Two applications in one process
86    /// therefore share one setting, and the last one started wins — which is stated because it is
87    /// the one way this field differs from its neighbours.
88    pub columns: bool,
89    /// How long the shared dataflow keeps what a subscriber might still ask for.
90    ///
91    /// The default releases the arrangements when the last subscription ends and keeps at most 64
92    /// versions of change history while one is open — but what is actually kept is the oldest
93    /// connected subscriber's lag, so both numbers are ceilings rather than costs. A deployment
94    /// whose clients reconnect constantly wants `release_when_idle` off, and one with slow clients
95    /// and fast events wants a deeper history; neither should have to recompile for it
96    /// ([`docs/23-incremental-views-report.md`](../../../../../docs/23-incremental-views-report.md)
97    /// §23.19 asked for exactly this).
98    ///
99    /// Ignored when `share_arrangements` is off: there is no shared dataflow to retain anything.
100    pub retention: beck_core::engine::Retention,
101    /// Where an envelope's `at` comes from.
102    ///
103    /// A dependency rather than a tunable, and here because the merge point is "the one place time
104    /// enters" (§3.7) and this is the configuration the merge point is built from. F11's constraint
105    /// is that a clock is supplied and never ambient; `beck_core::clock` says why, and says what is
106    /// deliberately not on the seam yet.
107    pub clock: Arc<dyn beck_core::clock::Clock>,
108    /// How a claimed identity becomes a verified one.
109    ///
110    /// A dependency rather than a tunable, for the same reason the clock is one, and here because
111    /// the merge point is where a proposal acquires its actor. `DevIdentity` by default: `beck run`
112    /// on a laptop must not need a secret, and `crate::identity` is where the consequences of that
113    /// default are written down.
114    pub identity: Arc<dyn crate::identity::Identity>,
115    /// How much one actor may turn into permanent storage — F3's channel (b).
116    ///
117    /// A tunable rather than a dependency, and **on by default**, which is what
118    /// [`docs/14`](../../../../../docs/14-review-findings.md) F3 decided: a quota a program has to
119    /// ask for is a quota most programs do not have. [`crate::quota`] is the mechanism, the numbers
120    /// and what the bound is actually worth.
121    pub quota: crate::quota::Quota,
122    /// How large a connection roster this process will hold — D6's presence signal.
123    ///
124    /// A tunable rather than a dependency, and bounded rather than optional, for the reason
125    /// [`crate::presence`] gives: the roster is keyed by a name the client may choose.
126    pub presence: crate::presence::Config,
127    /// How large an awareness roster this process will hold, and how large one contribution may
128    /// be — the two bounds [`crate::awareness`] explains.
129    pub awareness: crate::awareness::Config,
130}
131
132impl Default for AppConfig {
133    fn default() -> Self {
134        Self {
135            snapshot_every: 1000,
136            max_batch: 256,
137            dedup_capacity: 16_384,
138            styles: true,
139            maintain_views: true,
140            share_arrangements: true,
141            columns: true,
142            retention: beck_core::engine::Retention::default(),
143            clock: Arc::new(beck_core::clock::SystemClock),
144            identity: Arc::new(crate::identity::DevIdentity),
145            quota: crate::quota::Quota::default(),
146            presence: crate::presence::Config::default(),
147            awareness: crate::awareness::Config::default(),
148        }
149    }
150}
151
152/// A client's proposal. Transient: de-duplicated by `id`, validated, and discarded (F3 — only
153/// validated events are durably logged, so rejected traffic never becomes permanent storage).
154struct Proposal {
155    id: String,
156    at: Instant,
157    /// The whole viewer rather than its name: `validate` is handed a `Session`, and D6's claims →
158    /// capability mapping is the chokepoint's to use. Only the **name** goes on the envelope
159    /// (`docs/48` §48.6), so what is durable is unchanged.
160    actor: crate::identity::Actor,
161    command: Value,
162    reply: oneshot::Sender<Result<Seq, String>>,
163}
164
165pub struct App {
166    runtime: Arc<Runtime>,
167    store: Arc<dyn LogStore>,
168    state: RwLock<Value>,
169    /// Bumped after every committed batch; subscribers wake on it. `watch` coalesces by design,
170    /// which is exactly the backpressure behaviour a slow connection wants.
171    version: watch::Sender<Seq>,
172    ingress: mpsc::Sender<Proposal>,
173    head: AtomicU64,
174    config: AppConfig,
175    /// §5.3's one shared dataflow: the plan's operators that do not read the session, maintained
176    /// once for every subscription rather than once inside each. Advanced lazily by whichever
177    /// subscriber renders first at a new version, so a process with no subscribers does no view
178    /// work at all.
179    shared: Arc<beck_core::engine::SharedDataflow>,
180    /// F3's per-actor write quota. Held here rather than in the sequencer because it refuses
181    /// *before* the queue: a proposal that will not be admitted should not occupy a slot in it.
182    limit: crate::quota::RateLimit,
183    /// Who is connected — D6's non-durable signal, and the one input to a view that **moves
184    /// without an event**. Held by the application because a connection is the application's, and
185    /// read on every render of a program whose page asks for it.
186    here: Arc<crate::presence::Registry>,
187    /// What everybody is doing — the roster `awareness(f)` reads, held beside `here` because it is
188    /// the same kind of fact about the same connections and moves independently of it.
189    aware: Arc<crate::awareness::Registry>,
190    /// The stylesheet this program's pages need, derived once at startup.
191    ///
192    /// Derived rather than read from disk, for `/beck-bundle.bpk`'s reason one line up: a page
193    /// cannot be served rules for a program this process is not executing. Empty when
194    /// [`AppConfig::styles`] is off.
195    stylesheet: String,
196    /// Set once, when this process is going away. Every subscription watches it.
197    ///
198    /// §5.2 lists "graceful drain (finish folds, snapshot, hand off subscriptions)" among the
199    /// things the runtime must ship, and the last of those three needs a subscription to *end*:
200    /// `http::serve` stops accepting on shutdown, but a websocket that was already accepted is a
201    /// task of its own and went on living for as long as the process did. A client whose server
202    /// has drained should find out and reconnect — which, for a Mode B client, is also the moment
203    /// its offline queue matters (`docs/94` §94.10).
204    draining: watch::Sender<bool>,
205}
206
207impl App {
208    /// Recover from the log, then open ingress.
209    ///
210    /// Recovery is not a special mode: it is the same fold the runtime always runs, started from
211    /// the newest snapshot. A process that has just been SIGKILLed and one that has just been
212    /// deployed take exactly this path.
213    ///
214    /// Takes a prepared [`Runtime`] rather than a `Placed`, because building one requires choosing
215    /// a backend, and that choice belongs to whoever assembles the process — not to the sequencer.
216    pub async fn start(
217        runtime: Runtime,
218        store: Arc<dyn LogStore>,
219        config: AppConfig,
220    ) -> Result<Arc<App>> {
221        // Process-wide, and applied before anything folds: the layout a list gets is decided where
222        // the list is built, and the first thing this does is build the accumulator.
223        beck_core::seq::set_columns(config.columns);
224        let runtime = Arc::new(runtime);
225        let head = store.head().await?;
226        let (state, at) = replay_to(&runtime, store.as_ref(), head).await?;
227        if at != head {
228            bail!("recovery stopped at seq {at} but the log head is {head}");
229        }
230
231        telemetry().head.set(head);
232        // The one line that matters at startup: a pod that was just killed and a pod that was just
233        // deployed take exactly this path, and `seq` says which state it came back to.
234        tracing::info!(seq = head, store = store.kind(), "recovered from the log");
235
236        let (version, _) = watch::channel(head);
237        let (tx, rx) = mpsc::channel::<Proposal>(1024);
238        let shared = runtime.shared_dataflow(config.retention);
239        let runtime_for_styles = runtime.clone();
240        let app = Arc::new(App {
241            runtime,
242            store,
243            state: RwLock::new(state),
244            version,
245            ingress: tx,
246            head: AtomicU64::new(head),
247            config: config.clone(),
248            shared,
249            limit: crate::quota::RateLimit::new(config.quota),
250            stylesheet: match config.styles {
251                true => beck_core::style::stylesheet(&beck_core::style::classes(
252                    &runtime_for_styles.placed().program,
253                )),
254                false => String::new(),
255            },
256            here: crate::presence::Registry::new(config.presence),
257            aware: crate::awareness::Registry::new(config.awareness),
258            draining: watch::channel(false).0,
259        });
260        tokio::spawn(sequencer(app.clone(), rx, config));
261        Ok(app)
262    }
263
264    /// The stylesheet `/beck.css` serves — this program's classes and nothing else.
265    pub fn stylesheet(&self) -> &str {
266        &self.stylesheet
267    }
268
269    pub fn runtime(&self) -> &Runtime {
270        &self.runtime
271    }
272
273    pub fn store_kind(&self) -> &'static str {
274        self.store.kind()
275    }
276
277    pub fn head(&self) -> Seq {
278        self.head.load(Ordering::Relaxed)
279    }
280
281    pub fn subscribe(&self) -> watch::Receiver<Seq> {
282        self.version.subscribe()
283    }
284
285    /// Tell every subscription this process is going away.
286    ///
287    /// Idempotent, and one-way: an application that has drained does not come back, because the
288    /// thing that would bring it back is a new process.
289    pub fn drain(&self) {
290        let _ = self.draining.send(true);
291    }
292
293    /// Watch for the drain. `true` already means it has happened.
294    pub fn draining(&self) -> watch::Receiver<bool> {
295        self.draining.subscribe()
296    }
297
298    pub async fn state(&self) -> Value {
299        self.state.read().await.clone()
300    }
301
302    /// Render a subscriber's view of the current state, by full recompute.
303    ///
304    /// Kept for the two callers that render a state nobody is subscribed to: the server-side render
305    /// of the first document, and the resumption path's reconstruction of the view as of an old
306    /// `seq`. A live subscription goes through [`App::maintain`] instead.
307    pub async fn render(
308        &self,
309        actor: &(impl crate::program::Viewer + ?Sized),
310    ) -> Result<beck_core::Html> {
311        let state = self.state.read().await.clone();
312        let here = self.here.value();
313        let aware = self.aware.value();
314        timed(&telemetry().view, || {
315            self.runtime.view_with_all(&state, actor, &here, &aware)
316        })
317    }
318
319    /// Who is connected, as the value `presence()` produces.
320    pub fn here(&self) -> beck_core::Value {
321        self.here.value()
322    }
323
324    /// The roster itself, for a connection that wants to join it and for a gauge that wants to
325    /// read it.
326    pub fn presence(&self) -> &Arc<crate::presence::Registry> {
327        &self.here
328    }
329
330    /// The awareness roster, for a connection that wants to contribute to it.
331    pub fn awareness(&self) -> &Arc<crate::awareness::Registry> {
332        &self.aware
333    }
334
335    /// An engine for one new subscription, of whichever kind this application is configured for.
336    ///
337    /// With sharing on it owns only the per-session operators; the rest arrive from the one shared
338    /// dataflow. With it off it owns the whole plan, which is what every subscription did before
339    /// `docs/23-incremental-views-report.md`.
340    pub fn view_engine(&self) -> Result<beck_core::engine::Engine> {
341        if self.config.share_arrangements {
342            Ok(self.shared.subscriber())
343        } else {
344            self.runtime.view_engine()
345        }
346    }
347
348    /// Render a subscriber's view of the current state, by maintaining it. Returns the page **and
349    /// the version it reflects**.
350    ///
351    /// The engine belongs to the subscription, so its arrangements survive between events and the
352    /// per-event work is proportional to what the event changed (§5.3). The state is cloned under
353    /// the read lock and the engine runs outside it — an `Arc` bump under the lock, and no
354    /// rendering while the sequencer wants to write.
355    ///
356    /// The version is read **under the same lock** as the state, because the sequencer publishes
357    /// both under its write lock, and a page paired with a `seq` it does not reflect is a wrong DOM
358    /// after the client's next reconnect: a resuming client is served the difference from the `seq`
359    /// its last frame carried. This used to be `app.head()` sampled after the render, which is a
360    /// larger number whenever an event landed in between.
361    pub async fn maintain(
362        &self,
363        engine: &mut beck_core::engine::Engine,
364        actor: &(impl crate::program::Viewer + ?Sized),
365    ) -> Result<(beck_core::Html, Seq)> {
366        let (state, version) = {
367            let guard = self.state.read().await;
368            (guard.clone(), self.head.load(Ordering::Relaxed))
369        };
370        // Read *outside* the state lock, and that is the honest half of this design rather than an
371        // oversight: the roster is not a function of the log, so there is no version at which the
372        // two agree. A page renders the state at `version` and the connections as they were a
373        // moment ago, which is what "who is connected now" can mean at all.
374        let here = self.here.value();
375        let aware = self.aware.value();
376        timed(&telemetry().view, || {
377            if !self.config.maintain_views {
378                return Ok((
379                    self.runtime.view_with_all(&state, actor, &here, &aware)?,
380                    version,
381                ));
382            }
383            if self.config.share_arrangements {
384                self.runtime.render_shared(
385                    &self.shared,
386                    engine,
387                    &state,
388                    version,
389                    actor,
390                    &here,
391                    &aware,
392                )
393            } else {
394                Ok((
395                    self.runtime.render(engine, &state, actor, &here, &aware)?,
396                    version,
397                ))
398            }
399        })
400    }
401
402    /// Whether subscriptions maintain their views (§5.3) or recompute them.
403    /// How this process decides who is asking.
404    ///
405    /// Public because both edges — the socket and the document handler — have to ask the same
406    /// question, and because the dashboard and the startup line have to be able to *say* which
407    /// provider is in force. An operator who cannot tell from the logs whether authentication is
408    /// on does not have authentication (`docs/48` §48.2).
409    pub fn identity(&self) -> &Arc<dyn crate::identity::Identity> {
410        &self.config.identity
411    }
412
413    /// The clock this process was configured with — F11's supplied one, never an ambient reading.
414    ///
415    /// Public for the same reason [`App::identity`] is: the HTTP edge has to answer "how long is
416    /// this credential good for" and must not reach for a second clock to do it.
417    pub fn clock(&self) -> &Arc<dyn beck_core::clock::Clock> {
418        &self.config.clock
419    }
420
421    pub fn maintains_views(&self) -> bool {
422        self.config.maintain_views
423    }
424
425    /// Whether the operators that do not read the session are held once between subscribers.
426    pub fn shares_arrangements(&self) -> bool {
427        self.config.maintain_views && self.config.share_arrangements
428    }
429
430    /// The shared dataflow, for a measurement that wants to know what it holds.
431    pub fn shared_dataflow(&self) -> &Arc<beck_core::engine::SharedDataflow> {
432        &self.shared
433    }
434
435    /// Propose a command. Returns the `seq` its events landed at.
436    ///
437    /// The reply is the **ack**, and it means *committed* — not "your view has caught up". Phase 0
438    /// found out the hard way that those are different facts (§18.5 item 1).
439    pub async fn propose(
440        &self,
441        id: String,
442        actor: impl Into<crate::identity::Proposer>,
443        command: Value,
444    ) -> Result<Seq, String> {
445        let actor = actor.into().0;
446        let (reply, rx) = oneshot::channel();
447        // §3.7: the merge point is the one place time enters. It enters *here*, from the clock the
448        // process was configured with, and is data on the envelope from this line onwards — which
449        // is what makes a replay of that envelope reproduce the run rather than re-read the clock.
450        let at = Instant(self.config.clock.now_millis());
451
452        // F3's quota, charged from that same instant rather than from a second reading of a clock.
453        // Refused *before* the queue: a proposal nothing will admit should not occupy a slot in it,
454        // which is the difference between a quota and a slower queue.
455        if !self.limit.admit(actor.name(), at.0) {
456            telemetry().throttled.incr();
457            tracing::warn!(actor = actor.name(), "refused: over the write quota");
458            return Err("over the write quota".to_string());
459        }
460        self.ingress
461            .send(Proposal {
462                id,
463                at,
464                actor,
465                command,
466                reply,
467            })
468            .await
469            .map_err(|_| "ingress is closed".to_string())?;
470        rx.await
471            .map_err(|_| "ingress dropped the proposal".to_string())?
472    }
473
474    /// Run something against a consistent snapshot of the accumulator and the version it is at.
475    ///
476    /// The read lock is held for the whole of `f`, which is what makes it a snapshot rather than
477    /// two facts read at two times: the sequencer commits under the write lock, so nothing can move
478    /// the state — and therefore nothing can advance the shared dataflow past this version — while
479    /// this runs. [`crate::pgwire`] is the caller, and it is the one place a *reader* needs the two
480    /// together; a rendering subscriber takes a clone instead, because a render is `O(page)` and a
481    /// scan is `O(rows)`.
482    pub async fn read_snapshot<T>(&self, f: impl FnOnce(&Value, Seq) -> T) -> T {
483        let guard = self.state.read().await;
484        f(&guard, self.head.load(Ordering::Relaxed))
485    }
486
487    /// The state as of `seq`, for a resuming subscriber.
488    pub async fn state_at(&self, seq: Seq) -> Result<Value> {
489        let (state, _) = replay_to(&self.runtime, self.store.as_ref(), seq).await?;
490        Ok(state)
491    }
492
493    pub async fn floor(&self) -> Result<Seq> {
494        self.store.floor().await
495    }
496}
497
498/// This host's stopwatch over the one step the merge point spends real time in.
499///
500/// `beck_host::sequence` reads no clock, because one of its two hosts is a browser tab where
501/// `std::time::Instant::now()` is a panic. A process that has a clock says so by passing this.
502struct FoldTimer;
503
504impl beck_host::sequence::Meter for FoldTimer {
505    fn fold(&self, f: &mut dyn FnMut() -> Result<Value>) -> Result<Value> {
506        timed(&telemetry().fold, f)
507    }
508}
509
510/// The single writer. Everything about the total order lives in this one task.
511///
512/// What it decides lives in [`mod@beck_host::sequence`] instead: which proposals become events, and
513/// what each proposer is told. This task is the part that is about *this* host — a queue, a
514/// durable append, a version to publish, a snapshot on a counter — and a browser tab running the
515/// same application has none of those and all of the rules
516/// ([`docs/17`](../../../../../docs/17-playground.md) §17.2).
517async fn sequencer(app: Arc<App>, mut rx: mpsc::Receiver<Proposal>, config: AppConfig) {
518    // The commands already appended, with the position each got. **The position is the point**:
519    // §4.3 makes the id an idempotency key so "a retry after a reconnect is safe", and a retry is
520    // only safe if the answer to the second attempt is the answer to the first. Remembering the id
521    // alone let this reply "duplicate" — a *rejection* — to a command that had been accepted, so a
522    // client replaying its offline queue was told its work had been refused and took it back off
523    // the page (`docs/94` §94.10).
524    let mut seen = Seen::new(config.dedup_capacity);
525    let mut since_snapshot = 0u64;
526    let mut batch: Vec<Proposal> = Vec::with_capacity(config.max_batch);
527
528    while let Some(first) = rx.recv().await {
529        batch.clear();
530        batch.push(first);
531        // Whatever else has already arrived rides along. The batch is exactly "what queued while
532        // the last append was in flight", so the system self-tunes: latency at low load,
533        // throughput at high load.
534        while batch.len() < config.max_batch {
535            match rx.try_recv() {
536                Ok(p) => batch.push(p),
537                Err(_) => break,
538            }
539        }
540
541        // The write lock is held across validation *and* the append, because validation must see
542        // the batch it is inside: `Add(x)` followed by `Toggle(x)` in one batch must work
543        // (§18.5 item 5).
544        let mut state = app.state.write().await;
545        let base = app.head.load(Ordering::Relaxed);
546
547        // The proposals, minus their reply channels — which is the whole of what the rules do not
548        // need. The channels stay here, in the order the decisions come back in.
549        let mut senders = Vec::with_capacity(batch.len());
550        let mut proposals = Vec::with_capacity(batch.len());
551        for p in batch.drain(..) {
552            senders.push(p.reply);
553            proposals.push((p.id, p.at, p.actor, p.command));
554        }
555        let decided = beck_host::sequence(
556            &app.runtime,
557            &state,
558            base,
559            &mut seen,
560            proposals
561                .iter()
562                .map(|(id, at, actor, command)| beck_host::sequence::Proposal {
563                    id: id.clone(),
564                    at: *at,
565                    actor,
566                    command: command.clone(),
567                })
568                .collect(),
569            &FoldTimer,
570        );
571
572        let mut replies: Vec<(oneshot::Sender<Result<Seq, String>>, usize)> = Vec::new();
573        for (reply, decision) in senders.into_iter().zip(decided.decisions) {
574            match decision {
575                // Idempotency by envelope identity: a retry after a reconnect is safe (§4.3), and
576                // it is safe because this is an **ack** carrying the position the first attempt
577                // got, not a refusal. The command is in the log; saying so twice is the whole of
578                // what idempotent means.
579                beck_host::Decision::Duplicate(at) => {
580                    telemetry().deduplicated.incr();
581                    let _ = reply.send(Ok(at));
582                }
583                beck_host::Decision::Refused { why } => {
584                    telemetry().rejected.incr();
585                    let _ = reply.send(Err(why));
586                }
587                beck_host::Decision::Accepted { offset } => replies.push((reply, offset)),
588            }
589        }
590        let (speculative, pending) = (decided.state, decided.pending);
591        if pending.is_empty() {
592            continue;
593        }
594
595        let append_started = std::time::Instant::now();
596        let appended = app.store.append(&pending).await;
597        telemetry().append.record(append_started.elapsed());
598        match appended {
599            Ok(stamped) => {
600                // The predicted seqs must match what the store assigned. That assertion is how a
601                // second writer would be caught (§18.5 item 5).
602                for (i, env) in stamped.iter().enumerate() {
603                    if env.seq != base + i as u64 + 1 {
604                        tracing::error!(
605                            expected = base + i as u64 + 1,
606                            actual = env.seq,
607                            "the log assigned a seq the sequencer did not predict"
608                        );
609                        std::process::abort();
610                    }
611                }
612                let head = stamped.last().map(|e| e.seq).unwrap_or(base);
613                telemetry().events_appended.add(stamped.len() as u64);
614                telemetry().head.set(head);
615                *state = speculative;
616                app.head.store(head, Ordering::Relaxed);
617                drop(state);
618
619                for (reply, offset) in replies {
620                    let _ = reply.send(Ok(base + offset as u64));
621                }
622                let _ = app.version.send(head);
623
624                since_snapshot += stamped.len() as u64;
625                if since_snapshot >= config.snapshot_every {
626                    since_snapshot = 0;
627                    let snapshot = Snapshot {
628                        seq: head,
629                        state: app.state.read().await.clone(),
630                    };
631                    let started = std::time::Instant::now();
632                    let put = app.store.put_snapshot(&snapshot).await;
633                    telemetry().snapshot.record(started.elapsed());
634                    match put {
635                        Ok(()) => tracing::info!(seq = head, "snapshot written"),
636                        Err(e) => {
637                            telemetry().snapshot_failures.incr();
638                            tracing::warn!(
639                                error = %e, seq = head,
640                                "snapshot failed; the log is still the truth"
641                            );
642                        }
643                    }
644                }
645            }
646            Err(e) => {
647                // "A failed append has no repair path, and that is correct." The process's state
648                // is ahead of the durable truth and there is nothing to reconcile: abort, and the
649                // next process folds the log (§18.5 item 6).
650                telemetry().append_failures.incr();
651                tracing::error!(error = %e, seq = base + 1, "append failed after the fold advanced; aborting");
652                std::process::abort();
653            }
654        }
655    }
656}
657
658/// Fold the log from the best available starting point — a snapshot if there is one, genesis
659/// otherwise. This is `beck replay`, and the resumption path uses it to reconstruct the view a
660/// reconnecting subscriber last saw.
661pub async fn replay_to(
662    runtime: &Runtime,
663    store: &dyn LogStore,
664    target: Seq,
665) -> Result<(Value, Seq)> {
666    let (mut state, mut at) = match store.snapshot_at_or_before(target).await? {
667        Some(s) => (s.state, s.seq),
668        None => (runtime.initial_state()?, 0),
669    };
670
671    const CHUNK: usize = 4096;
672    while at < target {
673        let batch = store.read(at, CHUNK.min((target - at) as usize)).await?;
674        if batch.is_empty() {
675            break;
676        }
677        for env in &batch {
678            let event = env.event()?;
679            state = runtime.fold(&state, env, event)?;
680            at = env.seq;
681        }
682    }
683    Ok((state, at))
684}
685
686/// Fold the whole log from genesis, ignoring snapshots.
687///
688/// D3's genesis-replay discipline: snapshots are an optimisation, and a snapshot that disagrees
689/// with the log is a bug we want CI to find, not a fact we want to trust.
690pub async fn replay_from_genesis(runtime: &Runtime, store: &dyn LogStore) -> Result<(Value, Seq)> {
691    let started = std::time::Instant::now();
692    let mut state = runtime.initial_state()?;
693    let mut at = 0;
694    loop {
695        let batch = store.read(at, 4096).await?;
696        if batch.is_empty() {
697            // Recorded here rather than per event: a replay is one operation from the operator's
698            // point of view — "how long was this pod down for" — and the per-event cost is what
699            // `tests/scaling.rs` measures.
700            telemetry().replay.record(started.elapsed());
701            return Ok((state, at));
702        }
703        for env in &batch {
704            let event = env.event()?;
705            state = runtime.fold(&state, env, event)?;
706            at = env.seq;
707        }
708    }
709}