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 = 400;
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,
78 pub ready_max_lag_slots: u64,
81 pub ready_max_hold: Duration,
84 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 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 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
253pub struct RestoredState {
256 pub vm: VmSnapshot,
257 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#[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
298pub 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 resume_watermark: AtomicU64,
318 applied_batches: AtomicU64,
319}
320
321#[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 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 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 pub fn take_restored(&self) -> Option<RestoredState> {
361 self.state.restored.lock().unwrap().take()
362 }
363
364 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 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
404pub 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
419pub fn take_restored() -> Option<RestoredState> {
422 ACTIVE_SNAPSHOT_RUNTIME
423 .try_with(SnapshotRuntime::take_restored)
424 .ok()
425 .flatten()
426}
427
428#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
459pub enum SnapshotTrigger {
460 Periodic,
461 Shutdown,
462}
463
464pub 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 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 pub fn runtime(&self) -> SnapshotRuntime {
537 self.runtime.clone()
538 }
539
540 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 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 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 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 interval.tick().await;
686 loop {
687 interval.tick().await;
688 if let Err(err) = service.snapshot_now(SnapshotTrigger::Periodic).await {
689 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 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 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
793async 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 cache.trim_to_max_entries(max_entries);
815 debug!(view_id = %spec.id, count, "Rebuilt sorted cache from snapshot");
816 }
817 }
818}