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::{SnapshotContract, 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 serde::Serialize;
39use sha2::{Digest, Sha256};
40use std::collections::{BTreeMap, BTreeSet, HashMap};
41use std::future::Future;
42use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
43use std::sync::{Arc, Mutex as StdMutex};
44use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
45use tokio::sync::{mpsc, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
46use tracing::{debug, info, info_span, warn, Instrument};
47
48/// Rough Solana slot duration, used only to convert snapshot age into an
49/// estimated slot distance for the staleness clamp.
50const ESTIMATED_SLOT_MILLIS: u64 = 200;
51/// How long a snapshot cycle waits for in-flight VM updates and their queued
52/// projection batches to finish.
53const CONSISTENCY_CUT_TIMEOUT: Duration = Duration::from_secs(10);
54const STATE_CONTRACT_SCHEMA_V1: &str = "arete.snapshot-state-contract/v1";
55const PROJECTION_CONTRACT_SCHEMA_V1: &str = "arete.snapshot-projection-contract/v1";
56const ROW_SCOPE_CONTRACT_SCHEMA_V1: &str = "arete.snapshot-row-scope-contract/v1";
57
58/// Configuration for state snapshots. Disabled by default; enable via
59/// `ServerBuilder::snapshots(...)` or `ARETE_SNAPSHOT_*` env vars.
60#[derive(Clone, Debug)]
61pub struct SnapshotConfig {
62    /// Master opt-in.
63    pub enabled: bool,
64    /// Where blobs live: `file:///var/lib/arete/snapshots`, a plain path, or
65    /// (with the `snapshot-object-store` feature) `s3://`/`gs://`/`az://`.
66    pub url: Option<String>,
67    /// Periodic snapshot cadence.
68    pub interval: Duration,
69    /// Retained snapshots; older ones are pruned after each write.
70    pub keep: usize,
71    /// Take a final snapshot on SIGTERM/SIGINT before exit.
72    pub snapshot_on_shutdown: bool,
73    /// Skip a periodic cycle when fewer batches were applied since the last
74    /// snapshot (quiet stacks snapshot rarely).
75    pub min_mutations: u64,
76    /// If the watermark's estimated lag is greater than this many slots,
77    /// including lag at snapshot time plus file age, hydrate state but start
78    /// the stream live instead of resuming from the watermark.
79    pub max_resume_age_slots: u64,
80    /// `/ready` stays 503 after a watermark resume until the projector is
81    /// within this many slots of the observed tip...
82    pub ready_max_lag_slots: u64,
83    /// ...or until this much time has passed (guards quiet stacks, where the
84    /// watermark never advances because nothing happens on-chain).
85    pub ready_max_hold: Duration,
86    /// Bytecode hashes from pre-contract snapshots that an operator has
87    /// explicitly approved for one state-only migration. These snapshots are
88    /// hydrated after structural state-id remapping and always start live.
89    pub legacy_bytecode_hashes: BTreeSet<String>,
90}
91
92impl Default for SnapshotConfig {
93    fn default() -> Self {
94        Self {
95            enabled: false,
96            url: None,
97            interval: Duration::from_secs(60),
98            keep: 4,
99            snapshot_on_shutdown: true,
100            min_mutations: 1,
101            // ~10 minutes of slots: conservative vs. typical provider
102            // `from_slot` replay windows (in-cluster richat rings are far
103            // more generous; raw Triton is minutes).
104            max_resume_age_slots: 1_500,
105            ready_max_lag_slots: 50,
106            ready_max_hold: Duration::from_secs(60),
107            legacy_bytecode_hashes: BTreeSet::new(),
108        }
109    }
110}
111
112impl SnapshotConfig {
113    /// Load snapshot settings from `ARETE_SNAPSHOT_*` env vars. Snapshots stay
114    /// disabled unless `ARETE_SNAPSHOT_ENABLED=true`.
115    pub fn from_env() -> Result<Self> {
116        let mut config = Self::default();
117        config.enabled = crate::config::env_bool("ARETE_SNAPSHOT_ENABLED")?.unwrap_or(false);
118        config.url = std::env::var("ARETE_SNAPSHOT_URL")
119            .ok()
120            .filter(|value| !value.trim().is_empty());
121        config.interval = Duration::from_secs(
122            crate::config::env_parse("ARETE_SNAPSHOT_INTERVAL_SECS")?
123                .unwrap_or(config.interval.as_secs()),
124        );
125        config.keep = crate::config::env_parse("ARETE_SNAPSHOT_KEEP")?.unwrap_or(config.keep);
126        config.snapshot_on_shutdown = crate::config::env_bool("ARETE_SNAPSHOT_ON_SHUTDOWN")?
127            .unwrap_or(config.snapshot_on_shutdown);
128        config.min_mutations = crate::config::env_parse("ARETE_SNAPSHOT_MIN_MUTATIONS")?
129            .unwrap_or(config.min_mutations);
130        config.max_resume_age_slots =
131            crate::config::env_parse("ARETE_SNAPSHOT_MAX_RESUME_AGE_SLOTS")?
132                .unwrap_or(config.max_resume_age_slots);
133        config.ready_max_lag_slots =
134            crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_LAG_SLOTS")?
135                .unwrap_or(config.ready_max_lag_slots);
136        config.ready_max_hold = Duration::from_secs(
137            crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_HOLD_SECS")?
138                .unwrap_or(config.ready_max_hold.as_secs()),
139        );
140        config.legacy_bytecode_hashes = std::env::var("ARETE_SNAPSHOT_LEGACY_BYTECODE_HASHES")
141            .ok()
142            .into_iter()
143            .flat_map(|value| {
144                value
145                    .split(',')
146                    .map(str::trim)
147                    .filter(|hash| !hash.is_empty())
148                    .map(str::to_ascii_lowercase)
149                    .collect::<Vec<_>>()
150            })
151            .collect();
152        config.validate()?;
153        Ok(config)
154    }
155
156    pub fn validate(&self) -> Result<()> {
157        if self.enabled && self.url.as_deref().is_none_or(|url| url.trim().is_empty()) {
158            anyhow::bail!("snapshots are enabled but ARETE_SNAPSHOT_URL is not set");
159        }
160        if self.enabled && (self.interval.is_zero() || self.keep == 0) {
161            anyhow::bail!("snapshot interval and keep count must be greater than zero");
162        }
163        for hash in &self.legacy_bytecode_hashes {
164            if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
165                anyhow::bail!(
166                    "ARETE_SNAPSHOT_LEGACY_BYTECODE_HASHES contains invalid SHA-256 hash '{hash}'"
167                );
168            }
169        }
170        Ok(())
171    }
172}
173
174fn contract_hash<T: Serialize>(schema: &str, value: &T) -> SnapshotContract {
175    let canonical = arete_hash::canonicalize_jcs(value)
176        .expect("snapshot contracts contain only canonical JSON values");
177    let hash = hex::encode(Sha256::digest(canonical));
178    SnapshotContract {
179        schema: schema.to_string(),
180        hash,
181    }
182}
183
184fn state_contract(spec: &crate::Spec) -> Option<SnapshotContract> {
185    if spec.entity_specs.is_empty() {
186        return None;
187    }
188    let mut entities = Vec::with_capacity(spec.entity_specs.len());
189    for entity in &spec.entity_specs {
190        let mut indexes = entity
191            .identity
192            .lookup_indexes
193            .iter()
194            .map(|index| (&index.field_name, &index.temporal_field))
195            .collect::<Vec<_>>();
196        indexes.sort();
197        indexes.dedup();
198        let fields = entity
199            .field_mappings
200            .iter()
201            .map(|(path, field)| {
202                (
203                    path,
204                    serde_json::json!({
205                        "baseType": field.base_type,
206                        "integerKind": field.integer_kind,
207                        "isOptional": field.is_optional,
208                        "isArray": field.is_array,
209                        "innerType": field.inner_type,
210                        "resolvedType": field.resolved_type,
211                        "emit": field.emit,
212                    }),
213                )
214            })
215            .collect::<BTreeMap<_, _>>();
216        entities.push(serde_json::json!({
217            "name": entity.state_name,
218            "primaryKeys": entity.identity.primary_keys,
219            "lookupIndexes": indexes,
220            "fields": fields,
221        }));
222    }
223    entities.sort_by_key(|entity| entity["name"].as_str().unwrap_or_default().to_string());
224    Some(contract_hash(
225        STATE_CONTRACT_SCHEMA_V1,
226        &serde_json::json!({"entities": entities}),
227    ))
228}
229
230fn projection_contract(view_index: &ViewIndex) -> SnapshotContract {
231    contract_hash(PROJECTION_CONTRACT_SCHEMA_V1, &view_index.snapshot_specs())
232}
233
234/// The row-scope policy, or `None` when no entity is scoped. Kept out of the
235/// projection contract so that a policy change is detected even when the
236/// stack's bytecode is unchanged.
237fn row_scope_contract(view_index: &ViewIndex) -> Option<SnapshotContract> {
238    let scopes = view_index.entity_scope_paths();
239    (!scopes.is_empty()).then(|| contract_hash(ROW_SCOPE_CONTRACT_SCHEMA_V1, &scopes))
240}
241
242fn state_ids_by_entity(spec: &crate::Spec) -> HashMap<String, u32> {
243    spec.bytecode
244        .entities
245        .iter()
246        .map(|(name, entity)| (name.clone(), entity.state_id))
247        .collect()
248}
249
250fn remap_snapshot_states(vm: &mut VmSnapshot, state_ids: &HashMap<String, u32>) -> Result<()> {
251    let states = std::mem::take(&mut vm.states);
252    for (_, table) in states {
253        let state_id = state_ids
254            .get(&table.entity_name)
255            .with_context(|| format!("snapshot contains unknown entity '{}'", table.entity_name))?;
256        if vm.states.insert(*state_id, table).is_some() {
257            anyhow::bail!("snapshot contains duplicate entity state for id {state_id}");
258        }
259    }
260    Ok(())
261}
262
263/// VM state handed from the restore path to the generated runtime, consumed
264/// exactly once via [`take_restored`].
265pub struct RestoredState {
266    pub vm: VmSnapshot,
267    /// `Some(slot)` to resume the Yellowstone stream from that slot; `None`
268    /// when the snapshot was too stale (state still hydrates, stream starts
269    /// live and account-derived state self-heals).
270    pub resume_watermark: Option<u64>,
271}
272
273#[derive(Clone)]
274struct RuntimeRegistration {
275    vm: Arc<StdMutex<VmContext>>,
276    slot_tracker: SlotTracker,
277}
278
279struct ResumeGate {
280    started: Instant,
281    max_lag_slots: u64,
282    max_hold: Duration,
283}
284
285/// Per-runtime barrier that keeps snapshot capture from splitting a VM update
286/// from the projection batch it produced.
287///
288/// Generated mutation producers enter the barrier in shared mode before
289/// touching the VM and transfer the guard to their [`MutationBatch`]. The
290/// projector releases it only after applying that batch. Snapshot capture
291/// enters in exclusive mode, which therefore waits for both in-flight parser
292/// work and queued projection work to finish.
293#[derive(Clone, Default)]
294pub struct SnapshotBarrier {
295    inner: Arc<RwLock<()>>,
296}
297
298impl SnapshotBarrier {
299    pub async fn enter_processing(&self) -> SnapshotProcessingGuard {
300        SnapshotProcessingGuard(self.inner.clone().read_owned().await)
301    }
302
303    async fn enter_snapshot(&self) -> OwnedRwLockWriteGuard<()> {
304        self.inner.clone().write_owned().await
305    }
306}
307
308/// Shared processing guard carried by a mutation batch until projection is
309/// complete. The inner guard is intentionally opaque outside arete-server.
310pub struct SnapshotProcessingGuard(#[allow(dead_code)] OwnedRwLockReadGuard<()>);
311
312impl std::fmt::Debug for SnapshotProcessingGuard {
313    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        formatter.write_str("SnapshotProcessingGuard")
315    }
316}
317
318#[derive(Default)]
319struct SnapshotRuntimeState {
320    registered: StdMutex<Option<RuntimeRegistration>>,
321    restored: StdMutex<Option<RestoredState>>,
322    resume_gate: StdMutex<Option<ResumeGate>>,
323    processing_barrier: SnapshotBarrier,
324    /// Highest slot among mutation batches this runtime's projector has
325    /// applied. This is the safe `from_slot` resume point (`SlotTracker` is
326    /// not: it follows the raw slot subscription, not parser progress).
327    resume_watermark: AtomicU64,
328    applied_batches: AtomicU64,
329}
330
331/// Per-server snapshot coordination shared by its parser, projector, snapshot
332/// manager, and readiness endpoint.
333///
334/// The generated parser hooks use [`scope`](Self::scope) so their existing
335/// argument-free calls cannot accidentally bind to another server running in
336/// the same process.
337#[derive(Clone, Default)]
338pub struct SnapshotRuntime {
339    state: Arc<SnapshotRuntimeState>,
340}
341
342tokio::task_local! {
343    static ACTIVE_SNAPSHOT_RUNTIME: SnapshotRuntime;
344}
345
346impl SnapshotRuntime {
347    /// Run a generated parser future with this server's snapshot state.
348    pub async fn scope<F>(&self, future: F) -> F::Output
349    where
350        F: Future,
351    {
352        ACTIVE_SNAPSHOT_RUNTIME.scope(self.clone(), future).await
353    }
354
355    /// Associate the parser's VM and slot tracker with this server only.
356    pub fn register_runtime(
357        &self,
358        vm: Arc<StdMutex<VmContext>>,
359        slot_tracker: SlotTracker,
360    ) -> SnapshotBarrier {
361        let mut registered = self.state.registered.lock().unwrap();
362        if registered.is_some() {
363            debug!("Snapshot runtime registration replaced");
364        }
365        *registered = Some(RuntimeRegistration { vm, slot_tracker });
366        self.state.processing_barrier.clone()
367    }
368
369    /// Consume this server's restored VM state exactly once.
370    pub fn take_restored(&self) -> Option<RestoredState> {
371        self.state.restored.lock().unwrap().take()
372    }
373
374    /// Record a batch applied by this server's projector.
375    pub(crate) fn record_applied_batch(&self, slot: Option<u64>) {
376        self.state.applied_batches.fetch_add(1, Ordering::Relaxed);
377        if let Some(slot) = slot {
378            self.state
379                .resume_watermark
380                .fetch_max(slot, Ordering::Relaxed);
381        }
382    }
383
384    /// Returns `true` unless this server's watermark resume is still catching
385    /// up to its observed slot tip.
386    pub fn resume_gate_ready(&self) -> bool {
387        let mut gate_slot = self.state.resume_gate.lock().unwrap();
388        let Some(gate) = gate_slot.as_ref() else {
389            return true;
390        };
391        if gate.started.elapsed() >= gate.max_hold {
392            info!("Snapshot resume readiness gate released (max hold reached)");
393            *gate_slot = None;
394            return true;
395        }
396        let tip = self
397            .state
398            .registered
399            .lock()
400            .unwrap()
401            .as_ref()
402            .map(|registration| registration.slot_tracker.get())
403            .unwrap_or(0);
404        let applied = self.state.resume_watermark.load(Ordering::Relaxed);
405        if tip > 0 && tip.saturating_sub(applied) <= gate.max_lag_slots {
406            info!(tip, applied, "Snapshot resume caught up; marking ready");
407            *gate_slot = None;
408            return true;
409        }
410        false
411    }
412}
413
414/// Called by the generated runtime after it creates its `VmContext` and
415/// `SlotTracker`, so the snapshot manager can dump them later.
416///
417/// Whether or not snapshots are enabled, this also links the VM to the
418/// server's projector, which asks it for whole entities its bounded cache had
419/// to drop (see [`crate::projector::EntityResync`]).
420pub fn register_runtime(
421    vm: Arc<StdMutex<VmContext>>,
422    slot_tracker: SlotTracker,
423) -> Option<SnapshotBarrier> {
424    crate::projector::link_active_resync(&vm);
425    match ACTIVE_SNAPSHOT_RUNTIME.try_with(|runtime| runtime.register_runtime(vm, slot_tracker)) {
426        Ok(barrier) => Some(barrier),
427        Err(_) => {
428            debug!("Snapshot runtime registration ignored (snapshots disabled)");
429            None
430        }
431    }
432}
433
434/// Called by the generated runtime before connecting: returns the restored VM
435/// state (if any) exactly once.
436pub fn take_restored() -> Option<RestoredState> {
437    ACTIVE_SNAPSHOT_RUNTIME
438        .try_with(SnapshotRuntime::take_restored)
439        .ok()
440        .flatten()
441}
442
443/// Where the generated Yellowstone runtime should resume its stream.
444///
445/// `Option<u64>` cannot express this: "no checkpoint to resume from" and
446/// "gave up on the checkpoint" are both `None`, and only the second one
447/// loses data. Naming them apart is what lets the caller mark the hole.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum ReconnectPosition {
450    /// Resume from this slot; nothing is lost.
451    Slot(u64),
452    /// Start live because nothing has been processed yet.
453    Live,
454    /// Start live after abandoning a checkpoint the provider would not serve.
455    /// Every slot between `abandoned` and the live tip is lost.
456    LiveAfterGap { abandoned: u64 },
457}
458
459impl ReconnectPosition {
460    /// The `from_slot` to put on the subscription request.
461    pub fn from_slot(self) -> Option<u64> {
462        match self {
463            Self::Slot(slot) => Some(slot),
464            Self::Live | Self::LiveAfterGap { .. } => None,
465        }
466    }
467}
468
469/// Select a reconnect checkpoint for the generated Yellowstone runtime.
470///
471/// A restored replay never falls back to live: retries advance only to slots
472/// the main parser stream has finished processing. Without a restored replay,
473/// repeated short-lived connections eventually give up on the checkpoint —
474/// unless `live_fallback_attempts` is `None`, which refuses to trade data for
475/// availability.
476#[doc(hidden)]
477pub fn select_reconnect_from_slot(
478    restored_watermark: Option<u64>,
479    processed_watermark: u64,
480    attempt: u32,
481    live_fallback_attempts: Option<u32>,
482) -> ReconnectPosition {
483    if let Some(restored_watermark) = restored_watermark {
484        return ReconnectPosition::Slot(restored_watermark.max(processed_watermark));
485    }
486    if processed_watermark == 0 {
487        // Nothing has been processed, so starting live loses nothing.
488        return ReconnectPosition::Live;
489    }
490    match live_fallback_attempts {
491        Some(limit) if attempt >= limit => ReconnectPosition::LiveAfterGap {
492            abandoned: processed_watermark,
493        },
494        _ => ReconnectPosition::Slot(processed_watermark),
495    }
496}
497
498fn now_epoch_ms() -> u64 {
499    SystemTime::now()
500        .duration_since(UNIX_EPOCH)
501        .unwrap()
502        .as_millis() as u64
503}
504
505/// What kicked off a snapshot cycle.
506#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum SnapshotTrigger {
509    Periodic,
510    Shutdown,
511}
512
513/// Owns the store plus everything needed to dump and restore state. Created by
514/// `Runtime::run` when snapshots are enabled.
515pub struct SnapshotService {
516    config: SnapshotConfig,
517    store: Arc<dyn SnapshotStore>,
518    runtime: SnapshotRuntime,
519    bytecode_hash: String,
520    state_contract: Option<SnapshotContract>,
521    projection_contract: SnapshotContract,
522    row_scope_contract: Option<SnapshotContract>,
523    state_ids: HashMap<String, u32>,
524    program_ids: Vec<String>,
525    entity_cache: EntityCache,
526    journal: Arc<crate::journal::EventJournal>,
527    batches_at_last_snapshot: AtomicU64,
528    warned_missing_vm: AtomicBool,
529}
530
531impl SnapshotService {
532    /// Build the store, then attempt a restore (any failure logs a warning
533    /// and cold-starts — restore problems must never block startup).
534    pub async fn initialize(
535        config: SnapshotConfig,
536        spec: &crate::Spec,
537        entity_cache: EntityCache,
538        view_index: &ViewIndex,
539        journal: Arc<crate::journal::EventJournal>,
540        _mutations_tx: mpsc::Sender<MutationBatch>,
541    ) -> Result<Arc<Self>> {
542        let url = config
543            .url
544            .clone()
545            .context("snapshots are enabled but no snapshot URL is configured")?;
546        let store = store::store_from_url(&url)?;
547
548        let mut program_ids = spec.program_ids.clone();
549        program_ids.sort();
550
551        let service = Arc::new(Self {
552            config,
553            store,
554            runtime: SnapshotRuntime::default(),
555            bytecode_hash: spec.bytecode.fingerprint(),
556            state_contract: state_contract(spec),
557            projection_contract: projection_contract(view_index),
558            row_scope_contract: row_scope_contract(view_index),
559            state_ids: state_ids_by_entity(spec),
560            program_ids,
561            entity_cache,
562            journal,
563            batches_at_last_snapshot: AtomicU64::new(0),
564            warned_missing_vm: AtomicBool::new(false),
565        });
566        info!(
567            store = %service.store.describe(),
568            interval_secs = service.config.interval.as_secs(),
569            keep = service.config.keep,
570            "State snapshots enabled"
571        );
572
573        match service.restore(view_index).await {
574            Ok(true) => {}
575            Ok(false) => info!("No usable snapshot found; starting cold"),
576            Err(err) => warn!(
577                error = format!("{err:#}"),
578                "Failed to restore snapshot; starting cold"
579            ),
580        }
581        Ok(service)
582    }
583
584    pub fn config(&self) -> &SnapshotConfig {
585        &self.config
586    }
587
588    /// Return the per-server coordination handle that must be shared with the
589    /// matching parser, projector, and readiness endpoint.
590    pub fn runtime(&self) -> SnapshotRuntime {
591        self.runtime.clone()
592    }
593
594    /// Load and validate the latest snapshot, hydrate the projection caches,
595    /// and stash the VM portion for the generated runtime. Returns whether a
596    /// snapshot was applied.
597    async fn restore(&self, view_index: &ViewIndex) -> Result<bool> {
598        let Some((name, bytes)) = self.store.load_latest().await? else {
599            return Ok(false);
600        };
601
602        let header = envelope::decode_header(&bytes)
603            .with_context(|| format!("snapshot {name} has an unreadable header"))?;
604
605        if header.format_version != SNAPSHOT_FORMAT_VERSION {
606            warn!(
607                snapshot = %name,
608                found = header.format_version,
609                expected = SNAPSHOT_FORMAT_VERSION,
610                "Snapshot format version mismatch; discarding (cold start)"
611            );
612            return Ok(false);
613        }
614        let exact_bytecode = header.bytecode_hash == self.bytecode_hash;
615        let matching_contracts = self.state_contract.is_some()
616            && header.state_contract == self.state_contract
617            && header.projection_contract.as_ref() == Some(&self.projection_contract);
618        let approved_legacy = !exact_bytecode
619            && header.state_contract.is_none()
620            && header.projection_contract.is_none()
621            && self
622                .config
623                .legacy_bytecode_hashes
624                .contains(&header.bytecode_hash);
625        let legacy_migration = approved_legacy;
626        if !exact_bytecode && !matching_contracts && !approved_legacy {
627            warn!(
628                snapshot = %name,
629                "Snapshot was taken by an incompatible stack build; discarding (cold start)"
630            );
631            return Ok(false);
632        }
633        let mut snapshot_program_ids = header.program_ids.clone();
634        snapshot_program_ids.sort();
635        if snapshot_program_ids != self.program_ids {
636            warn!(
637                snapshot = %name,
638                "Snapshot program ids do not match this server; discarding (cold start)"
639            );
640            return Ok(false);
641        }
642
643        let mut payload = tokio::task::spawn_blocking(move || envelope::decode_payload(&bytes))
644            .await
645            .context("snapshot decode task panicked")?
646            .with_context(|| format!("snapshot {name} has an unreadable payload"))?;
647        if !exact_bytecode {
648            remap_snapshot_states(&mut payload.vm, &self.state_ids)
649                .with_context(|| format!("snapshot {name} state contract is incompatible"))?;
650        }
651        // Retained frames carry the row scope they were published under, and
652        // replay authorises them by it. Under a different policy those scopes
653        // are wrong, so the projections are rebuilt as for a legacy snapshot.
654        let row_scopes_changed = header.row_scope_contract != self.row_scope_contract;
655        if row_scopes_changed {
656            warn!(
657                snapshot = %name,
658                "Snapshot was taken under a different row-scope policy; rebuilding projections from live input"
659            );
660        }
661        if legacy_migration || row_scopes_changed {
662            // A pre-contract snapshot cannot prove that its materialized views
663            // still match the current projections. Preserve only durable VM
664            // state and let live input rebuild every projection cache.
665            payload.entity_cache.clear();
666            payload.entity_lifetimes = Some(Default::default());
667            payload.entity_tombstones.clear();
668            // Retained frames are published view output, shaped by the same
669            // projections, so the same doubt applies — and replaying stale
670            // frames is worse than a stale cache, because consumers keep them.
671            payload.journal = Default::default();
672        }
673
674        let cached_views = payload.entity_cache.len();
675        let cached_entities: usize = payload
676            .entity_cache
677            .iter()
678            .map(|(_, entries)| entries.len())
679            .sum();
680        let retained_events: usize = payload
681            .journal
682            .views
683            .values()
684            .map(|view| view.records.len())
685            .sum();
686        self.entity_cache.hydrate(payload.entity_cache).await;
687        match payload.entity_lifetimes {
688            Some(lifetimes) => self.entity_cache.hydrate_lifetimes(lifetimes).await,
689            None => {
690                // Migrate the old account delete/recreation barriers once;
691                // all newly written snapshots persist their ordering domain.
692                self.entity_cache
693                    .hydrate_legacy_tombstones(payload.entity_tombstones)
694                    .await;
695            }
696        }
697        // The VM keeps more entities than the cache does (and after a legacy
698        // migration the cache keeps none), so its next patch for one the
699        // cache lacks is only the fields that changed. A VM linked to the
700        // projector has that patch refused anyway: a restored entity's next
701        // mutation is not its creation, and only a creation is stored for a
702        // key the cache lacks. Treat them all as evicted as well, for a VM
703        // registered without that link; the memory is bounded (see
704        // `EntityCache`), and a view fed by a linked VM drops it.
705        remember_uncached_vm_keys(view_index, &payload.vm, &self.entity_cache).await;
706        // Only a shutdown snapshot is exact; see `EventJournal::hydrate`.
707        let exact_offsets = header.trigger == Some(SnapshotTrigger::Shutdown);
708        self.journal.hydrate(payload.journal, exact_offsets).await;
709        rebuild_sorted_caches(view_index, &self.entity_cache).await;
710
711        // Even when the stream starts live, the watermark seeds the applied
712        // position: the hydrated state already contains everything up to it.
713        self.runtime
714            .state
715            .resume_watermark
716            .fetch_max(header.resume_watermark, Ordering::Relaxed);
717
718        let age_ms = now_epoch_ms().saturating_sub(header.created_at_epoch_ms);
719        let estimated_age_slots = age_ms / ESTIMATED_SLOT_MILLIS;
720        // File age alone is insufficient: a shutdown checkpoint is freshly
721        // written even when a quiet program's last applied update is already
722        // far behind the observed chain tip. Account for both the lag already
723        // present at the consistency cut and the time elapsed since it.
724        let watermark_lag_at_snapshot =
725            header.observed_slot.saturating_sub(header.resume_watermark);
726        let estimated_resume_lag = watermark_lag_at_snapshot.saturating_add(estimated_age_slots);
727        let resume_watermark = if exact_bytecode
728            && header.resume_watermark > 0
729            && estimated_resume_lag <= self.config.max_resume_age_slots
730        {
731            Some(header.resume_watermark)
732        } else {
733            if header.resume_watermark > 0 && exact_bytecode {
734                warn!(
735                    resume_watermark = header.resume_watermark,
736                    observed_slot = header.observed_slot,
737                    watermark_lag_at_snapshot,
738                    estimated_age_slots,
739                    estimated_resume_lag,
740                    max_resume_age_slots = self.config.max_resume_age_slots,
741                    "Snapshot watermark is outside the resume window; hydrating state but \
742                     starting the stream live. Account-derived state self-heals from \
743                     full account writes; only instruction events in the gap are missed."
744                );
745            }
746            if !exact_bytecode {
747                warn!(
748                    snapshot = %name,
749                    matching_contracts,
750                    approved_legacy,
751                    legacy_state_only = legacy_migration,
752                    "Hydrated snapshot state from different bytecode; starting live"
753                );
754            }
755            None
756        };
757
758        if resume_watermark.is_none() {
759            // The stream starts live, so events between the retained tape and
760            // the first live append are lost. Offsets stay dense across that
761            // hole, which would present it to a consumer as an unbroken
762            // continuation — mark it so a replay across it is refused instead.
763            self.journal.mark_gap().await;
764        }
765
766        if resume_watermark.is_some() {
767            *self.runtime.state.resume_gate.lock().unwrap() = Some(ResumeGate {
768                started: Instant::now(),
769                max_lag_slots: self.config.ready_max_lag_slots,
770                max_hold: self.config.ready_max_hold,
771            });
772        }
773
774        info!(
775            snapshot = %name,
776            vm_entities = payload.vm.total_entries(),
777            cached_views,
778            cached_entities,
779            retained_events,
780            resume_watermark = header.resume_watermark,
781            resuming = resume_watermark.is_some(),
782            age_secs = age_ms / 1_000,
783            "Restored state from snapshot"
784        );
785
786        *self.runtime.state.restored.lock().unwrap() = Some(RestoredState {
787            vm: payload.vm,
788            resume_watermark,
789        });
790        Ok(true)
791    }
792
793    /// Spawn the periodic snapshot task.
794    pub fn spawn(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
795        let service = Arc::clone(self);
796        tokio::spawn(
797            async move {
798                let mut interval = tokio::time::interval(service.config.interval);
799                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
800                // The first tick fires immediately; skip it so the first
801                // snapshot lands one full interval after startup.
802                interval.tick().await;
803                loop {
804                    interval.tick().await;
805                    if let Err(err) = service.snapshot_now(SnapshotTrigger::Periodic).await {
806                        // Snapshotting must never take down a healthy server.
807                        warn!(
808                            error = format!("{err:#}"),
809                            "Snapshot cycle failed; will retry next interval"
810                        );
811                    }
812                }
813            }
814            .instrument(info_span!("snapshot.manager")),
815        )
816    }
817
818    /// Run one snapshot cycle. Returns `Ok(false)` when skipped (no VM
819    /// registered yet, or too few mutations since the last snapshot).
820    pub async fn snapshot_now(&self, trigger: SnapshotTrigger) -> Result<bool> {
821        let Some(registration) = self.runtime.state.registered.lock().unwrap().clone() else {
822            if !self.warned_missing_vm.swap(true, Ordering::Relaxed) {
823                warn!(
824                    "Snapshots are enabled but no VM has been registered; the stack \
825                     may have been built with an older arete-macros version"
826                );
827            }
828            return Ok(false);
829        };
830
831        let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
832        if trigger == SnapshotTrigger::Periodic {
833            let since_last = applied_batches
834                .saturating_sub(self.batches_at_last_snapshot.load(Ordering::Relaxed));
835            if since_last < self.config.min_mutations {
836                debug!(since_last, "Skipping snapshot cycle (too few mutations)");
837                return Ok(false);
838            }
839        }
840
841        // Wait for every in-flight VM update and its queued projection batch
842        // to finish, then block new updates until both sides have been dumped.
843        // Processing guards move with their mutation batches and are released
844        // by the projector only after cache application, so this exclusive
845        // guard establishes one exact cut without an enqueue race.
846        let consistency_guard = tokio::time::timeout(
847            CONSISTENCY_CUT_TIMEOUT,
848            self.runtime.state.processing_barrier.enter_snapshot(),
849        )
850        .await
851        .context("timed out waiting for a consistent VM/projection snapshot cut")?;
852
853        let dump_started = Instant::now();
854        let (vm_snapshot, resume_watermark) = {
855            let vm = registration
856                .vm
857                .lock()
858                .map_err(|_| anyhow::anyhow!("VM mutex poisoned"))?;
859            let resume_watermark = self.runtime.state.resume_watermark.load(Ordering::Relaxed);
860            let vm_snapshot = vm.dump();
861            (vm_snapshot, resume_watermark)
862        };
863        let vm_lock_held = dump_started.elapsed();
864        let observed_slot = registration.slot_tracker.get();
865        // Shares the cached entities' fields; encoding writes them out.
866        let entity_cache_dump = self.entity_cache.dump_shared().await;
867        // Dumped inside the same consistency guard as the cache, so a restore
868        // can never leave the cache ahead of the tape.
869        let journal_dump = self.journal.dump().await;
870        if trigger == SnapshotTrigger::Shutdown {
871            // Publishing continues after the guard releases — the parser is
872            // aborted only once this snapshot is encoded and stored — so
873            // without this the file would not hold every offset that reached a
874            // subscriber, and the restore below would adopt its epoch anyway.
875            self.journal.seal();
876        }
877        let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
878        drop(consistency_guard);
879
880        let created_at_epoch_ms = now_epoch_ms();
881        let header = SnapshotHeader {
882            format_version: SNAPSHOT_FORMAT_VERSION,
883            bytecode_hash: self.bytecode_hash.clone(),
884            state_contract: self.state_contract.clone(),
885            projection_contract: Some(self.projection_contract.clone()),
886            row_scope_contract: self.row_scope_contract.clone(),
887            program_ids: self.program_ids.clone(),
888            resume_watermark,
889            observed_slot,
890            created_at_epoch_ms,
891            trigger: Some(trigger),
892            entry_counts: vm_snapshot
893                .entry_counts()
894                .into_iter()
895                .chain(
896                    // Retained record counts are otherwise invisible after the
897                    // restore log line.
898                    // Derived from the dump rather than a second trip
899                    // through the journal's lock inside the cut.
900                    journal_dump.views.iter().map(|(view_id, view)| {
901                        (format!("journal:{view_id}"), view.records.len() as u64)
902                    }),
903                )
904                .collect(),
905        };
906        let payload = envelope::SharedSnapshotPayload {
907            entity_lifetimes: Some(self.entity_cache.dump_lifetimes().await),
908            entity_tombstones: Default::default(),
909            vm: vm_snapshot,
910            entity_cache: entity_cache_dump,
911            journal: journal_dump,
912        };
913        let bytes = tokio::task::spawn_blocking(move || envelope::encode_shared(&header, &payload))
914            .await
915            .context("snapshot encode task panicked")??;
916
917        let name = store::snapshot_name(created_at_epoch_ms, resume_watermark);
918        self.store.write(&name, &bytes).await?;
919        if let Err(err) = self.store.prune(self.config.keep).await {
920            warn!(error = format!("{err:#}"), "Failed to prune old snapshots");
921        }
922        self.batches_at_last_snapshot
923            .store(applied_batches, Ordering::Relaxed);
924
925        info!(
926            snapshot = %name,
927            bytes = bytes.len(),
928            resume_watermark,
929            observed_slot,
930            vm_lock_ms = vm_lock_held.as_millis() as u64,
931            trigger = ?trigger,
932            "Snapshot written"
933        );
934        Ok(true)
935    }
936}
937
938/// Mark every entity the restored VM holds but a restored view does not as
939/// evicted from that view, so the VM's next patch for it — only the fields
940/// that changed — is refused rather than stored as the whole entity even if
941/// the VM does not mark creations (see [`EntityCache`]'s "Only whole
942/// entities").
943async fn remember_uncached_vm_keys(
944    view_index: &ViewIndex,
945    vm: &VmSnapshot,
946    entity_cache: &EntityCache,
947) {
948    for table in vm.states.values() {
949        let views = view_index.by_export(&table.entity_name);
950        if views.is_empty() || table.data.is_empty() {
951            continue;
952        }
953        let keys: Vec<String> = table
954            .data
955            .iter()
956            .map(|(key, _)| crate::projector::Projector::extract_key(key))
957            .collect();
958        for view in views {
959            entity_cache
960                .remember_evicted(&view.id, keys.iter().cloned())
961                .await;
962        }
963    }
964}
965
966/// Rebuild each derived `SortedViewCache` from the hydrated `EntityCache`,
967/// mirroring the projector's upsert path. Sorted caches are not persisted:
968/// they are derived state and MB-scale rebuilds are sub-millisecond.
969async fn rebuild_sorted_caches(view_index: &ViewIndex, entity_cache: &EntityCache) {
970    let sorted_caches = view_index.sorted_caches();
971    let max_entries = entity_cache.max_entities_per_view();
972    for spec in view_index.get_derived_views() {
973        let Some(source_view) = spec.source_view.as_ref() else {
974            continue;
975        };
976        // The source view's own copies: derived views share their fields.
977        let entities = entity_cache.get_all_shared(source_view).await;
978        let filter = spec
979            .pipeline
980            .as_ref()
981            .and_then(|pipeline| pipeline.filter.as_ref());
982        let mut caches = sorted_caches.write().await;
983        if let Some(cache) = caches.get_mut(&spec.id) {
984            cache.clear();
985            let count = entities.len();
986            for (key, entity) in entities {
987                if filter.is_none_or(|filter| filter.matches(&entity)) {
988                    cache.put(key, entity);
989                }
990            }
991            // Trim once after the batch; the bound matches the projector's.
992            cache.trim_to_max_entries(max_entries);
993            debug!(view_id = %spec.id, count, "Rebuilt sorted cache from snapshot");
994        }
995    }
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use crate::cache::CacheWrite;
1002    use crate::view::{Delivery, Filters, Projection, ViewSpec};
1003    use crate::websocket::frame::Mode;
1004    use arete_interpreter::snapshot::StateTableSnapshot;
1005    use serde_json::json;
1006
1007    fn view(id: &str, mode: Mode) -> ViewSpec {
1008        ViewSpec {
1009            id: id.to_string(),
1010            export: "Round".to_string(),
1011            mode,
1012            wire_format: Default::default(),
1013            projection: Projection::all(),
1014            filters: Filters::all(),
1015            delivery: Delivery::default(),
1016            pipeline: None,
1017            source_view: None,
1018        }
1019    }
1020
1021    #[tokio::test]
1022    async fn rebuilding_sorted_views_from_an_empty_snapshot_clears_previous_rows() {
1023        use crate::materialized_view::{SortConfig, SortOrder, ViewPipeline};
1024        let mut index = ViewIndex::new();
1025        index.add_spec(view("Round/list", Mode::List));
1026        let mut derived = view("Round/sorted", Mode::List);
1027        derived.source_view = Some("Round/list".into());
1028        derived.pipeline = Some(ViewPipeline {
1029            sort: Some(SortConfig {
1030                field_path: vec!["id".into()],
1031                order: SortOrder::Desc,
1032            }),
1033            ..Default::default()
1034        });
1035        index.add_spec(derived);
1036        index
1037            .sorted_caches()
1038            .write()
1039            .await
1040            .get_mut("Round/sorted")
1041            .unwrap()
1042            .upsert("old".into(), json!({"id":1}));
1043        rebuild_sorted_caches(&index, &EntityCache::new()).await;
1044        assert!(index
1045            .sorted_caches()
1046            .write()
1047            .await
1048            .get_mut("Round/sorted")
1049            .unwrap()
1050            .get_all_ordered()
1051            .is_empty());
1052    }
1053
1054    /// After a restore the VM holds entities the cache does not; the VM's next
1055    /// patch for one of them is a fragment and must not become the entity.
1056    #[tokio::test]
1057    async fn restored_vm_entities_missing_from_the_cache_are_remembered_as_evicted() {
1058        let mut index = ViewIndex::new();
1059        index.add_spec(view("Round/list", Mode::List));
1060        index.add_spec(view("Round/state", Mode::State));
1061        let cache = EntityCache::new();
1062        cache
1063            .hydrate(vec![(
1064                "Round/list".to_string(),
1065                vec![("2".to_string(), json!({"id": 2}))],
1066            )])
1067            .await;
1068        let vm = VmSnapshot {
1069            states: HashMap::from([(
1070                0,
1071                StateTableSnapshot {
1072                    entity_name: "Round".to_string(),
1073                    data: vec![(json!(1), json!({"id": 1})), (json!(2), json!({"id": 2}))],
1074                    ..Default::default()
1075                },
1076            )]),
1077            ..Default::default()
1078        };
1079
1080        remember_uncached_vm_keys(&index, &vm, &cache).await;
1081
1082        assert!(matches!(
1083            cache.upsert("Round/list", "1", json!({"n": 1})).await,
1084            CacheWrite::Refused { .. }
1085        ));
1086        assert!(matches!(
1087            cache.upsert("Round/state", "1", json!({"n": 1})).await,
1088            CacheWrite::Refused { .. }
1089        ));
1090        assert_eq!(
1091            cache.upsert("Round/list", "2", json!({"n": 1})).await,
1092            CacheWrite::Merged
1093        );
1094        // A key the VM never held is new.
1095        assert_eq!(
1096            cache.upsert("Round/list", "3", json!({"id": 3})).await,
1097            CacheWrite::Created
1098        );
1099    }
1100}