cranpose_services/
durable_save.rs1use std::{
2 collections::BTreeMap,
3 panic::{AssertUnwindSafe, catch_unwind},
4 sync::{
5 Arc, Mutex, OnceLock, PoisonError,
6 atomic::{AtomicU64, Ordering},
7 },
8 time::Duration,
9};
10
11use crate::preferences::{PreferencesError, PreferencesRef};
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum DurableSaveOutcome {
16 Nothing,
18 Completed,
20 Failed,
22 TimedOut,
24}
25
26#[derive(Clone, Debug, thiserror::Error)]
28pub enum DurableSaveError {
29 #[error(transparent)]
31 Preferences(#[from] PreferencesError),
32 #[error("durable save failed: {0}")]
34 Storage(String),
35}
36
37type SaveCallback = dyn Fn() -> Result<(), DurableSaveError> + Send + Sync;
38type PreferenceCallback = dyn Fn() -> String + Send + Sync;
39
40enum SaveWork {
41 Callback(Box<SaveCallback>),
42 Preference {
43 store: PreferencesRef,
44 key: String,
45 read: Box<PreferenceCallback>,
46 },
47}
48
49static NEXT_SAVE: AtomicU64 = AtomicU64::new(1);
50type Saves = BTreeMap<u64, Arc<SaveWork>>;
51
52fn saves() -> &'static Mutex<Saves> {
53 static SAVES: OnceLock<Mutex<Saves>> = OnceLock::new();
54 SAVES.get_or_init(Mutex::default)
55}
56
57pub struct DurableSaveRegistration {
59 id: u64,
60}
61
62impl Drop for DurableSaveRegistration {
63 fn drop(&mut self) {
64 let removed = saves()
65 .lock()
66 .unwrap_or_else(PoisonError::into_inner)
67 .remove(&self.id);
68 drop(removed);
69 }
70}
71
72fn register(work: SaveWork) -> DurableSaveRegistration {
73 let id = NEXT_SAVE.fetch_add(1, Ordering::Relaxed);
74 saves()
75 .lock()
76 .unwrap_or_else(PoisonError::into_inner)
77 .insert(id, Arc::new(work));
78 DurableSaveRegistration { id }
79}
80
81pub fn register_durable_save(
84 save: impl Fn() -> Result<(), DurableSaveError> + Send + Sync + 'static,
85) -> DurableSaveRegistration {
86 register(SaveWork::Callback(Box::new(save)))
87}
88
89pub fn register_preference_save(
92 store: PreferencesRef,
93 key: String,
94 read: impl Fn() -> String + Send + Sync + 'static,
95) -> DurableSaveRegistration {
96 register(SaveWork::Preference {
97 store,
98 key,
99 read: Box::new(read),
100 })
101}
102
103#[expect(non_snake_case)]
105#[track_caller]
106pub fn DurableSaveEffect<K: PartialEq + 'static>(
107 keys: K,
108 save: impl Fn() -> Result<(), DurableSaveError> + Send + Sync + 'static,
109) {
110 cranpose_core::__disposable_effect_impl(
111 cranpose_core::caller_location_key()
112 ^ cranpose_core::location_key(file!(), line!(), column!()),
113 keys,
114 move |scope| {
115 let registration = register_durable_save(save);
116 scope.on_dispose(move || drop(registration))
117 },
118 );
119}
120
121mod local_saves;
122
123pub use local_saves::SaveableRegistration;
124pub(crate) use local_saves::{LocalSaves, forget, register as register_local, saved};
125
126struct PreferenceBatch {
127 store: PreferencesRef,
128 values: BTreeMap<String, String>,
129 forget: Vec<Arc<local_saves::Forget>>,
130}
131
132impl PreferenceBatch {
133 fn removed_keys(&self) -> Vec<String> {
134 if self.forget.is_empty() {
135 return Vec::new();
136 }
137 let mut keys = self.store.keys();
138 keys.retain(|key| self.forget.iter().any(|forget| forget.matches(key)));
139 keys
140 }
141}
142
143#[derive(Default)]
144struct SaveBatch {
145 preferences: Vec<PreferenceBatch>,
146 callbacks: Vec<Arc<SaveWork>>,
147}
148
149impl SaveBatch {
150 fn for_store(&mut self, store: &PreferencesRef) -> &mut PreferenceBatch {
151 let index = match self
152 .preferences
153 .iter()
154 .position(|batch| Arc::ptr_eq(&batch.store, store))
155 {
156 Some(index) => index,
157 None => {
158 self.preferences.push(PreferenceBatch {
159 store: Arc::clone(store),
160 values: BTreeMap::new(),
161 forget: Vec::new(),
162 });
163 self.preferences.len() - 1
164 }
165 };
166 &mut self.preferences[index]
167 }
168
169 fn push(&mut self, store: &PreferencesRef, key: String, value: String) {
170 self.for_store(store).values.insert(key, value);
171 }
172
173 fn forget(&mut self, store: &PreferencesRef, keys: Arc<local_saves::Forget>) {
174 self.for_store(store).forget.push(keys);
175 }
176
177 fn collect() -> (Self, local_saves::Sampled) {
179 let mut batch = Self {
180 callbacks: saves()
181 .lock()
182 .unwrap_or_else(PoisonError::into_inner)
183 .values()
184 .cloned()
185 .collect(),
186 ..Self::default()
187 };
188 let sampled = local_saves::sample(&mut batch);
189 (batch, sampled)
190 }
191
192 fn is_empty(&self) -> bool {
193 self.preferences.is_empty() && self.callbacks.is_empty()
194 }
195
196 fn run(mut self) -> DurableSaveOutcome {
197 let mut succeeded = true;
198 for work in std::mem::take(&mut self.callbacks) {
199 succeeded &= match &*work {
200 SaveWork::Callback(save) => run_callback(save),
201 SaveWork::Preference { store, key, read } => {
202 match catch_unwind(AssertUnwindSafe(read)) {
203 Ok(value) => {
204 self.push(store, key.clone(), value);
205 true
206 }
207 Err(_) => false,
208 }
209 }
210 };
211 }
212 for batch in self.preferences {
213 succeeded &= run_callback(|| {
214 batch
215 .store
216 .update_many(&batch.values, &batch.removed_keys())?;
217 batch.store.flush()?;
218 Ok(())
219 });
220 }
221 if succeeded {
222 DurableSaveOutcome::Completed
223 } else {
224 DurableSaveOutcome::Failed
225 }
226 }
227}
228
229fn run_callback(save: impl FnOnce() -> Result<(), DurableSaveError>) -> bool {
230 match catch_unwind(AssertUnwindSafe(save)) {
231 Ok(Ok(())) => true,
232 Ok(Err(error)) => {
233 log::warn!("cranpose: {error}");
234 false
235 }
236 Err(_) => {
237 log::warn!("cranpose: durable save panicked");
238 false
239 }
240 }
241}
242
243#[inline(never)]
251pub fn run_durable_saves(deadline: Duration) -> DurableSaveOutcome {
252 #[cfg(not(target_arch = "wasm32"))]
253 {
254 executor::run(deadline)
255 }
256 #[cfg(target_arch = "wasm32")]
257 {
258 let started = web_time::Instant::now();
259 let (batch, sampled) = SaveBatch::collect();
260 if batch.is_empty() {
261 return DurableSaveOutcome::Nothing;
262 }
263 let result = batch.run();
264 if result == DurableSaveOutcome::Completed {
265 sampled.completed();
266 }
267 if result == DurableSaveOutcome::Completed && started.elapsed() > deadline {
268 DurableSaveOutcome::TimedOut
269 } else {
270 result
271 }
272 }
273}
274
275#[cfg(not(target_arch = "wasm32"))]
276mod executor;