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 = 400;
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 snapshot is older than this many (estimated) slots, hydrate
76    /// state but start the stream live instead of resuming from the watermark.
77    pub max_resume_age_slots: u64,
78    /// `/ready` stays 503 after a watermark resume until the projector is
79    /// within this many slots of the observed tip...
80    pub ready_max_lag_slots: u64,
81    /// ...or until this much time has passed (guards quiet stacks, where the
82    /// watermark never advances because nothing happens on-chain).
83    pub ready_max_hold: Duration,
84    /// Bytecode hashes from pre-contract snapshots that an operator has
85    /// explicitly approved for one state-only migration. These snapshots are
86    /// hydrated after structural state-id remapping and always start live.
87    pub legacy_bytecode_hashes: BTreeSet<String>,
88}
89
90impl Default for SnapshotConfig {
91    fn default() -> Self {
92        Self {
93            enabled: false,
94            url: None,
95            interval: Duration::from_secs(60),
96            keep: 4,
97            snapshot_on_shutdown: true,
98            min_mutations: 1,
99            // ~10 minutes of slots: conservative vs. typical provider
100            // `from_slot` replay windows (in-cluster richat rings are far
101            // more generous; raw Triton is minutes).
102            max_resume_age_slots: 1_500,
103            ready_max_lag_slots: 50,
104            ready_max_hold: Duration::from_secs(60),
105            legacy_bytecode_hashes: BTreeSet::new(),
106        }
107    }
108}
109
110impl SnapshotConfig {
111    /// Load snapshot settings from `ARETE_SNAPSHOT_*` env vars. Snapshots stay
112    /// disabled unless `ARETE_SNAPSHOT_ENABLED=true`.
113    pub fn from_env() -> Result<Self> {
114        let mut config = Self::default();
115        config.enabled = crate::config::env_bool("ARETE_SNAPSHOT_ENABLED")?.unwrap_or(false);
116        config.url = std::env::var("ARETE_SNAPSHOT_URL")
117            .ok()
118            .filter(|value| !value.trim().is_empty());
119        config.interval = Duration::from_secs(
120            crate::config::env_parse("ARETE_SNAPSHOT_INTERVAL_SECS")?
121                .unwrap_or(config.interval.as_secs()),
122        );
123        config.keep = crate::config::env_parse("ARETE_SNAPSHOT_KEEP")?.unwrap_or(config.keep);
124        config.snapshot_on_shutdown = crate::config::env_bool("ARETE_SNAPSHOT_ON_SHUTDOWN")?
125            .unwrap_or(config.snapshot_on_shutdown);
126        config.min_mutations = crate::config::env_parse("ARETE_SNAPSHOT_MIN_MUTATIONS")?
127            .unwrap_or(config.min_mutations);
128        config.max_resume_age_slots =
129            crate::config::env_parse("ARETE_SNAPSHOT_MAX_RESUME_AGE_SLOTS")?
130                .unwrap_or(config.max_resume_age_slots);
131        config.ready_max_lag_slots =
132            crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_LAG_SLOTS")?
133                .unwrap_or(config.ready_max_lag_slots);
134        config.ready_max_hold = Duration::from_secs(
135            crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_HOLD_SECS")?
136                .unwrap_or(config.ready_max_hold.as_secs()),
137        );
138        config.legacy_bytecode_hashes = std::env::var("ARETE_SNAPSHOT_LEGACY_BYTECODE_HASHES")
139            .ok()
140            .into_iter()
141            .flat_map(|value| {
142                value
143                    .split(',')
144                    .map(str::trim)
145                    .filter(|hash| !hash.is_empty())
146                    .map(str::to_ascii_lowercase)
147                    .collect::<Vec<_>>()
148            })
149            .collect();
150        config.validate()?;
151        Ok(config)
152    }
153
154    pub fn validate(&self) -> Result<()> {
155        if self.enabled && self.url.as_deref().is_none_or(|url| url.trim().is_empty()) {
156            anyhow::bail!("snapshots are enabled but ARETE_SNAPSHOT_URL is not set");
157        }
158        if self.enabled && (self.interval.is_zero() || self.keep == 0) {
159            anyhow::bail!("snapshot interval and keep count must be greater than zero");
160        }
161        for hash in &self.legacy_bytecode_hashes {
162            if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
163                anyhow::bail!(
164                    "ARETE_SNAPSHOT_LEGACY_BYTECODE_HASHES contains invalid SHA-256 hash '{hash}'"
165                );
166            }
167        }
168        Ok(())
169    }
170}
171
172fn contract_hash<T: Serialize>(schema: &str, value: &T) -> SnapshotContract {
173    let canonical = arete_hash::canonicalize_jcs(value)
174        .expect("snapshot contracts contain only canonical JSON values");
175    let hash = hex::encode(Sha256::digest(canonical));
176    SnapshotContract {
177        schema: schema.to_string(),
178        hash,
179    }
180}
181
182fn state_contract(spec: &crate::Spec) -> Option<SnapshotContract> {
183    if spec.entity_specs.is_empty() {
184        return None;
185    }
186    let mut entities = Vec::with_capacity(spec.entity_specs.len());
187    for entity in &spec.entity_specs {
188        let mut indexes = entity
189            .identity
190            .lookup_indexes
191            .iter()
192            .map(|index| (&index.field_name, &index.temporal_field))
193            .collect::<Vec<_>>();
194        indexes.sort();
195        indexes.dedup();
196        let fields = entity
197            .field_mappings
198            .iter()
199            .map(|(path, field)| {
200                (
201                    path,
202                    serde_json::json!({
203                        "baseType": field.base_type,
204                        "integerKind": field.integer_kind,
205                        "isOptional": field.is_optional,
206                        "isArray": field.is_array,
207                        "innerType": field.inner_type,
208                        "resolvedType": field.resolved_type,
209                        "emit": field.emit,
210                    }),
211                )
212            })
213            .collect::<BTreeMap<_, _>>();
214        entities.push(serde_json::json!({
215            "name": entity.state_name,
216            "primaryKeys": entity.identity.primary_keys,
217            "lookupIndexes": indexes,
218            "fields": fields,
219        }));
220    }
221    entities.sort_by_key(|entity| entity["name"].as_str().unwrap_or_default().to_string());
222    Some(contract_hash(
223        STATE_CONTRACT_SCHEMA_V1,
224        &serde_json::json!({"entities": entities}),
225    ))
226}
227
228fn projection_contract(view_index: &ViewIndex) -> SnapshotContract {
229    contract_hash(PROJECTION_CONTRACT_SCHEMA_V1, &view_index.snapshot_specs())
230}
231
232fn state_ids_by_entity(spec: &crate::Spec) -> HashMap<String, u32> {
233    spec.bytecode
234        .entities
235        .iter()
236        .map(|(name, entity)| (name.clone(), entity.state_id))
237        .collect()
238}
239
240fn remap_snapshot_states(vm: &mut VmSnapshot, state_ids: &HashMap<String, u32>) -> Result<()> {
241    let states = std::mem::take(&mut vm.states);
242    for (_, table) in states {
243        let state_id = state_ids
244            .get(&table.entity_name)
245            .with_context(|| format!("snapshot contains unknown entity '{}'", table.entity_name))?;
246        if vm.states.insert(*state_id, table).is_some() {
247            anyhow::bail!("snapshot contains duplicate entity state for id {state_id}");
248        }
249    }
250    Ok(())
251}
252
253/// VM state handed from the restore path to the generated runtime, consumed
254/// exactly once via [`take_restored`].
255pub struct RestoredState {
256    pub vm: VmSnapshot,
257    /// `Some(slot)` to resume the Yellowstone stream from that slot; `None`
258    /// when the snapshot was too stale (state still hydrates, stream starts
259    /// live and account-derived state self-heals).
260    pub resume_watermark: Option<u64>,
261}
262
263#[derive(Clone)]
264struct RuntimeRegistration {
265    vm: Arc<StdMutex<VmContext>>,
266    slot_tracker: SlotTracker,
267}
268
269struct ResumeGate {
270    started: Instant,
271    max_lag_slots: u64,
272    max_hold: Duration,
273}
274
275/// Per-runtime barrier that keeps snapshot capture from splitting a VM update
276/// from the projection batch it produced.
277///
278/// Generated mutation producers enter the barrier in shared mode before
279/// touching the VM and transfer the guard to their [`MutationBatch`]. The
280/// projector releases it only after applying that batch. Snapshot capture
281/// enters in exclusive mode, which therefore waits for both in-flight parser
282/// work and queued projection work to finish.
283#[derive(Clone, Default)]
284pub struct SnapshotBarrier {
285    inner: Arc<RwLock<()>>,
286}
287
288impl SnapshotBarrier {
289    pub async fn enter_processing(&self) -> SnapshotProcessingGuard {
290        SnapshotProcessingGuard(self.inner.clone().read_owned().await)
291    }
292
293    async fn enter_snapshot(&self) -> OwnedRwLockWriteGuard<()> {
294        self.inner.clone().write_owned().await
295    }
296}
297
298/// Shared processing guard carried by a mutation batch until projection is
299/// complete. The inner guard is intentionally opaque outside arete-server.
300pub struct SnapshotProcessingGuard(#[allow(dead_code)] OwnedRwLockReadGuard<()>);
301
302impl std::fmt::Debug for SnapshotProcessingGuard {
303    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        formatter.write_str("SnapshotProcessingGuard")
305    }
306}
307
308#[derive(Default)]
309struct SnapshotRuntimeState {
310    registered: StdMutex<Option<RuntimeRegistration>>,
311    restored: StdMutex<Option<RestoredState>>,
312    resume_gate: StdMutex<Option<ResumeGate>>,
313    processing_barrier: SnapshotBarrier,
314    /// Highest slot among mutation batches this runtime's projector has
315    /// applied. This is the safe `from_slot` resume point (`SlotTracker` is
316    /// not: it follows the raw slot subscription, not parser progress).
317    resume_watermark: AtomicU64,
318    applied_batches: AtomicU64,
319}
320
321/// Per-server snapshot coordination shared by its parser, projector, snapshot
322/// manager, and readiness endpoint.
323///
324/// The generated parser hooks use [`scope`](Self::scope) so their existing
325/// argument-free calls cannot accidentally bind to another server running in
326/// the same process.
327#[derive(Clone, Default)]
328pub struct SnapshotRuntime {
329    state: Arc<SnapshotRuntimeState>,
330}
331
332tokio::task_local! {
333    static ACTIVE_SNAPSHOT_RUNTIME: SnapshotRuntime;
334}
335
336impl SnapshotRuntime {
337    /// Run a generated parser future with this server's snapshot state.
338    pub async fn scope<F>(&self, future: F) -> F::Output
339    where
340        F: Future,
341    {
342        ACTIVE_SNAPSHOT_RUNTIME.scope(self.clone(), future).await
343    }
344
345    /// Associate the parser's VM and slot tracker with this server only.
346    pub fn register_runtime(
347        &self,
348        vm: Arc<StdMutex<VmContext>>,
349        slot_tracker: SlotTracker,
350    ) -> SnapshotBarrier {
351        let mut registered = self.state.registered.lock().unwrap();
352        if registered.is_some() {
353            debug!("Snapshot runtime registration replaced");
354        }
355        *registered = Some(RuntimeRegistration { vm, slot_tracker });
356        self.state.processing_barrier.clone()
357    }
358
359    /// Consume this server's restored VM state exactly once.
360    pub fn take_restored(&self) -> Option<RestoredState> {
361        self.state.restored.lock().unwrap().take()
362    }
363
364    /// Record a batch applied by this server's projector.
365    pub(crate) fn record_applied_batch(&self, slot: Option<u64>) {
366        self.state.applied_batches.fetch_add(1, Ordering::Relaxed);
367        if let Some(slot) = slot {
368            self.state
369                .resume_watermark
370                .fetch_max(slot, Ordering::Relaxed);
371        }
372    }
373
374    /// Returns `true` unless this server's watermark resume is still catching
375    /// up to its observed slot tip.
376    pub fn resume_gate_ready(&self) -> bool {
377        let mut gate_slot = self.state.resume_gate.lock().unwrap();
378        let Some(gate) = gate_slot.as_ref() else {
379            return true;
380        };
381        if gate.started.elapsed() >= gate.max_hold {
382            info!("Snapshot resume readiness gate released (max hold reached)");
383            *gate_slot = None;
384            return true;
385        }
386        let tip = self
387            .state
388            .registered
389            .lock()
390            .unwrap()
391            .as_ref()
392            .map(|registration| registration.slot_tracker.get())
393            .unwrap_or(0);
394        let applied = self.state.resume_watermark.load(Ordering::Relaxed);
395        if tip > 0 && tip.saturating_sub(applied) <= gate.max_lag_slots {
396            info!(tip, applied, "Snapshot resume caught up; marking ready");
397            *gate_slot = None;
398            return true;
399        }
400        false
401    }
402}
403
404/// Called by the generated runtime after it creates its `VmContext` and
405/// `SlotTracker`, so the snapshot manager can dump them later.
406pub fn register_runtime(
407    vm: Arc<StdMutex<VmContext>>,
408    slot_tracker: SlotTracker,
409) -> Option<SnapshotBarrier> {
410    match ACTIVE_SNAPSHOT_RUNTIME.try_with(|runtime| runtime.register_runtime(vm, slot_tracker)) {
411        Ok(barrier) => Some(barrier),
412        Err(_) => {
413            debug!("Snapshot runtime registration ignored (snapshots disabled)");
414            None
415        }
416    }
417}
418
419/// Called by the generated runtime before connecting: returns the restored VM
420/// state (if any) exactly once.
421pub fn take_restored() -> Option<RestoredState> {
422    ACTIVE_SNAPSHOT_RUNTIME
423        .try_with(SnapshotRuntime::take_restored)
424        .ok()
425        .flatten()
426}
427
428/// Select a reconnect checkpoint for the generated Yellowstone runtime.
429///
430/// A restored replay never falls back to live: retries advance only to slots
431/// the main parser stream has finished processing. Without a restored replay,
432/// the existing live fallback remains available after repeated short-lived
433/// connections.
434#[doc(hidden)]
435pub fn select_reconnect_from_slot(
436    restored_watermark: Option<u64>,
437    processed_watermark: u64,
438    attempt: u32,
439    live_fallback_attempts: u32,
440) -> Option<u64> {
441    if let Some(restored_watermark) = restored_watermark {
442        return Some(restored_watermark.max(processed_watermark));
443    }
444    if attempt >= live_fallback_attempts {
445        return None;
446    }
447    (processed_watermark > 0).then_some(processed_watermark)
448}
449
450fn now_epoch_ms() -> u64 {
451    SystemTime::now()
452        .duration_since(UNIX_EPOCH)
453        .unwrap()
454        .as_millis() as u64
455}
456
457/// What kicked off a snapshot cycle.
458#[derive(Clone, Copy, Debug, PartialEq, Eq)]
459pub enum SnapshotTrigger {
460    Periodic,
461    Shutdown,
462}
463
464/// Owns the store plus everything needed to dump and restore state. Created by
465/// `Runtime::run` when snapshots are enabled.
466pub struct SnapshotService {
467    config: SnapshotConfig,
468    store: Arc<dyn SnapshotStore>,
469    runtime: SnapshotRuntime,
470    bytecode_hash: String,
471    state_contract: Option<SnapshotContract>,
472    projection_contract: SnapshotContract,
473    state_ids: HashMap<String, u32>,
474    program_ids: Vec<String>,
475    entity_cache: EntityCache,
476    batches_at_last_snapshot: AtomicU64,
477    warned_missing_vm: AtomicBool,
478}
479
480impl SnapshotService {
481    /// Build the store, then attempt a restore (any failure logs a warning
482    /// and cold-starts — restore problems must never block startup).
483    pub async fn initialize(
484        config: SnapshotConfig,
485        spec: &crate::Spec,
486        entity_cache: EntityCache,
487        view_index: &ViewIndex,
488        _mutations_tx: mpsc::Sender<MutationBatch>,
489    ) -> Result<Arc<Self>> {
490        let url = config
491            .url
492            .clone()
493            .context("snapshots are enabled but no snapshot URL is configured")?;
494        let store = store::store_from_url(&url)?;
495
496        let mut program_ids = spec.program_ids.clone();
497        program_ids.sort();
498
499        let service = Arc::new(Self {
500            config,
501            store,
502            runtime: SnapshotRuntime::default(),
503            bytecode_hash: spec.bytecode.fingerprint(),
504            state_contract: state_contract(spec),
505            projection_contract: projection_contract(view_index),
506            state_ids: state_ids_by_entity(spec),
507            program_ids,
508            entity_cache,
509            batches_at_last_snapshot: AtomicU64::new(0),
510            warned_missing_vm: AtomicBool::new(false),
511        });
512        info!(
513            store = %service.store.describe(),
514            interval_secs = service.config.interval.as_secs(),
515            keep = service.config.keep,
516            "State snapshots enabled"
517        );
518
519        match service.restore(view_index).await {
520            Ok(true) => {}
521            Ok(false) => info!("No usable snapshot found; starting cold"),
522            Err(err) => warn!(
523                error = format!("{err:#}"),
524                "Failed to restore snapshot; starting cold"
525            ),
526        }
527        Ok(service)
528    }
529
530    pub fn config(&self) -> &SnapshotConfig {
531        &self.config
532    }
533
534    /// Return the per-server coordination handle that must be shared with the
535    /// matching parser, projector, and readiness endpoint.
536    pub fn runtime(&self) -> SnapshotRuntime {
537        self.runtime.clone()
538    }
539
540    /// Load and validate the latest snapshot, hydrate the projection caches,
541    /// and stash the VM portion for the generated runtime. Returns whether a
542    /// snapshot was applied.
543    async fn restore(&self, view_index: &ViewIndex) -> Result<bool> {
544        let Some((name, bytes)) = self.store.load_latest().await? else {
545            return Ok(false);
546        };
547
548        let header = envelope::decode_header(&bytes)
549            .with_context(|| format!("snapshot {name} has an unreadable header"))?;
550
551        if header.format_version != SNAPSHOT_FORMAT_VERSION {
552            warn!(
553                snapshot = %name,
554                found = header.format_version,
555                expected = SNAPSHOT_FORMAT_VERSION,
556                "Snapshot format version mismatch; discarding (cold start)"
557            );
558            return Ok(false);
559        }
560        let exact_bytecode = header.bytecode_hash == self.bytecode_hash;
561        let matching_contracts = self.state_contract.is_some()
562            && header.state_contract == self.state_contract
563            && header.projection_contract.as_ref() == Some(&self.projection_contract);
564        let approved_legacy = !exact_bytecode
565            && header.state_contract.is_none()
566            && header.projection_contract.is_none()
567            && self
568                .config
569                .legacy_bytecode_hashes
570                .contains(&header.bytecode_hash);
571        let legacy_migration = approved_legacy;
572        if !exact_bytecode && !matching_contracts && !approved_legacy {
573            warn!(
574                snapshot = %name,
575                "Snapshot was taken by an incompatible stack build; discarding (cold start)"
576            );
577            return Ok(false);
578        }
579        let mut snapshot_program_ids = header.program_ids.clone();
580        snapshot_program_ids.sort();
581        if snapshot_program_ids != self.program_ids {
582            warn!(
583                snapshot = %name,
584                "Snapshot program ids do not match this server; discarding (cold start)"
585            );
586            return Ok(false);
587        }
588
589        let mut payload = tokio::task::spawn_blocking(move || envelope::decode_payload(&bytes))
590            .await
591            .context("snapshot decode task panicked")?
592            .with_context(|| format!("snapshot {name} has an unreadable payload"))?;
593        if !exact_bytecode {
594            remap_snapshot_states(&mut payload.vm, &self.state_ids)
595                .with_context(|| format!("snapshot {name} state contract is incompatible"))?;
596        }
597        if legacy_migration {
598            // A pre-contract snapshot cannot prove that its materialized views
599            // still match the current projections. Preserve only durable VM
600            // state and let live input rebuild every projection cache.
601            payload.entity_cache.clear();
602        }
603
604        let cached_views = payload.entity_cache.len();
605        let cached_entities: usize = payload
606            .entity_cache
607            .iter()
608            .map(|(_, entries)| entries.len())
609            .sum();
610        self.entity_cache.hydrate(payload.entity_cache).await;
611        rebuild_sorted_caches(view_index, &self.entity_cache).await;
612
613        // Even when the stream starts live, the watermark seeds the applied
614        // position: the hydrated state already contains everything up to it.
615        self.runtime
616            .state
617            .resume_watermark
618            .fetch_max(header.resume_watermark, Ordering::Relaxed);
619
620        let age_ms = now_epoch_ms().saturating_sub(header.created_at_epoch_ms);
621        let estimated_age_slots = age_ms / ESTIMATED_SLOT_MILLIS;
622        let resume_watermark = if exact_bytecode
623            && header.resume_watermark > 0
624            && estimated_age_slots <= self.config.max_resume_age_slots
625        {
626            Some(header.resume_watermark)
627        } else {
628            if header.resume_watermark > 0 && exact_bytecode {
629                warn!(
630                    resume_watermark = header.resume_watermark,
631                    estimated_age_slots,
632                    max_resume_age_slots = self.config.max_resume_age_slots,
633                    "Snapshot is older than the resume window; hydrating state but \
634                     starting the stream live. Account-derived state self-heals from \
635                     full account writes; only instruction events in the gap are missed."
636                );
637            }
638            if !exact_bytecode {
639                warn!(
640                    snapshot = %name,
641                    matching_contracts,
642                    approved_legacy,
643                    legacy_state_only = legacy_migration,
644                    "Hydrated snapshot state from different bytecode; starting live"
645                );
646            }
647            None
648        };
649
650        if resume_watermark.is_some() {
651            *self.runtime.state.resume_gate.lock().unwrap() = Some(ResumeGate {
652                started: Instant::now(),
653                max_lag_slots: self.config.ready_max_lag_slots,
654                max_hold: self.config.ready_max_hold,
655            });
656        }
657
658        info!(
659            snapshot = %name,
660            vm_entities = payload.vm.total_entries(),
661            cached_views,
662            cached_entities,
663            resume_watermark = header.resume_watermark,
664            resuming = resume_watermark.is_some(),
665            age_secs = age_ms / 1_000,
666            "Restored state from snapshot"
667        );
668
669        *self.runtime.state.restored.lock().unwrap() = Some(RestoredState {
670            vm: payload.vm,
671            resume_watermark,
672        });
673        Ok(true)
674    }
675
676    /// Spawn the periodic snapshot task.
677    pub fn spawn(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
678        let service = Arc::clone(self);
679        tokio::spawn(
680            async move {
681                let mut interval = tokio::time::interval(service.config.interval);
682                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
683                // The first tick fires immediately; skip it so the first
684                // snapshot lands one full interval after startup.
685                interval.tick().await;
686                loop {
687                    interval.tick().await;
688                    if let Err(err) = service.snapshot_now(SnapshotTrigger::Periodic).await {
689                        // Snapshotting must never take down a healthy server.
690                        warn!(
691                            error = format!("{err:#}"),
692                            "Snapshot cycle failed; will retry next interval"
693                        );
694                    }
695                }
696            }
697            .instrument(info_span!("snapshot.manager")),
698        )
699    }
700
701    /// Run one snapshot cycle. Returns `Ok(false)` when skipped (no VM
702    /// registered yet, or too few mutations since the last snapshot).
703    pub async fn snapshot_now(&self, trigger: SnapshotTrigger) -> Result<bool> {
704        let Some(registration) = self.runtime.state.registered.lock().unwrap().clone() else {
705            if !self.warned_missing_vm.swap(true, Ordering::Relaxed) {
706                warn!(
707                    "Snapshots are enabled but no VM has been registered; the stack \
708                     may have been built with an older arete-macros version"
709                );
710            }
711            return Ok(false);
712        };
713
714        let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
715        if trigger == SnapshotTrigger::Periodic {
716            let since_last = applied_batches
717                .saturating_sub(self.batches_at_last_snapshot.load(Ordering::Relaxed));
718            if since_last < self.config.min_mutations {
719                debug!(since_last, "Skipping snapshot cycle (too few mutations)");
720                return Ok(false);
721            }
722        }
723
724        // Wait for every in-flight VM update and its queued projection batch
725        // to finish, then block new updates until both sides have been dumped.
726        // Processing guards move with their mutation batches and are released
727        // by the projector only after cache application, so this exclusive
728        // guard establishes one exact cut without an enqueue race.
729        let consistency_guard = tokio::time::timeout(
730            CONSISTENCY_CUT_TIMEOUT,
731            self.runtime.state.processing_barrier.enter_snapshot(),
732        )
733        .await
734        .context("timed out waiting for a consistent VM/projection snapshot cut")?;
735
736        let dump_started = Instant::now();
737        let (vm_snapshot, resume_watermark) = {
738            let vm = registration
739                .vm
740                .lock()
741                .map_err(|_| anyhow::anyhow!("VM mutex poisoned"))?;
742            let resume_watermark = self.runtime.state.resume_watermark.load(Ordering::Relaxed);
743            let vm_snapshot = vm.dump();
744            (vm_snapshot, resume_watermark)
745        };
746        let vm_lock_held = dump_started.elapsed();
747        let observed_slot = registration.slot_tracker.get();
748        let entity_cache_dump = self.entity_cache.dump().await;
749        let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
750        drop(consistency_guard);
751
752        let created_at_epoch_ms = now_epoch_ms();
753        let header = SnapshotHeader {
754            format_version: SNAPSHOT_FORMAT_VERSION,
755            bytecode_hash: self.bytecode_hash.clone(),
756            state_contract: self.state_contract.clone(),
757            projection_contract: Some(self.projection_contract.clone()),
758            program_ids: self.program_ids.clone(),
759            resume_watermark,
760            observed_slot,
761            created_at_epoch_ms,
762            entry_counts: vm_snapshot.entry_counts().into_iter().collect(),
763        };
764        let payload = SnapshotPayload {
765            vm: vm_snapshot,
766            entity_cache: entity_cache_dump,
767        };
768        let bytes = tokio::task::spawn_blocking(move || envelope::encode(&header, &payload))
769            .await
770            .context("snapshot encode task panicked")??;
771
772        let name = store::snapshot_name(created_at_epoch_ms, resume_watermark);
773        self.store.write(&name, &bytes).await?;
774        if let Err(err) = self.store.prune(self.config.keep).await {
775            warn!(error = format!("{err:#}"), "Failed to prune old snapshots");
776        }
777        self.batches_at_last_snapshot
778            .store(applied_batches, Ordering::Relaxed);
779
780        info!(
781            snapshot = %name,
782            bytes = bytes.len(),
783            resume_watermark,
784            observed_slot,
785            vm_lock_ms = vm_lock_held.as_millis() as u64,
786            trigger = ?trigger,
787            "Snapshot written"
788        );
789        Ok(true)
790    }
791}
792
793/// Rebuild each derived `SortedViewCache` from the hydrated `EntityCache`,
794/// mirroring the projector's upsert path. Sorted caches are not persisted:
795/// they are derived state and MB-scale rebuilds are sub-millisecond.
796async fn rebuild_sorted_caches(view_index: &ViewIndex, entity_cache: &EntityCache) {
797    let sorted_caches = view_index.sorted_caches();
798    let max_entries = entity_cache.max_entities_per_view();
799    for spec in view_index.get_derived_views() {
800        let Some(source_view) = spec.source_view.as_ref() else {
801            continue;
802        };
803        let entities = entity_cache.get_all(source_view).await;
804        if entities.is_empty() {
805            continue;
806        }
807        let mut caches = sorted_caches.write().await;
808        if let Some(cache) = caches.get_mut(&spec.id) {
809            let count = entities.len();
810            for (key, entity) in entities {
811                cache.upsert(key, entity);
812            }
813            // Trim once after the batch; the bound matches the projector's.
814            cache.trim_to_max_entries(max_entries);
815            debug!(view_id = %spec.id, count, "Rebuilt sorted cache from snapshot");
816        }
817    }
818}