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";
56const ROW_SCOPE_CONTRACT_SCHEMA_V1: &str = "arete.snapshot-row-scope-contract/v1";
57
58#[derive(Clone, Debug)]
61pub struct SnapshotConfig {
62 pub enabled: bool,
64 pub url: Option<String>,
67 pub interval: Duration,
69 pub keep: usize,
71 pub snapshot_on_shutdown: bool,
73 pub min_mutations: u64,
76 pub max_resume_age_slots: u64,
80 pub ready_max_lag_slots: u64,
83 pub ready_max_hold: Duration,
86 pub legacy_bytecode_hashes: BTreeSet<String>,
90}
91
92impl Default for SnapshotConfig {
93 fn default() -> Self {
94 Self {
95 enabled: false,
96 url: None,
97 interval: Duration::from_secs(60),
98 keep: 4,
99 snapshot_on_shutdown: true,
100 min_mutations: 1,
101 max_resume_age_slots: 1_500,
105 ready_max_lag_slots: 50,
106 ready_max_hold: Duration::from_secs(60),
107 legacy_bytecode_hashes: BTreeSet::new(),
108 }
109 }
110}
111
112impl SnapshotConfig {
113 pub fn from_env() -> Result<Self> {
116 let mut config = Self::default();
117 config.enabled = crate::config::env_bool("ARETE_SNAPSHOT_ENABLED")?.unwrap_or(false);
118 config.url = std::env::var("ARETE_SNAPSHOT_URL")
119 .ok()
120 .filter(|value| !value.trim().is_empty());
121 config.interval = Duration::from_secs(
122 crate::config::env_parse("ARETE_SNAPSHOT_INTERVAL_SECS")?
123 .unwrap_or(config.interval.as_secs()),
124 );
125 config.keep = crate::config::env_parse("ARETE_SNAPSHOT_KEEP")?.unwrap_or(config.keep);
126 config.snapshot_on_shutdown = crate::config::env_bool("ARETE_SNAPSHOT_ON_SHUTDOWN")?
127 .unwrap_or(config.snapshot_on_shutdown);
128 config.min_mutations = crate::config::env_parse("ARETE_SNAPSHOT_MIN_MUTATIONS")?
129 .unwrap_or(config.min_mutations);
130 config.max_resume_age_slots =
131 crate::config::env_parse("ARETE_SNAPSHOT_MAX_RESUME_AGE_SLOTS")?
132 .unwrap_or(config.max_resume_age_slots);
133 config.ready_max_lag_slots =
134 crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_LAG_SLOTS")?
135 .unwrap_or(config.ready_max_lag_slots);
136 config.ready_max_hold = Duration::from_secs(
137 crate::config::env_parse("ARETE_SNAPSHOT_READY_MAX_HOLD_SECS")?
138 .unwrap_or(config.ready_max_hold.as_secs()),
139 );
140 config.legacy_bytecode_hashes = std::env::var("ARETE_SNAPSHOT_LEGACY_BYTECODE_HASHES")
141 .ok()
142 .into_iter()
143 .flat_map(|value| {
144 value
145 .split(',')
146 .map(str::trim)
147 .filter(|hash| !hash.is_empty())
148 .map(str::to_ascii_lowercase)
149 .collect::<Vec<_>>()
150 })
151 .collect();
152 config.validate()?;
153 Ok(config)
154 }
155
156 pub fn validate(&self) -> Result<()> {
157 if self.enabled && self.url.as_deref().is_none_or(|url| url.trim().is_empty()) {
158 anyhow::bail!("snapshots are enabled but ARETE_SNAPSHOT_URL is not set");
159 }
160 if self.enabled && (self.interval.is_zero() || self.keep == 0) {
161 anyhow::bail!("snapshot interval and keep count must be greater than zero");
162 }
163 for hash in &self.legacy_bytecode_hashes {
164 if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
165 anyhow::bail!(
166 "ARETE_SNAPSHOT_LEGACY_BYTECODE_HASHES contains invalid SHA-256 hash '{hash}'"
167 );
168 }
169 }
170 Ok(())
171 }
172}
173
174fn contract_hash<T: Serialize>(schema: &str, value: &T) -> SnapshotContract {
175 let canonical = arete_hash::canonicalize_jcs(value)
176 .expect("snapshot contracts contain only canonical JSON values");
177 let hash = hex::encode(Sha256::digest(canonical));
178 SnapshotContract {
179 schema: schema.to_string(),
180 hash,
181 }
182}
183
184fn state_contract(spec: &crate::Spec) -> Option<SnapshotContract> {
185 if spec.entity_specs.is_empty() {
186 return None;
187 }
188 let mut entities = Vec::with_capacity(spec.entity_specs.len());
189 for entity in &spec.entity_specs {
190 let mut indexes = entity
191 .identity
192 .lookup_indexes
193 .iter()
194 .map(|index| (&index.field_name, &index.temporal_field))
195 .collect::<Vec<_>>();
196 indexes.sort();
197 indexes.dedup();
198 let fields = entity
199 .field_mappings
200 .iter()
201 .map(|(path, field)| {
202 (
203 path,
204 serde_json::json!({
205 "baseType": field.base_type,
206 "integerKind": field.integer_kind,
207 "isOptional": field.is_optional,
208 "isArray": field.is_array,
209 "innerType": field.inner_type,
210 "resolvedType": field.resolved_type,
211 "emit": field.emit,
212 }),
213 )
214 })
215 .collect::<BTreeMap<_, _>>();
216 entities.push(serde_json::json!({
217 "name": entity.state_name,
218 "primaryKeys": entity.identity.primary_keys,
219 "lookupIndexes": indexes,
220 "fields": fields,
221 }));
222 }
223 entities.sort_by_key(|entity| entity["name"].as_str().unwrap_or_default().to_string());
224 Some(contract_hash(
225 STATE_CONTRACT_SCHEMA_V1,
226 &serde_json::json!({"entities": entities}),
227 ))
228}
229
230fn projection_contract(view_index: &ViewIndex) -> SnapshotContract {
231 contract_hash(PROJECTION_CONTRACT_SCHEMA_V1, &view_index.snapshot_specs())
232}
233
234fn row_scope_contract(view_index: &ViewIndex) -> Option<SnapshotContract> {
238 let scopes = view_index.entity_scope_paths();
239 (!scopes.is_empty()).then(|| contract_hash(ROW_SCOPE_CONTRACT_SCHEMA_V1, &scopes))
240}
241
242fn state_ids_by_entity(spec: &crate::Spec) -> HashMap<String, u32> {
243 spec.bytecode
244 .entities
245 .iter()
246 .map(|(name, entity)| (name.clone(), entity.state_id))
247 .collect()
248}
249
250fn remap_snapshot_states(vm: &mut VmSnapshot, state_ids: &HashMap<String, u32>) -> Result<()> {
251 let states = std::mem::take(&mut vm.states);
252 for (_, table) in states {
253 let state_id = state_ids
254 .get(&table.entity_name)
255 .with_context(|| format!("snapshot contains unknown entity '{}'", table.entity_name))?;
256 if vm.states.insert(*state_id, table).is_some() {
257 anyhow::bail!("snapshot contains duplicate entity state for id {state_id}");
258 }
259 }
260 Ok(())
261}
262
263pub struct RestoredState {
266 pub vm: VmSnapshot,
267 pub resume_watermark: Option<u64>,
271}
272
273#[derive(Clone)]
274struct RuntimeRegistration {
275 vm: Arc<StdMutex<VmContext>>,
276 slot_tracker: SlotTracker,
277}
278
279struct ResumeGate {
280 started: Instant,
281 max_lag_slots: u64,
282 max_hold: Duration,
283}
284
285#[derive(Clone, Default)]
294pub struct SnapshotBarrier {
295 inner: Arc<RwLock<()>>,
296}
297
298impl SnapshotBarrier {
299 pub async fn enter_processing(&self) -> SnapshotProcessingGuard {
300 SnapshotProcessingGuard(self.inner.clone().read_owned().await)
301 }
302
303 async fn enter_snapshot(&self) -> OwnedRwLockWriteGuard<()> {
304 self.inner.clone().write_owned().await
305 }
306}
307
308pub struct SnapshotProcessingGuard(#[allow(dead_code)] OwnedRwLockReadGuard<()>);
311
312impl std::fmt::Debug for SnapshotProcessingGuard {
313 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 formatter.write_str("SnapshotProcessingGuard")
315 }
316}
317
318#[derive(Default)]
319struct SnapshotRuntimeState {
320 registered: StdMutex<Option<RuntimeRegistration>>,
321 restored: StdMutex<Option<RestoredState>>,
322 resume_gate: StdMutex<Option<ResumeGate>>,
323 processing_barrier: SnapshotBarrier,
324 resume_watermark: AtomicU64,
328 applied_batches: AtomicU64,
329}
330
331#[derive(Clone, Default)]
338pub struct SnapshotRuntime {
339 state: Arc<SnapshotRuntimeState>,
340}
341
342tokio::task_local! {
343 static ACTIVE_SNAPSHOT_RUNTIME: SnapshotRuntime;
344}
345
346impl SnapshotRuntime {
347 pub async fn scope<F>(&self, future: F) -> F::Output
349 where
350 F: Future,
351 {
352 ACTIVE_SNAPSHOT_RUNTIME.scope(self.clone(), future).await
353 }
354
355 pub fn register_runtime(
357 &self,
358 vm: Arc<StdMutex<VmContext>>,
359 slot_tracker: SlotTracker,
360 ) -> SnapshotBarrier {
361 let mut registered = self.state.registered.lock().unwrap();
362 if registered.is_some() {
363 debug!("Snapshot runtime registration replaced");
364 }
365 *registered = Some(RuntimeRegistration { vm, slot_tracker });
366 self.state.processing_barrier.clone()
367 }
368
369 pub fn take_restored(&self) -> Option<RestoredState> {
371 self.state.restored.lock().unwrap().take()
372 }
373
374 pub(crate) fn record_applied_batch(&self, slot: Option<u64>) {
376 self.state.applied_batches.fetch_add(1, Ordering::Relaxed);
377 if let Some(slot) = slot {
378 self.state
379 .resume_watermark
380 .fetch_max(slot, Ordering::Relaxed);
381 }
382 }
383
384 pub fn resume_gate_ready(&self) -> bool {
387 let mut gate_slot = self.state.resume_gate.lock().unwrap();
388 let Some(gate) = gate_slot.as_ref() else {
389 return true;
390 };
391 if gate.started.elapsed() >= gate.max_hold {
392 info!("Snapshot resume readiness gate released (max hold reached)");
393 *gate_slot = None;
394 return true;
395 }
396 let tip = self
397 .state
398 .registered
399 .lock()
400 .unwrap()
401 .as_ref()
402 .map(|registration| registration.slot_tracker.get())
403 .unwrap_or(0);
404 let applied = self.state.resume_watermark.load(Ordering::Relaxed);
405 if tip > 0 && tip.saturating_sub(applied) <= gate.max_lag_slots {
406 info!(tip, applied, "Snapshot resume caught up; marking ready");
407 *gate_slot = None;
408 return true;
409 }
410 false
411 }
412}
413
414pub fn register_runtime(
421 vm: Arc<StdMutex<VmContext>>,
422 slot_tracker: SlotTracker,
423) -> Option<SnapshotBarrier> {
424 crate::projector::link_active_resync(&vm);
425 match ACTIVE_SNAPSHOT_RUNTIME.try_with(|runtime| runtime.register_runtime(vm, slot_tracker)) {
426 Ok(barrier) => Some(barrier),
427 Err(_) => {
428 debug!("Snapshot runtime registration ignored (snapshots disabled)");
429 None
430 }
431 }
432}
433
434pub fn take_restored() -> Option<RestoredState> {
437 ACTIVE_SNAPSHOT_RUNTIME
438 .try_with(SnapshotRuntime::take_restored)
439 .ok()
440 .flatten()
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449pub enum ReconnectPosition {
450 Slot(u64),
452 Live,
454 LiveAfterGap { abandoned: u64 },
457}
458
459impl ReconnectPosition {
460 pub fn from_slot(self) -> Option<u64> {
462 match self {
463 Self::Slot(slot) => Some(slot),
464 Self::Live | Self::LiveAfterGap { .. } => None,
465 }
466 }
467}
468
469#[doc(hidden)]
477pub fn select_reconnect_from_slot(
478 restored_watermark: Option<u64>,
479 processed_watermark: u64,
480 attempt: u32,
481 live_fallback_attempts: Option<u32>,
482) -> ReconnectPosition {
483 if let Some(restored_watermark) = restored_watermark {
484 return ReconnectPosition::Slot(restored_watermark.max(processed_watermark));
485 }
486 if processed_watermark == 0 {
487 return ReconnectPosition::Live;
489 }
490 match live_fallback_attempts {
491 Some(limit) if attempt >= limit => ReconnectPosition::LiveAfterGap {
492 abandoned: processed_watermark,
493 },
494 _ => ReconnectPosition::Slot(processed_watermark),
495 }
496}
497
498fn now_epoch_ms() -> u64 {
499 SystemTime::now()
500 .duration_since(UNIX_EPOCH)
501 .unwrap()
502 .as_millis() as u64
503}
504
505#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
507#[serde(rename_all = "snake_case")]
508pub enum SnapshotTrigger {
509 Periodic,
510 Shutdown,
511}
512
513pub struct SnapshotService {
516 config: SnapshotConfig,
517 store: Arc<dyn SnapshotStore>,
518 runtime: SnapshotRuntime,
519 bytecode_hash: String,
520 state_contract: Option<SnapshotContract>,
521 projection_contract: SnapshotContract,
522 row_scope_contract: Option<SnapshotContract>,
523 state_ids: HashMap<String, u32>,
524 program_ids: Vec<String>,
525 entity_cache: EntityCache,
526 journal: Arc<crate::journal::EventJournal>,
527 batches_at_last_snapshot: AtomicU64,
528 warned_missing_vm: AtomicBool,
529}
530
531impl SnapshotService {
532 pub async fn initialize(
535 config: SnapshotConfig,
536 spec: &crate::Spec,
537 entity_cache: EntityCache,
538 view_index: &ViewIndex,
539 journal: Arc<crate::journal::EventJournal>,
540 _mutations_tx: mpsc::Sender<MutationBatch>,
541 ) -> Result<Arc<Self>> {
542 let url = config
543 .url
544 .clone()
545 .context("snapshots are enabled but no snapshot URL is configured")?;
546 let store = store::store_from_url(&url)?;
547
548 let mut program_ids = spec.program_ids.clone();
549 program_ids.sort();
550
551 let service = Arc::new(Self {
552 config,
553 store,
554 runtime: SnapshotRuntime::default(),
555 bytecode_hash: spec.bytecode.fingerprint(),
556 state_contract: state_contract(spec),
557 projection_contract: projection_contract(view_index),
558 row_scope_contract: row_scope_contract(view_index),
559 state_ids: state_ids_by_entity(spec),
560 program_ids,
561 entity_cache,
562 journal,
563 batches_at_last_snapshot: AtomicU64::new(0),
564 warned_missing_vm: AtomicBool::new(false),
565 });
566 info!(
567 store = %service.store.describe(),
568 interval_secs = service.config.interval.as_secs(),
569 keep = service.config.keep,
570 "State snapshots enabled"
571 );
572
573 match service.restore(view_index).await {
574 Ok(true) => {}
575 Ok(false) => info!("No usable snapshot found; starting cold"),
576 Err(err) => warn!(
577 error = format!("{err:#}"),
578 "Failed to restore snapshot; starting cold"
579 ),
580 }
581 Ok(service)
582 }
583
584 pub fn config(&self) -> &SnapshotConfig {
585 &self.config
586 }
587
588 pub fn runtime(&self) -> SnapshotRuntime {
591 self.runtime.clone()
592 }
593
594 async fn restore(&self, view_index: &ViewIndex) -> Result<bool> {
598 let Some((name, bytes)) = self.store.load_latest().await? else {
599 return Ok(false);
600 };
601
602 let header = envelope::decode_header(&bytes)
603 .with_context(|| format!("snapshot {name} has an unreadable header"))?;
604
605 if header.format_version != SNAPSHOT_FORMAT_VERSION {
606 warn!(
607 snapshot = %name,
608 found = header.format_version,
609 expected = SNAPSHOT_FORMAT_VERSION,
610 "Snapshot format version mismatch; discarding (cold start)"
611 );
612 return Ok(false);
613 }
614 let exact_bytecode = header.bytecode_hash == self.bytecode_hash;
615 let matching_contracts = self.state_contract.is_some()
616 && header.state_contract == self.state_contract
617 && header.projection_contract.as_ref() == Some(&self.projection_contract);
618 let approved_legacy = !exact_bytecode
619 && header.state_contract.is_none()
620 && header.projection_contract.is_none()
621 && self
622 .config
623 .legacy_bytecode_hashes
624 .contains(&header.bytecode_hash);
625 let legacy_migration = approved_legacy;
626 if !exact_bytecode && !matching_contracts && !approved_legacy {
627 warn!(
628 snapshot = %name,
629 "Snapshot was taken by an incompatible stack build; discarding (cold start)"
630 );
631 return Ok(false);
632 }
633 let mut snapshot_program_ids = header.program_ids.clone();
634 snapshot_program_ids.sort();
635 if snapshot_program_ids != self.program_ids {
636 warn!(
637 snapshot = %name,
638 "Snapshot program ids do not match this server; discarding (cold start)"
639 );
640 return Ok(false);
641 }
642
643 let mut payload = tokio::task::spawn_blocking(move || envelope::decode_payload(&bytes))
644 .await
645 .context("snapshot decode task panicked")?
646 .with_context(|| format!("snapshot {name} has an unreadable payload"))?;
647 if !exact_bytecode {
648 remap_snapshot_states(&mut payload.vm, &self.state_ids)
649 .with_context(|| format!("snapshot {name} state contract is incompatible"))?;
650 }
651 let row_scopes_changed = header.row_scope_contract != self.row_scope_contract;
655 if row_scopes_changed {
656 warn!(
657 snapshot = %name,
658 "Snapshot was taken under a different row-scope policy; rebuilding projections from live input"
659 );
660 }
661 if legacy_migration || row_scopes_changed {
662 payload.entity_cache.clear();
666 payload.entity_lifetimes = Some(Default::default());
667 payload.entity_tombstones.clear();
668 payload.journal = Default::default();
672 }
673
674 let cached_views = payload.entity_cache.len();
675 let cached_entities: usize = payload
676 .entity_cache
677 .iter()
678 .map(|(_, entries)| entries.len())
679 .sum();
680 let retained_events: usize = payload
681 .journal
682 .views
683 .values()
684 .map(|view| view.records.len())
685 .sum();
686 self.entity_cache.hydrate(payload.entity_cache).await;
687 match payload.entity_lifetimes {
688 Some(lifetimes) => self.entity_cache.hydrate_lifetimes(lifetimes).await,
689 None => {
690 self.entity_cache
693 .hydrate_legacy_tombstones(payload.entity_tombstones)
694 .await;
695 }
696 }
697 remember_uncached_vm_keys(view_index, &payload.vm, &self.entity_cache).await;
706 let exact_offsets = header.trigger == Some(SnapshotTrigger::Shutdown);
708 self.journal.hydrate(payload.journal, exact_offsets).await;
709 rebuild_sorted_caches(view_index, &self.entity_cache).await;
710
711 self.runtime
714 .state
715 .resume_watermark
716 .fetch_max(header.resume_watermark, Ordering::Relaxed);
717
718 let age_ms = now_epoch_ms().saturating_sub(header.created_at_epoch_ms);
719 let estimated_age_slots = age_ms / ESTIMATED_SLOT_MILLIS;
720 let watermark_lag_at_snapshot =
725 header.observed_slot.saturating_sub(header.resume_watermark);
726 let estimated_resume_lag = watermark_lag_at_snapshot.saturating_add(estimated_age_slots);
727 let resume_watermark = if exact_bytecode
728 && header.resume_watermark > 0
729 && estimated_resume_lag <= self.config.max_resume_age_slots
730 {
731 Some(header.resume_watermark)
732 } else {
733 if header.resume_watermark > 0 && exact_bytecode {
734 warn!(
735 resume_watermark = header.resume_watermark,
736 observed_slot = header.observed_slot,
737 watermark_lag_at_snapshot,
738 estimated_age_slots,
739 estimated_resume_lag,
740 max_resume_age_slots = self.config.max_resume_age_slots,
741 "Snapshot watermark is outside the resume window; hydrating state but \
742 starting the stream live. Account-derived state self-heals from \
743 full account writes; only instruction events in the gap are missed."
744 );
745 }
746 if !exact_bytecode {
747 warn!(
748 snapshot = %name,
749 matching_contracts,
750 approved_legacy,
751 legacy_state_only = legacy_migration,
752 "Hydrated snapshot state from different bytecode; starting live"
753 );
754 }
755 None
756 };
757
758 if resume_watermark.is_none() {
759 self.journal.mark_gap().await;
764 }
765
766 if resume_watermark.is_some() {
767 *self.runtime.state.resume_gate.lock().unwrap() = Some(ResumeGate {
768 started: Instant::now(),
769 max_lag_slots: self.config.ready_max_lag_slots,
770 max_hold: self.config.ready_max_hold,
771 });
772 }
773
774 info!(
775 snapshot = %name,
776 vm_entities = payload.vm.total_entries(),
777 cached_views,
778 cached_entities,
779 retained_events,
780 resume_watermark = header.resume_watermark,
781 resuming = resume_watermark.is_some(),
782 age_secs = age_ms / 1_000,
783 "Restored state from snapshot"
784 );
785
786 *self.runtime.state.restored.lock().unwrap() = Some(RestoredState {
787 vm: payload.vm,
788 resume_watermark,
789 });
790 Ok(true)
791 }
792
793 pub fn spawn(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
795 let service = Arc::clone(self);
796 tokio::spawn(
797 async move {
798 let mut interval = tokio::time::interval(service.config.interval);
799 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
800 interval.tick().await;
803 loop {
804 interval.tick().await;
805 if let Err(err) = service.snapshot_now(SnapshotTrigger::Periodic).await {
806 warn!(
808 error = format!("{err:#}"),
809 "Snapshot cycle failed; will retry next interval"
810 );
811 }
812 }
813 }
814 .instrument(info_span!("snapshot.manager")),
815 )
816 }
817
818 pub async fn snapshot_now(&self, trigger: SnapshotTrigger) -> Result<bool> {
821 let Some(registration) = self.runtime.state.registered.lock().unwrap().clone() else {
822 if !self.warned_missing_vm.swap(true, Ordering::Relaxed) {
823 warn!(
824 "Snapshots are enabled but no VM has been registered; the stack \
825 may have been built with an older arete-macros version"
826 );
827 }
828 return Ok(false);
829 };
830
831 let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
832 if trigger == SnapshotTrigger::Periodic {
833 let since_last = applied_batches
834 .saturating_sub(self.batches_at_last_snapshot.load(Ordering::Relaxed));
835 if since_last < self.config.min_mutations {
836 debug!(since_last, "Skipping snapshot cycle (too few mutations)");
837 return Ok(false);
838 }
839 }
840
841 let consistency_guard = tokio::time::timeout(
847 CONSISTENCY_CUT_TIMEOUT,
848 self.runtime.state.processing_barrier.enter_snapshot(),
849 )
850 .await
851 .context("timed out waiting for a consistent VM/projection snapshot cut")?;
852
853 let dump_started = Instant::now();
854 let (vm_snapshot, resume_watermark) = {
855 let vm = registration
856 .vm
857 .lock()
858 .map_err(|_| anyhow::anyhow!("VM mutex poisoned"))?;
859 let resume_watermark = self.runtime.state.resume_watermark.load(Ordering::Relaxed);
860 let vm_snapshot = vm.dump();
861 (vm_snapshot, resume_watermark)
862 };
863 let vm_lock_held = dump_started.elapsed();
864 let observed_slot = registration.slot_tracker.get();
865 let entity_cache_dump = self.entity_cache.dump_shared().await;
867 let journal_dump = self.journal.dump().await;
870 if trigger == SnapshotTrigger::Shutdown {
871 self.journal.seal();
876 }
877 let applied_batches = self.runtime.state.applied_batches.load(Ordering::Relaxed);
878 drop(consistency_guard);
879
880 let created_at_epoch_ms = now_epoch_ms();
881 let header = SnapshotHeader {
882 format_version: SNAPSHOT_FORMAT_VERSION,
883 bytecode_hash: self.bytecode_hash.clone(),
884 state_contract: self.state_contract.clone(),
885 projection_contract: Some(self.projection_contract.clone()),
886 row_scope_contract: self.row_scope_contract.clone(),
887 program_ids: self.program_ids.clone(),
888 resume_watermark,
889 observed_slot,
890 created_at_epoch_ms,
891 trigger: Some(trigger),
892 entry_counts: vm_snapshot
893 .entry_counts()
894 .into_iter()
895 .chain(
896 journal_dump.views.iter().map(|(view_id, view)| {
901 (format!("journal:{view_id}"), view.records.len() as u64)
902 }),
903 )
904 .collect(),
905 };
906 let payload = envelope::SharedSnapshotPayload {
907 entity_lifetimes: Some(self.entity_cache.dump_lifetimes().await),
908 entity_tombstones: Default::default(),
909 vm: vm_snapshot,
910 entity_cache: entity_cache_dump,
911 journal: journal_dump,
912 };
913 let bytes = tokio::task::spawn_blocking(move || envelope::encode_shared(&header, &payload))
914 .await
915 .context("snapshot encode task panicked")??;
916
917 let name = store::snapshot_name(created_at_epoch_ms, resume_watermark);
918 self.store.write(&name, &bytes).await?;
919 if let Err(err) = self.store.prune(self.config.keep).await {
920 warn!(error = format!("{err:#}"), "Failed to prune old snapshots");
921 }
922 self.batches_at_last_snapshot
923 .store(applied_batches, Ordering::Relaxed);
924
925 info!(
926 snapshot = %name,
927 bytes = bytes.len(),
928 resume_watermark,
929 observed_slot,
930 vm_lock_ms = vm_lock_held.as_millis() as u64,
931 trigger = ?trigger,
932 "Snapshot written"
933 );
934 Ok(true)
935 }
936}
937
938async fn remember_uncached_vm_keys(
944 view_index: &ViewIndex,
945 vm: &VmSnapshot,
946 entity_cache: &EntityCache,
947) {
948 for table in vm.states.values() {
949 let views = view_index.by_export(&table.entity_name);
950 if views.is_empty() || table.data.is_empty() {
951 continue;
952 }
953 let keys: Vec<String> = table
954 .data
955 .iter()
956 .map(|(key, _)| crate::projector::Projector::extract_key(key))
957 .collect();
958 for view in views {
959 entity_cache
960 .remember_evicted(&view.id, keys.iter().cloned())
961 .await;
962 }
963 }
964}
965
966async fn rebuild_sorted_caches(view_index: &ViewIndex, entity_cache: &EntityCache) {
970 let sorted_caches = view_index.sorted_caches();
971 let max_entries = entity_cache.max_entities_per_view();
972 for spec in view_index.get_derived_views() {
973 let Some(source_view) = spec.source_view.as_ref() else {
974 continue;
975 };
976 let entities = entity_cache.get_all_shared(source_view).await;
978 let filter = spec
979 .pipeline
980 .as_ref()
981 .and_then(|pipeline| pipeline.filter.as_ref());
982 let mut caches = sorted_caches.write().await;
983 if let Some(cache) = caches.get_mut(&spec.id) {
984 cache.clear();
985 let count = entities.len();
986 for (key, entity) in entities {
987 if filter.is_none_or(|filter| filter.matches(&entity)) {
988 cache.put(key, entity);
989 }
990 }
991 cache.trim_to_max_entries(max_entries);
993 debug!(view_id = %spec.id, count, "Rebuilt sorted cache from snapshot");
994 }
995 }
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001 use crate::cache::CacheWrite;
1002 use crate::view::{Delivery, Filters, Projection, ViewSpec};
1003 use crate::websocket::frame::Mode;
1004 use arete_interpreter::snapshot::StateTableSnapshot;
1005 use serde_json::json;
1006
1007 fn view(id: &str, mode: Mode) -> ViewSpec {
1008 ViewSpec {
1009 id: id.to_string(),
1010 export: "Round".to_string(),
1011 mode,
1012 wire_format: Default::default(),
1013 projection: Projection::all(),
1014 filters: Filters::all(),
1015 delivery: Delivery::default(),
1016 pipeline: None,
1017 source_view: None,
1018 }
1019 }
1020
1021 #[tokio::test]
1022 async fn rebuilding_sorted_views_from_an_empty_snapshot_clears_previous_rows() {
1023 use crate::materialized_view::{SortConfig, SortOrder, ViewPipeline};
1024 let mut index = ViewIndex::new();
1025 index.add_spec(view("Round/list", Mode::List));
1026 let mut derived = view("Round/sorted", Mode::List);
1027 derived.source_view = Some("Round/list".into());
1028 derived.pipeline = Some(ViewPipeline {
1029 sort: Some(SortConfig {
1030 field_path: vec!["id".into()],
1031 order: SortOrder::Desc,
1032 }),
1033 ..Default::default()
1034 });
1035 index.add_spec(derived);
1036 index
1037 .sorted_caches()
1038 .write()
1039 .await
1040 .get_mut("Round/sorted")
1041 .unwrap()
1042 .upsert("old".into(), json!({"id":1}));
1043 rebuild_sorted_caches(&index, &EntityCache::new()).await;
1044 assert!(index
1045 .sorted_caches()
1046 .write()
1047 .await
1048 .get_mut("Round/sorted")
1049 .unwrap()
1050 .get_all_ordered()
1051 .is_empty());
1052 }
1053
1054 #[tokio::test]
1057 async fn restored_vm_entities_missing_from_the_cache_are_remembered_as_evicted() {
1058 let mut index = ViewIndex::new();
1059 index.add_spec(view("Round/list", Mode::List));
1060 index.add_spec(view("Round/state", Mode::State));
1061 let cache = EntityCache::new();
1062 cache
1063 .hydrate(vec![(
1064 "Round/list".to_string(),
1065 vec![("2".to_string(), json!({"id": 2}))],
1066 )])
1067 .await;
1068 let vm = VmSnapshot {
1069 states: HashMap::from([(
1070 0,
1071 StateTableSnapshot {
1072 entity_name: "Round".to_string(),
1073 data: vec![(json!(1), json!({"id": 1})), (json!(2), json!({"id": 2}))],
1074 ..Default::default()
1075 },
1076 )]),
1077 ..Default::default()
1078 };
1079
1080 remember_uncached_vm_keys(&index, &vm, &cache).await;
1081
1082 assert!(matches!(
1083 cache.upsert("Round/list", "1", json!({"n": 1})).await,
1084 CacheWrite::Refused { .. }
1085 ));
1086 assert!(matches!(
1087 cache.upsert("Round/state", "1", json!({"n": 1})).await,
1088 CacheWrite::Refused { .. }
1089 ));
1090 assert_eq!(
1091 cache.upsert("Round/list", "2", json!({"n": 1})).await,
1092 CacheWrite::Merged
1093 );
1094 assert_eq!(
1096 cache.upsert("Round/list", "3", json!({"id": 3})).await,
1097 CacheWrite::Created
1098 );
1099 }
1100}