Skip to main content

arete_server/snapshot/
mod.rs

1//! Opt-in periodic snapshots of in-memory server state (VM entity tables +
2//! projection caches) and a restore path that rehydrates on startup, so a
3//! restarted server comes back with its history instead of starting empty.
4//!
5//! arete-server owns all snapshot logic. The generated runtime's only
6//! responsibilities are (1) registering the `VmContext`/`SlotTracker` it
7//! creates via [`register_runtime`] and (2) hydrating from a restored blob via
8//! [`take_restored`] before connecting to Yellowstone. Those hooks resolve
9//! through a task-local [`SnapshotRuntime`], so multiple servers embedded in
10//! one process cannot consume or replace each other's snapshot state. A stack
11//! built with an older arete-macros simply never registers a VM; snapshots
12//! stay disabled with a warning.
13//!
14//! Consistency cut: every generated VM update holds a shared snapshot barrier
15//! guard until its mutation batch has been applied by the projector. Snapshot
16//! capture takes the exclusive guard before dumping either side, so the VM,
17//! projection caches, and resume watermark all describe the same processing
18//! cut. On restore the stream replays from that watermark; the snapshotted
19//! version trackers drop the overlap.
20
21pub mod envelope;
22#[cfg(feature = "snapshot-object-store")]
23pub mod object;
24pub mod store;
25
26pub use envelope::{SnapshotHeader, SnapshotPayload};
27#[cfg(feature = "snapshot-object-store")]
28pub use object::ObjectSnapshotStore;
29pub use store::{FsStore, SnapshotStore};
30
31use crate::cache::EntityCache;
32use crate::health::SlotTracker;
33use crate::mutation_batch::MutationBatch;
34use crate::view::ViewIndex;
35use anyhow::{Context, Result};
36use arete_interpreter::snapshot::{VmSnapshot, SNAPSHOT_FORMAT_VERSION};
37use arete_interpreter::vm::VmContext;
38use std::future::Future;
39use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
40use std::sync::{Arc, Mutex as StdMutex};
41use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
42use tokio::sync::{mpsc, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
43use tracing::{debug, info, info_span, warn, Instrument};
44
45/// Rough Solana slot duration, used only to convert snapshot age into an
46/// estimated slot distance for the staleness clamp.
47const ESTIMATED_SLOT_MILLIS: u64 = 400;
48/// How long a snapshot cycle waits for in-flight VM updates and their queued
49/// projection batches to finish.
50const CONSISTENCY_CUT_TIMEOUT: Duration = Duration::from_secs(10);
51
52/// Configuration for state snapshots. Disabled by default; enable via
53/// `ServerBuilder::snapshots(...)` or `ARETE_SNAPSHOT_*` env vars.
54#[derive(Clone, Debug)]
55pub struct SnapshotConfig {
56    /// Master opt-in.
57    pub enabled: bool,
58    /// Where blobs live: `file:///var/lib/arete/snapshots`, a plain path, or
59    /// (with the `snapshot-object-store` feature) `s3://`/`gs://`/`az://`.
60    pub url: Option<String>,
61    /// Periodic snapshot cadence.
62    pub interval: Duration,
63    /// Retained snapshots; older ones are pruned after each write.
64    pub keep: usize,
65    /// Take a final snapshot on SIGTERM/SIGINT before exit.
66    pub snapshot_on_shutdown: bool,
67    /// Skip a periodic cycle when fewer batches were applied since the last
68    /// snapshot (quiet stacks snapshot rarely).
69    pub min_mutations: u64,
70    /// If the snapshot is older than this many (estimated) slots, hydrate
71    /// state but start the stream live instead of resuming from the watermark.
72    pub max_resume_age_slots: u64,
73    /// `/ready` stays 503 after a watermark resume until the projector is
74    /// within this many slots of the observed tip...
75    pub ready_max_lag_slots: u64,
76    /// ...or until this much time has passed (guards quiet stacks, where the
77    /// watermark never advances because nothing happens on-chain).
78    pub ready_max_hold: Duration,
79}
80
81impl Default for SnapshotConfig {
82    fn default() -> Self {
83        Self {
84            enabled: false,
85            url: None,
86            interval: Duration::from_secs(60),
87            keep: 4,
88            snapshot_on_shutdown: true,
89            min_mutations: 1,
90            // ~10 minutes of slots: conservative vs. typical provider
91            // `from_slot` replay windows (in-cluster richat rings are far
92            // more generous; raw Triton is minutes).
93            max_resume_age_slots: 1_500,
94            ready_max_lag_slots: 50,
95            ready_max_hold: Duration::from_secs(60),
96        }
97    }
98}
99
100impl SnapshotConfig {
101    /// Load snapshot settings from `ARETE_SNAPSHOT_*` env vars. Snapshots stay
102    /// disabled unless `ARETE_SNAPSHOT_ENABLED=true`.
103    pub fn from_env() -> Result<Self> {
104        let mut config = Self::default();
105        config.enabled = crate::config::env_bool("ARETE_SNAPSHOT_ENABLED")?.unwrap_or(false);
106        config.url = std::env::var("ARETE_SNAPSHOT_URL")
107            .ok()
108            .filter(|value| !value.trim().is_empty());
109        config.interval = Duration::from_secs(
110            crate::config::env_parse("ARETE_SNAPSHOT_INTERVAL_SECS")?
111                .unwrap_or(config.interval.as_secs()),
112        );
113        config.keep = crate::config::env_parse("ARETE_SNAPSHOT_KEEP")?.unwrap_or(config.keep);
114        config.snapshot_on_shutdown = crate::config::env_bool("ARETE_SNAPSHOT_ON_SHUTDOWN")?
115            .unwrap_or(config.snapshot_on_shutdown);
116        config.min_mutations = crate::config::env_parse("ARETE_SNAPSHOT_MIN_MUTATIONS")?
117            .unwrap_or(config.min_mutations);
118        config.max_resume_age_slots =
119            crate::config::env_parse("ARETE_SNAPSHOT_MAX_RESUME_AGE_SLOTS")?
120                .unwrap_or(config.max_resume_age_slots);
121        config.ready_max_lag_slots =
122            crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_LAG_SLOTS")?
123                .unwrap_or(config.ready_max_lag_slots);
124        config.ready_max_hold = Duration::from_secs(
125            crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_HOLD_SECS")?
126                .unwrap_or(config.ready_max_hold.as_secs()),
127        );
128        config.validate()?;
129        Ok(config)
130    }
131
132    pub fn validate(&self) -> Result<()> {
133        if self.enabled && self.url.as_deref().is_none_or(|url| url.trim().is_empty()) {
134            anyhow::bail!("snapshots are enabled but ARETE_SNAPSHOT_URL is not set");
135        }
136        if self.enabled && (self.interval.is_zero() || self.keep == 0) {
137            anyhow::bail!("snapshot interval and keep count must be greater than zero");
138        }
139        Ok(())
140    }
141}
142
143/// VM state handed from the restore path to the generated runtime, consumed
144/// exactly once via [`take_restored`].
145pub struct RestoredState {
146    pub vm: VmSnapshot,
147    /// `Some(slot)` to resume the Yellowstone stream from that slot; `None`
148    /// when the snapshot was too stale (state still hydrates, stream starts
149    /// live and account-derived state self-heals).
150    pub resume_watermark: Option<u64>,
151}
152
153#[derive(Clone)]
154struct RuntimeRegistration {
155    vm: Arc<StdMutex<VmContext>>,
156    slot_tracker: SlotTracker,
157}
158
159struct ResumeGate {
160    started: Instant,
161    max_lag_slots: u64,
162    max_hold: Duration,
163}
164
165/// Per-runtime barrier that keeps snapshot capture from splitting a VM update
166/// from the projection batch it produced.
167///
168/// Generated mutation producers enter the barrier in shared mode before
169/// touching the VM and transfer the guard to their [`MutationBatch`]. The
170/// projector releases it only after applying that batch. Snapshot capture
171/// enters in exclusive mode, which therefore waits for both in-flight parser
172/// work and queued projection work to finish.
173#[derive(Clone, Default)]
174pub struct SnapshotBarrier {
175    inner: Arc<RwLock<()>>,
176}
177
178impl SnapshotBarrier {
179    pub async fn enter_processing(&self) -> SnapshotProcessingGuard {
180        SnapshotProcessingGuard(self.inner.clone().read_owned().await)
181    }
182
183    async fn enter_snapshot(&self) -> OwnedRwLockWriteGuard<()> {
184        self.inner.clone().write_owned().await
185    }
186}
187
188/// Shared processing guard carried by a mutation batch until projection is
189/// complete. The inner guard is intentionally opaque outside arete-server.
190pub struct SnapshotProcessingGuard(#[allow(dead_code)] OwnedRwLockReadGuard<()>);
191
192impl std::fmt::Debug for SnapshotProcessingGuard {
193    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        formatter.write_str("SnapshotProcessingGuard")
195    }
196}
197
198#[derive(Default)]
199struct SnapshotRuntimeState {
200    registered: StdMutex<Option<RuntimeRegistration>>,
201    restored: StdMutex<Option<RestoredState>>,
202    resume_gate: StdMutex<Option<ResumeGate>>,
203    processing_barrier: SnapshotBarrier,
204    /// Highest slot among mutation batches this runtime's projector has
205    /// applied. This is the safe `from_slot` resume point (`SlotTracker` is
206    /// not: it follows the raw slot subscription, not parser progress).
207    resume_watermark: AtomicU64,
208    applied_batches: AtomicU64,
209}
210
211/// Per-server snapshot coordination shared by its parser, projector, snapshot
212/// manager, and readiness endpoint.
213///
214/// The generated parser hooks use [`scope`](Self::scope) so their existing
215/// argument-free calls cannot accidentally bind to another server running in
216/// the same process.
217#[derive(Clone, Default)]
218pub struct SnapshotRuntime {
219    state: Arc<SnapshotRuntimeState>,
220}
221
222tokio::task_local! {
223    static ACTIVE_SNAPSHOT_RUNTIME: SnapshotRuntime;
224}
225
226impl SnapshotRuntime {
227    /// Run a generated parser future with this server's snapshot state.
228    pub async fn scope<F>(&self, future: F) -> F::Output
229    where
230        F: Future,
231    {
232        ACTIVE_SNAPSHOT_RUNTIME.scope(self.clone(), future).await
233    }
234
235    /// Associate the parser's VM and slot tracker with this server only.
236    pub fn register_runtime(
237        &self,
238        vm: Arc<StdMutex<VmContext>>,
239        slot_tracker: SlotTracker,
240    ) -> SnapshotBarrier {
241        let mut registered = self.state.registered.lock().unwrap();
242        if registered.is_some() {
243            debug!("Snapshot runtime registration replaced");
244        }
245        *registered = Some(RuntimeRegistration { vm, slot_tracker });
246        self.state.processing_barrier.clone()
247    }
248
249    /// Consume this server's restored VM state exactly once.
250    pub fn take_restored(&self) -> Option<RestoredState> {
251        self.state.restored.lock().unwrap().take()
252    }
253
254    /// Record a batch applied by this server's projector.
255    pub(crate) fn record_applied_batch(&self, slot: Option<u64>) {
256        self.state.applied_batches.fetch_add(1, Ordering::Relaxed);
257        if let Some(slot) = slot {
258            self.state
259                .resume_watermark
260                .fetch_max(slot, Ordering::Relaxed);
261        }
262    }
263
264    /// Returns `true` unless this server's watermark resume is still catching
265    /// up to its observed slot tip.
266    pub fn resume_gate_ready(&self) -> bool {
267        let mut gate_slot = self.state.resume_gate.lock().unwrap();
268        let Some(gate) = gate_slot.as_ref() else {
269            return true;
270        };
271        if gate.started.elapsed() >= gate.max_hold {
272            info!("Snapshot resume readiness gate released (max hold reached)");
273            *gate_slot = None;
274            return true;
275        }
276        let tip = self
277            .state
278            .registered
279            .lock()
280            .unwrap()
281            .as_ref()
282            .map(|registration| registration.slot_tracker.get())
283            .unwrap_or(0);
284        let applied = self.state.resume_watermark.load(Ordering::Relaxed);
285        if tip > 0 && tip.saturating_sub(applied) <= gate.max_lag_slots {
286            info!(tip, applied, "Snapshot resume caught up; marking ready");
287            *gate_slot = None;
288            return true;
289        }
290        false
291    }
292}
293
294/// Called by the generated runtime after it creates its `VmContext` and
295/// `SlotTracker`, so the snapshot manager can dump them later.
296pub fn register_runtime(
297    vm: Arc<StdMutex<VmContext>>,
298    slot_tracker: SlotTracker,
299) -> Option<SnapshotBarrier> {
300    match ACTIVE_SNAPSHOT_RUNTIME.try_with(|runtime| runtime.register_runtime(vm, slot_tracker)) {
301        Ok(barrier) => Some(barrier),
302        Err(_) => {
303            debug!("Snapshot runtime registration ignored (snapshots disabled)");
304            None
305        }
306    }
307}
308
309/// Called by the generated runtime before connecting: returns the restored VM
310/// state (if any) exactly once.
311pub fn take_restored() -> Option<RestoredState> {
312    ACTIVE_SNAPSHOT_RUNTIME
313        .try_with(SnapshotRuntime::take_restored)
314        .ok()
315        .flatten()
316}
317
318/// Select a reconnect checkpoint for the generated Yellowstone runtime.
319///
320/// A restored replay never falls back to live: retries advance only to slots
321/// the main parser stream has finished processing. Without a restored replay,
322/// the existing live fallback remains available after repeated short-lived
323/// connections.
324#[doc(hidden)]
325pub fn select_reconnect_from_slot(
326    restored_watermark: Option<u64>,
327    processed_watermark: u64,
328    attempt: u32,
329    live_fallback_attempts: u32,
330) -> Option<u64> {
331    if let Some(restored_watermark) = restored_watermark {
332        return Some(restored_watermark.max(processed_watermark));
333    }
334    if attempt >= live_fallback_attempts {
335        return None;
336    }
337    (processed_watermark > 0).then_some(processed_watermark)
338}
339
340fn now_epoch_ms() -> u64 {
341    SystemTime::now()
342        .duration_since(UNIX_EPOCH)
343        .unwrap()
344        .as_millis() as u64
345}
346
347/// What kicked off a snapshot cycle.
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum SnapshotTrigger {
350    Periodic,
351    Shutdown,
352}
353
354/// Owns the store plus everything needed to dump and restore state. Created by
355/// `Runtime::run` when snapshots are enabled.
356pub struct SnapshotService {
357    config: SnapshotConfig,
358    store: Arc<dyn SnapshotStore>,
359    runtime: SnapshotRuntime,
360    bytecode_hash: String,
361    program_ids: Vec<String>,
362    entity_cache: EntityCache,
363    batches_at_last_snapshot: AtomicU64,
364    warned_missing_vm: AtomicBool,
365}
366
367impl SnapshotService {
368    /// Build the store, then attempt a restore (any failure logs a warning
369    /// and cold-starts — restore problems must never block startup).
370    pub async fn initialize(
371        config: SnapshotConfig,
372        spec: &crate::Spec,
373        entity_cache: EntityCache,
374        view_index: &ViewIndex,
375        _mutations_tx: mpsc::Sender<MutationBatch>,
376    ) -> Result<Arc<Self>> {
377        let url = config
378            .url
379            .clone()
380            .context("snapshots are enabled but no snapshot URL is configured")?;
381        let store = store::store_from_url(&url)?;
382
383        let mut program_ids = spec.program_ids.clone();
384        program_ids.sort();
385
386        let service = Arc::new(Self {
387            config,
388            store,
389            runtime: SnapshotRuntime::default(),
390            bytecode_hash: spec.bytecode.fingerprint(),
391            program_ids,
392            entity_cache,
393            batches_at_last_snapshot: AtomicU64::new(0),
394            warned_missing_vm: AtomicBool::new(false),
395        });
396        info!(
397            store = %service.store.describe(),
398            interval_secs = service.config.interval.as_secs(),
399            keep = service.config.keep,
400            "State snapshots enabled"
401        );
402
403        match service.restore(view_index).await {
404            Ok(true) => {}
405            Ok(false) => info!("No usable snapshot found; starting cold"),
406            Err(err) => warn!(
407                error = format!("{err:#}"),
408                "Failed to restore snapshot; starting cold"
409            ),
410        }
411        Ok(service)
412    }
413
414    pub fn config(&self) -> &SnapshotConfig {
415        &self.config
416    }
417
418    /// Return the per-server coordination handle that must be shared with the
419    /// matching parser, projector, and readiness endpoint.
420    pub fn runtime(&self) -> SnapshotRuntime {
421        self.runtime.clone()
422    }
423
424    /// Load and validate the latest snapshot, hydrate the projection caches,
425    /// and stash the VM portion for the generated runtime. Returns whether a
426    /// snapshot was applied.
427    async fn restore(&self, view_index: &ViewIndex) -> Result<bool> {
428        let Some((name, bytes)) = self.store.load_latest().await? else {
429            return Ok(false);
430        };
431
432        let header = envelope::decode_header(&bytes)
433            .with_context(|| format!("snapshot {name} has an unreadable header"))?;
434
435        if header.format_version != SNAPSHOT_FORMAT_VERSION {
436            warn!(
437                snapshot = %name,
438                found = header.format_version,
439                expected = SNAPSHOT_FORMAT_VERSION,
440                "Snapshot format version mismatch; discarding (cold start)"
441            );
442            return Ok(false);
443        }
444        if header.bytecode_hash != self.bytecode_hash {
445            warn!(
446                snapshot = %name,
447                "Snapshot was taken by a different stack build (bytecode hash \
448                 mismatch); discarding (cold start)"
449            );
450            return Ok(false);
451        }
452        let mut snapshot_program_ids = header.program_ids.clone();
453        snapshot_program_ids.sort();
454        if snapshot_program_ids != self.program_ids {
455            warn!(
456                snapshot = %name,
457                "Snapshot program ids do not match this server; discarding (cold start)"
458            );
459            return Ok(false);
460        }
461
462        let payload = tokio::task::spawn_blocking(move || envelope::decode_payload(&bytes))
463            .await
464            .context("snapshot decode task panicked")?
465            .with_context(|| format!("snapshot {name} has an unreadable payload"))?;
466
467        let cached_views = payload.entity_cache.len();
468        let cached_entities: usize = payload
469            .entity_cache
470            .iter()
471            .map(|(_, entries)| entries.len())
472            .sum();
473        self.entity_cache.hydrate(payload.entity_cache).await;
474        rebuild_sorted_caches(view_index, &self.entity_cache).await;
475
476        // Even when the stream starts live, the watermark seeds the applied
477        // position: the hydrated state already contains everything up to it.
478        self.runtime
479            .state
480            .resume_watermark
481            .fetch_max(header.resume_watermark, Ordering::Relaxed);
482
483        let age_ms = now_epoch_ms().saturating_sub(header.created_at_epoch_ms);
484        let estimated_age_slots = age_ms / ESTIMATED_SLOT_MILLIS;
485        let resume_watermark = if header.resume_watermark > 0
486            && estimated_age_slots <= self.config.max_resume_age_slots
487        {
488            Some(header.resume_watermark)
489        } else {
490            if header.resume_watermark > 0 {
491                warn!(
492                    resume_watermark = header.resume_watermark,
493                    estimated_age_slots,
494                    max_resume_age_slots = self.config.max_resume_age_slots,
495                    "Snapshot is older than the resume window; hydrating state but \
496                     starting the stream live. Account-derived state self-heals from \
497                     full account writes; only instruction events in the gap are missed."
498                );
499            }
500            None
501        };
502
503        if resume_watermark.is_some() {
504            *self.runtime.state.resume_gate.lock().unwrap() = Some(ResumeGate {
505                started: Instant::now(),
506                max_lag_slots: self.config.ready_max_lag_slots,
507                max_hold: self.config.ready_max_hold,
508            });
509        }
510
511        info!(
512            snapshot = %name,
513            vm_entities = payload.vm.total_entries(),
514            cached_views,
515            cached_entities,
516            resume_watermark = header.resume_watermark,
517            resuming = resume_watermark.is_some(),
518            age_secs = age_ms / 1_000,
519            "Restored state from snapshot"
520        );
521
522        *self.runtime.state.restored.lock().unwrap() = Some(RestoredState {
523            vm: payload.vm,
524            resume_watermark,
525        });
526        Ok(true)
527    }
528
529    /// Spawn the periodic snapshot task.
530    pub fn spawn(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
531        let service = Arc::clone(self);
532        tokio::spawn(
533            async move {
534                let mut interval = tokio::time::interval(service.config.interval);
535                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
536                // The first tick fires immediately; skip it so the first
537                // snapshot lands one full interval after startup.
538                interval.tick().await;
539                loop {
540                    interval.tick().await;
541                    if let Err(err) = service.snapshot_now(SnapshotTrigger::Periodic).await {
542                        // Snapshotting must never take down a healthy server.
543                        warn!(
544                            error = format!("{err:#}"),
545                            "Snapshot cycle failed; will retry next interval"
546                        );
547                    }
548                }
549            }
550            .instrument(info_span!("snapshot.manager")),
551        )
552    }
553
554    /// Run one snapshot cycle. Returns `Ok(false)` when skipped (no VM
555    /// registered yet, or too few mutations since the last snapshot).
556    pub async fn snapshot_now(&self, trigger: SnapshotTrigger) -> Result<bool> {
557        let Some(registration) = self.runtime.state.registered.lock().unwrap().clone() else {
558            if !self.warned_missing_vm.swap(true, Ordering::Relaxed) {
559                warn!(
560                    "Snapshots are enabled but no VM has been registered; the stack \
561                     may have been built with an older arete-macros version"
562                );
563            }
564            return Ok(false);
565        };
566
567        let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
568        if trigger == SnapshotTrigger::Periodic {
569            let since_last = applied_batches
570                .saturating_sub(self.batches_at_last_snapshot.load(Ordering::Relaxed));
571            if since_last < self.config.min_mutations {
572                debug!(since_last, "Skipping snapshot cycle (too few mutations)");
573                return Ok(false);
574            }
575        }
576
577        // Wait for every in-flight VM update and its queued projection batch
578        // to finish, then block new updates until both sides have been dumped.
579        // Processing guards move with their mutation batches and are released
580        // by the projector only after cache application, so this exclusive
581        // guard establishes one exact cut without an enqueue race.
582        let consistency_guard = tokio::time::timeout(
583            CONSISTENCY_CUT_TIMEOUT,
584            self.runtime.state.processing_barrier.enter_snapshot(),
585        )
586        .await
587        .context("timed out waiting for a consistent VM/projection snapshot cut")?;
588
589        let dump_started = Instant::now();
590        let (vm_snapshot, resume_watermark) = {
591            let vm = registration
592                .vm
593                .lock()
594                .map_err(|_| anyhow::anyhow!("VM mutex poisoned"))?;
595            let resume_watermark = self.runtime.state.resume_watermark.load(Ordering::Relaxed);
596            let vm_snapshot = vm.dump();
597            (vm_snapshot, resume_watermark)
598        };
599        let vm_lock_held = dump_started.elapsed();
600        let observed_slot = registration.slot_tracker.get();
601        let entity_cache_dump = self.entity_cache.dump().await;
602        let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
603        drop(consistency_guard);
604
605        let created_at_epoch_ms = now_epoch_ms();
606        let header = SnapshotHeader {
607            format_version: SNAPSHOT_FORMAT_VERSION,
608            bytecode_hash: self.bytecode_hash.clone(),
609            program_ids: self.program_ids.clone(),
610            resume_watermark,
611            observed_slot,
612            created_at_epoch_ms,
613            entry_counts: vm_snapshot.entry_counts().into_iter().collect(),
614        };
615        let payload = SnapshotPayload {
616            vm: vm_snapshot,
617            entity_cache: entity_cache_dump,
618        };
619        let bytes = tokio::task::spawn_blocking(move || envelope::encode(&header, &payload))
620            .await
621            .context("snapshot encode task panicked")??;
622
623        let name = store::snapshot_name(created_at_epoch_ms, resume_watermark);
624        self.store.write(&name, &bytes).await?;
625        if let Err(err) = self.store.prune(self.config.keep).await {
626            warn!(error = format!("{err:#}"), "Failed to prune old snapshots");
627        }
628        self.batches_at_last_snapshot
629            .store(applied_batches, Ordering::Relaxed);
630
631        info!(
632            snapshot = %name,
633            bytes = bytes.len(),
634            resume_watermark,
635            observed_slot,
636            vm_lock_ms = vm_lock_held.as_millis() as u64,
637            trigger = ?trigger,
638            "Snapshot written"
639        );
640        Ok(true)
641    }
642}
643
644/// Rebuild each derived `SortedViewCache` from the hydrated `EntityCache`,
645/// mirroring the projector's upsert path. Sorted caches are not persisted:
646/// they are derived state and MB-scale rebuilds are sub-millisecond.
647async fn rebuild_sorted_caches(view_index: &ViewIndex, entity_cache: &EntityCache) {
648    let sorted_caches = view_index.sorted_caches();
649    for spec in view_index.get_derived_views() {
650        let Some(source_view) = spec.source_view.as_ref() else {
651            continue;
652        };
653        let entities = entity_cache.get_all(source_view).await;
654        if entities.is_empty() {
655            continue;
656        }
657        let mut caches = sorted_caches.write().await;
658        if let Some(cache) = caches.get_mut(&spec.id) {
659            let count = entities.len();
660            for (key, entity) in entities {
661                cache.upsert(key, entity);
662            }
663            debug!(view_id = %spec.id, count, "Rebuilt sorted cache from snapshot");
664        }
665    }
666}