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}