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