1pub mod envelope;
22#[cfg(feature = "snapshot-object-store")]
23pub mod object;
24pub mod store;
25
26pub use envelope::{SnapshotHeader, SnapshotPayload};
27#[cfg(feature = "snapshot-object-store")]
28pub use object::ObjectSnapshotStore;
29pub use store::{FsStore, SnapshotStore};
30
31use crate::cache::EntityCache;
32use crate::health::SlotTracker;
33use crate::mutation_batch::MutationBatch;
34use crate::view::ViewIndex;
35use anyhow::{Context, Result};
36use arete_interpreter::snapshot::{VmSnapshot, SNAPSHOT_FORMAT_VERSION};
37use arete_interpreter::vm::VmContext;
38use std::future::Future;
39use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
40use std::sync::{Arc, Mutex as StdMutex};
41use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
42use tokio::sync::{mpsc, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
43use tracing::{debug, info, info_span, warn, Instrument};
44
45const ESTIMATED_SLOT_MILLIS: u64 = 400;
48const CONSISTENCY_CUT_TIMEOUT: Duration = Duration::from_secs(10);
51
52#[derive(Clone, Debug)]
55pub struct SnapshotConfig {
56 pub enabled: bool,
58 pub url: Option<String>,
61 pub interval: Duration,
63 pub keep: usize,
65 pub snapshot_on_shutdown: bool,
67 pub min_mutations: u64,
70 pub max_resume_age_slots: u64,
73 pub ready_max_lag_slots: u64,
76 pub ready_max_hold: Duration,
79}
80
81impl Default for SnapshotConfig {
82 fn default() -> Self {
83 Self {
84 enabled: false,
85 url: None,
86 interval: Duration::from_secs(60),
87 keep: 4,
88 snapshot_on_shutdown: true,
89 min_mutations: 1,
90 max_resume_age_slots: 1_500,
94 ready_max_lag_slots: 50,
95 ready_max_hold: Duration::from_secs(60),
96 }
97 }
98}
99
100impl SnapshotConfig {
101 pub fn from_env() -> Result<Self> {
104 let mut config = Self::default();
105 config.enabled = crate::config::env_bool("ARETE_SNAPSHOT_ENABLED")?.unwrap_or(false);
106 config.url = std::env::var("ARETE_SNAPSHOT_URL")
107 .ok()
108 .filter(|value| !value.trim().is_empty());
109 config.interval = Duration::from_secs(
110 crate::config::env_parse("ARETE_SNAPSHOT_INTERVAL_SECS")?
111 .unwrap_or(config.interval.as_secs()),
112 );
113 config.keep = crate::config::env_parse("ARETE_SNAPSHOT_KEEP")?.unwrap_or(config.keep);
114 config.snapshot_on_shutdown = crate::config::env_bool("ARETE_SNAPSHOT_ON_SHUTDOWN")?
115 .unwrap_or(config.snapshot_on_shutdown);
116 config.min_mutations = crate::config::env_parse("ARETE_SNAPSHOT_MIN_MUTATIONS")?
117 .unwrap_or(config.min_mutations);
118 config.max_resume_age_slots =
119 crate::config::env_parse("ARETE_SNAPSHOT_MAX_RESUME_AGE_SLOTS")?
120 .unwrap_or(config.max_resume_age_slots);
121 config.ready_max_lag_slots =
122 crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_LAG_SLOTS")?
123 .unwrap_or(config.ready_max_lag_slots);
124 config.ready_max_hold = Duration::from_secs(
125 crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_HOLD_SECS")?
126 .unwrap_or(config.ready_max_hold.as_secs()),
127 );
128 config.validate()?;
129 Ok(config)
130 }
131
132 pub fn validate(&self) -> Result<()> {
133 if self.enabled && self.url.as_deref().is_none_or(|url| url.trim().is_empty()) {
134 anyhow::bail!("snapshots are enabled but ARETE_SNAPSHOT_URL is not set");
135 }
136 if self.enabled && (self.interval.is_zero() || self.keep == 0) {
137 anyhow::bail!("snapshot interval and keep count must be greater than zero");
138 }
139 Ok(())
140 }
141}
142
143pub struct RestoredState {
146 pub vm: VmSnapshot,
147 pub resume_watermark: Option<u64>,
151}
152
153#[derive(Clone)]
154struct RuntimeRegistration {
155 vm: Arc<StdMutex<VmContext>>,
156 slot_tracker: SlotTracker,
157}
158
159struct ResumeGate {
160 started: Instant,
161 max_lag_slots: u64,
162 max_hold: Duration,
163}
164
165#[derive(Clone, Default)]
174pub struct SnapshotBarrier {
175 inner: Arc<RwLock<()>>,
176}
177
178impl SnapshotBarrier {
179 pub async fn enter_processing(&self) -> SnapshotProcessingGuard {
180 SnapshotProcessingGuard(self.inner.clone().read_owned().await)
181 }
182
183 async fn enter_snapshot(&self) -> OwnedRwLockWriteGuard<()> {
184 self.inner.clone().write_owned().await
185 }
186}
187
188pub struct SnapshotProcessingGuard(#[allow(dead_code)] OwnedRwLockReadGuard<()>);
191
192impl std::fmt::Debug for SnapshotProcessingGuard {
193 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 formatter.write_str("SnapshotProcessingGuard")
195 }
196}
197
198#[derive(Default)]
199struct SnapshotRuntimeState {
200 registered: StdMutex<Option<RuntimeRegistration>>,
201 restored: StdMutex<Option<RestoredState>>,
202 resume_gate: StdMutex<Option<ResumeGate>>,
203 processing_barrier: SnapshotBarrier,
204 resume_watermark: AtomicU64,
208 applied_batches: AtomicU64,
209}
210
211#[derive(Clone, Default)]
218pub struct SnapshotRuntime {
219 state: Arc<SnapshotRuntimeState>,
220}
221
222tokio::task_local! {
223 static ACTIVE_SNAPSHOT_RUNTIME: SnapshotRuntime;
224}
225
226impl SnapshotRuntime {
227 pub async fn scope<F>(&self, future: F) -> F::Output
229 where
230 F: Future,
231 {
232 ACTIVE_SNAPSHOT_RUNTIME.scope(self.clone(), future).await
233 }
234
235 pub fn register_runtime(
237 &self,
238 vm: Arc<StdMutex<VmContext>>,
239 slot_tracker: SlotTracker,
240 ) -> SnapshotBarrier {
241 let mut registered = self.state.registered.lock().unwrap();
242 if registered.is_some() {
243 debug!("Snapshot runtime registration replaced");
244 }
245 *registered = Some(RuntimeRegistration { vm, slot_tracker });
246 self.state.processing_barrier.clone()
247 }
248
249 pub fn take_restored(&self) -> Option<RestoredState> {
251 self.state.restored.lock().unwrap().take()
252 }
253
254 pub(crate) fn record_applied_batch(&self, slot: Option<u64>) {
256 self.state.applied_batches.fetch_add(1, Ordering::Relaxed);
257 if let Some(slot) = slot {
258 self.state
259 .resume_watermark
260 .fetch_max(slot, Ordering::Relaxed);
261 }
262 }
263
264 pub fn resume_gate_ready(&self) -> bool {
267 let mut gate_slot = self.state.resume_gate.lock().unwrap();
268 let Some(gate) = gate_slot.as_ref() else {
269 return true;
270 };
271 if gate.started.elapsed() >= gate.max_hold {
272 info!("Snapshot resume readiness gate released (max hold reached)");
273 *gate_slot = None;
274 return true;
275 }
276 let tip = self
277 .state
278 .registered
279 .lock()
280 .unwrap()
281 .as_ref()
282 .map(|registration| registration.slot_tracker.get())
283 .unwrap_or(0);
284 let applied = self.state.resume_watermark.load(Ordering::Relaxed);
285 if tip > 0 && tip.saturating_sub(applied) <= gate.max_lag_slots {
286 info!(tip, applied, "Snapshot resume caught up; marking ready");
287 *gate_slot = None;
288 return true;
289 }
290 false
291 }
292}
293
294pub fn register_runtime(
297 vm: Arc<StdMutex<VmContext>>,
298 slot_tracker: SlotTracker,
299) -> Option<SnapshotBarrier> {
300 match ACTIVE_SNAPSHOT_RUNTIME.try_with(|runtime| runtime.register_runtime(vm, slot_tracker)) {
301 Ok(barrier) => Some(barrier),
302 Err(_) => {
303 debug!("Snapshot runtime registration ignored (snapshots disabled)");
304 None
305 }
306 }
307}
308
309pub fn take_restored() -> Option<RestoredState> {
312 ACTIVE_SNAPSHOT_RUNTIME
313 .try_with(SnapshotRuntime::take_restored)
314 .ok()
315 .flatten()
316}
317
318#[doc(hidden)]
325pub fn select_reconnect_from_slot(
326 restored_watermark: Option<u64>,
327 processed_watermark: u64,
328 attempt: u32,
329 live_fallback_attempts: u32,
330) -> Option<u64> {
331 if let Some(restored_watermark) = restored_watermark {
332 return Some(restored_watermark.max(processed_watermark));
333 }
334 if attempt >= live_fallback_attempts {
335 return None;
336 }
337 (processed_watermark > 0).then_some(processed_watermark)
338}
339
340fn now_epoch_ms() -> u64 {
341 SystemTime::now()
342 .duration_since(UNIX_EPOCH)
343 .unwrap()
344 .as_millis() as u64
345}
346
347#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum SnapshotTrigger {
350 Periodic,
351 Shutdown,
352}
353
354pub struct SnapshotService {
357 config: SnapshotConfig,
358 store: Arc<dyn SnapshotStore>,
359 runtime: SnapshotRuntime,
360 bytecode_hash: String,
361 program_ids: Vec<String>,
362 entity_cache: EntityCache,
363 batches_at_last_snapshot: AtomicU64,
364 warned_missing_vm: AtomicBool,
365}
366
367impl SnapshotService {
368 pub async fn initialize(
371 config: SnapshotConfig,
372 spec: &crate::Spec,
373 entity_cache: EntityCache,
374 view_index: &ViewIndex,
375 _mutations_tx: mpsc::Sender<MutationBatch>,
376 ) -> Result<Arc<Self>> {
377 let url = config
378 .url
379 .clone()
380 .context("snapshots are enabled but no snapshot URL is configured")?;
381 let store = store::store_from_url(&url)?;
382
383 let mut program_ids = spec.program_ids.clone();
384 program_ids.sort();
385
386 let service = Arc::new(Self {
387 config,
388 store,
389 runtime: SnapshotRuntime::default(),
390 bytecode_hash: spec.bytecode.fingerprint(),
391 program_ids,
392 entity_cache,
393 batches_at_last_snapshot: AtomicU64::new(0),
394 warned_missing_vm: AtomicBool::new(false),
395 });
396 info!(
397 store = %service.store.describe(),
398 interval_secs = service.config.interval.as_secs(),
399 keep = service.config.keep,
400 "State snapshots enabled"
401 );
402
403 match service.restore(view_index).await {
404 Ok(true) => {}
405 Ok(false) => info!("No usable snapshot found; starting cold"),
406 Err(err) => warn!(
407 error = format!("{err:#}"),
408 "Failed to restore snapshot; starting cold"
409 ),
410 }
411 Ok(service)
412 }
413
414 pub fn config(&self) -> &SnapshotConfig {
415 &self.config
416 }
417
418 pub fn runtime(&self) -> SnapshotRuntime {
421 self.runtime.clone()
422 }
423
424 async fn restore(&self, view_index: &ViewIndex) -> Result<bool> {
428 let Some((name, bytes)) = self.store.load_latest().await? else {
429 return Ok(false);
430 };
431
432 let header = envelope::decode_header(&bytes)
433 .with_context(|| format!("snapshot {name} has an unreadable header"))?;
434
435 if header.format_version != SNAPSHOT_FORMAT_VERSION {
436 warn!(
437 snapshot = %name,
438 found = header.format_version,
439 expected = SNAPSHOT_FORMAT_VERSION,
440 "Snapshot format version mismatch; discarding (cold start)"
441 );
442 return Ok(false);
443 }
444 if header.bytecode_hash != self.bytecode_hash {
445 warn!(
446 snapshot = %name,
447 "Snapshot was taken by a different stack build (bytecode hash \
448 mismatch); discarding (cold start)"
449 );
450 return Ok(false);
451 }
452 let mut snapshot_program_ids = header.program_ids.clone();
453 snapshot_program_ids.sort();
454 if snapshot_program_ids != self.program_ids {
455 warn!(
456 snapshot = %name,
457 "Snapshot program ids do not match this server; discarding (cold start)"
458 );
459 return Ok(false);
460 }
461
462 let payload = tokio::task::spawn_blocking(move || envelope::decode_payload(&bytes))
463 .await
464 .context("snapshot decode task panicked")?
465 .with_context(|| format!("snapshot {name} has an unreadable payload"))?;
466
467 let cached_views = payload.entity_cache.len();
468 let cached_entities: usize = payload
469 .entity_cache
470 .iter()
471 .map(|(_, entries)| entries.len())
472 .sum();
473 self.entity_cache.hydrate(payload.entity_cache).await;
474 rebuild_sorted_caches(view_index, &self.entity_cache).await;
475
476 self.runtime
479 .state
480 .resume_watermark
481 .fetch_max(header.resume_watermark, Ordering::Relaxed);
482
483 let age_ms = now_epoch_ms().saturating_sub(header.created_at_epoch_ms);
484 let estimated_age_slots = age_ms / ESTIMATED_SLOT_MILLIS;
485 let resume_watermark = if header.resume_watermark > 0
486 && estimated_age_slots <= self.config.max_resume_age_slots
487 {
488 Some(header.resume_watermark)
489 } else {
490 if header.resume_watermark > 0 {
491 warn!(
492 resume_watermark = header.resume_watermark,
493 estimated_age_slots,
494 max_resume_age_slots = self.config.max_resume_age_slots,
495 "Snapshot is older than the resume window; hydrating state but \
496 starting the stream live. Account-derived state self-heals from \
497 full account writes; only instruction events in the gap are missed."
498 );
499 }
500 None
501 };
502
503 if resume_watermark.is_some() {
504 *self.runtime.state.resume_gate.lock().unwrap() = Some(ResumeGate {
505 started: Instant::now(),
506 max_lag_slots: self.config.ready_max_lag_slots,
507 max_hold: self.config.ready_max_hold,
508 });
509 }
510
511 info!(
512 snapshot = %name,
513 vm_entities = payload.vm.total_entries(),
514 cached_views,
515 cached_entities,
516 resume_watermark = header.resume_watermark,
517 resuming = resume_watermark.is_some(),
518 age_secs = age_ms / 1_000,
519 "Restored state from snapshot"
520 );
521
522 *self.runtime.state.restored.lock().unwrap() = Some(RestoredState {
523 vm: payload.vm,
524 resume_watermark,
525 });
526 Ok(true)
527 }
528
529 pub fn spawn(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
531 let service = Arc::clone(self);
532 tokio::spawn(
533 async move {
534 let mut interval = tokio::time::interval(service.config.interval);
535 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
536 interval.tick().await;
539 loop {
540 interval.tick().await;
541 if let Err(err) = service.snapshot_now(SnapshotTrigger::Periodic).await {
542 warn!(
544 error = format!("{err:#}"),
545 "Snapshot cycle failed; will retry next interval"
546 );
547 }
548 }
549 }
550 .instrument(info_span!("snapshot.manager")),
551 )
552 }
553
554 pub async fn snapshot_now(&self, trigger: SnapshotTrigger) -> Result<bool> {
557 let Some(registration) = self.runtime.state.registered.lock().unwrap().clone() else {
558 if !self.warned_missing_vm.swap(true, Ordering::Relaxed) {
559 warn!(
560 "Snapshots are enabled but no VM has been registered; the stack \
561 may have been built with an older arete-macros version"
562 );
563 }
564 return Ok(false);
565 };
566
567 let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
568 if trigger == SnapshotTrigger::Periodic {
569 let since_last = applied_batches
570 .saturating_sub(self.batches_at_last_snapshot.load(Ordering::Relaxed));
571 if since_last < self.config.min_mutations {
572 debug!(since_last, "Skipping snapshot cycle (too few mutations)");
573 return Ok(false);
574 }
575 }
576
577 let consistency_guard = tokio::time::timeout(
583 CONSISTENCY_CUT_TIMEOUT,
584 self.runtime.state.processing_barrier.enter_snapshot(),
585 )
586 .await
587 .context("timed out waiting for a consistent VM/projection snapshot cut")?;
588
589 let dump_started = Instant::now();
590 let (vm_snapshot, resume_watermark) = {
591 let vm = registration
592 .vm
593 .lock()
594 .map_err(|_| anyhow::anyhow!("VM mutex poisoned"))?;
595 let resume_watermark = self.runtime.state.resume_watermark.load(Ordering::Relaxed);
596 let vm_snapshot = vm.dump();
597 (vm_snapshot, resume_watermark)
598 };
599 let vm_lock_held = dump_started.elapsed();
600 let observed_slot = registration.slot_tracker.get();
601 let entity_cache_dump = self.entity_cache.dump().await;
602 let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
603 drop(consistency_guard);
604
605 let created_at_epoch_ms = now_epoch_ms();
606 let header = SnapshotHeader {
607 format_version: SNAPSHOT_FORMAT_VERSION,
608 bytecode_hash: self.bytecode_hash.clone(),
609 program_ids: self.program_ids.clone(),
610 resume_watermark,
611 observed_slot,
612 created_at_epoch_ms,
613 entry_counts: vm_snapshot.entry_counts().into_iter().collect(),
614 };
615 let payload = SnapshotPayload {
616 vm: vm_snapshot,
617 entity_cache: entity_cache_dump,
618 };
619 let bytes = tokio::task::spawn_blocking(move || envelope::encode(&header, &payload))
620 .await
621 .context("snapshot encode task panicked")??;
622
623 let name = store::snapshot_name(created_at_epoch_ms, resume_watermark);
624 self.store.write(&name, &bytes).await?;
625 if let Err(err) = self.store.prune(self.config.keep).await {
626 warn!(error = format!("{err:#}"), "Failed to prune old snapshots");
627 }
628 self.batches_at_last_snapshot
629 .store(applied_batches, Ordering::Relaxed);
630
631 info!(
632 snapshot = %name,
633 bytes = bytes.len(),
634 resume_watermark,
635 observed_slot,
636 vm_lock_ms = vm_lock_held.as_millis() as u64,
637 trigger = ?trigger,
638 "Snapshot written"
639 );
640 Ok(true)
641 }
642}
643
644async fn rebuild_sorted_caches(view_index: &ViewIndex, entity_cache: &EntityCache) {
648 let sorted_caches = view_index.sorted_caches();
649 for spec in view_index.get_derived_views() {
650 let Some(source_view) = spec.source_view.as_ref() else {
651 continue;
652 };
653 let entities = entity_cache.get_all(source_view).await;
654 if entities.is_empty() {
655 continue;
656 }
657 let mut caches = sorted_caches.write().await;
658 if let Some(cache) = caches.get_mut(&spec.id) {
659 let count = entities.len();
660 for (key, entity) in entities {
661 cache.upsert(key, entity);
662 }
663 debug!(view_id = %spec.id, count, "Rebuilt sorted cache from snapshot");
664 }
665 }
666}