1pub 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
48const ESTIMATED_SLOT_MILLIS: u64 = 200;
51const 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#[derive(Clone, Debug)]
60pub struct SnapshotConfig {
61 pub enabled: bool,
63 pub url: Option<String>,
66 pub interval: Duration,
68 pub keep: usize,
70 pub snapshot_on_shutdown: bool,
72 pub min_mutations: u64,
75 pub max_resume_age_slots: u64,
79 pub ready_max_lag_slots: u64,
82 pub ready_max_hold: Duration,
85 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 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 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
254pub struct RestoredState {
257 pub vm: VmSnapshot,
258 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#[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
299pub 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 resume_watermark: AtomicU64,
319 applied_batches: AtomicU64,
320}
321
322#[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 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 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 pub fn take_restored(&self) -> Option<RestoredState> {
362 self.state.restored.lock().unwrap().take()
363 }
364
365 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 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
405pub 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
425pub fn take_restored() -> Option<RestoredState> {
428 ACTIVE_SNAPSHOT_RUNTIME
429 .try_with(SnapshotRuntime::take_restored)
430 .ok()
431 .flatten()
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub enum ReconnectPosition {
441 Slot(u64),
443 Live,
445 LiveAfterGap { abandoned: u64 },
448}
449
450impl ReconnectPosition {
451 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#[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 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#[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
504pub 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 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 pub fn runtime(&self) -> SnapshotRuntime {
580 self.runtime.clone()
581 }
582
583 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 payload.entity_cache.clear();
645 payload.entity_lifetimes = Some(Default::default());
646 payload.entity_tombstones.clear();
647 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 self.entity_cache
672 .hydrate_legacy_tombstones(payload.entity_tombstones)
673 .await;
674 }
675 }
676 remember_uncached_vm_keys(view_index, &payload.vm, &self.entity_cache).await;
685 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 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 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 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 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 interval.tick().await;
782 loop {
783 interval.tick().await;
784 if let Err(err) = service.snapshot_now(SnapshotTrigger::Periodic).await {
785 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 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 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 let entity_cache_dump = self.entity_cache.dump_shared().await;
846 let journal_dump = self.journal.dump().await;
849 if trigger == SnapshotTrigger::Shutdown {
850 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 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
916async 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
944async 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 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 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 #[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 assert_eq!(
1074 cache.upsert("Round/list", "3", json!({"id": 3})).await,
1075 CacheWrite::Created
1076 );
1077 }
1078}