1use 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
81fn stop_preference_saves(store: &PreferencesRef, keys: &local_saves::Forget) {
84 let stopped: Vec<_> = saves()
85 .lock()
86 .unwrap_or_else(PoisonError::into_inner)
87 .extract_if(.., |_, work| match &**work {
88 SaveWork::Preference {
89 store: saved, key, ..
90 } => Arc::ptr_eq(saved, store) && keys.matches(key),
91 SaveWork::Callback(_) => false,
92 })
93 .collect();
94 drop(stopped);
95}
96
97pub fn register_durable_save(
100 save: impl Fn() -> Result<(), DurableSaveError> + Send + Sync + 'static,
101) -> DurableSaveRegistration {
102 register(SaveWork::Callback(Box::new(save)))
103}
104
105pub fn register_preference_save(
108 store: PreferencesRef,
109 key: String,
110 read: impl Fn() -> String + Send + Sync + 'static,
111) -> DurableSaveRegistration {
112 register(SaveWork::Preference {
113 store,
114 key,
115 read: Box::new(read),
116 })
117}
118
119#[expect(non_snake_case)]
121#[track_caller]
122pub fn DurableSaveEffect<K: PartialEq + 'static>(
123 keys: K,
124 save: impl Fn() -> Result<(), DurableSaveError> + Send + Sync + 'static,
125) {
126 cranpose_core::__disposable_effect_impl(
127 cranpose_core::caller_location_key()
128 ^ cranpose_core::location_key(file!(), line!(), column!()),
129 keys,
130 move |scope| {
131 let registration = register_durable_save(save);
132 scope.on_dispose(move || drop(registration))
133 },
134 );
135}
136
137mod local_saves;
138
139pub use local_saves::SaveableRegistration;
140pub(crate) use local_saves::{LocalSaves, forget, register as register_local, saved};
141
142struct PreferenceBatch {
143 store: PreferencesRef,
144 values: BTreeMap<String, String>,
145 forget: Vec<Arc<local_saves::Forget>>,
146}
147
148impl PreferenceBatch {
149 fn removed_keys(&self) -> Vec<String> {
150 if self.forget.is_empty() {
151 return Vec::new();
152 }
153 let mut keys = self.store.keys();
154 keys.retain(|key| self.forget.iter().any(|forget| forget.matches(key)));
155 keys
156 }
157}
158
159#[derive(Default)]
160struct SaveBatch {
161 preferences: Vec<PreferenceBatch>,
162 callbacks: Vec<Arc<SaveWork>>,
163}
164
165impl SaveBatch {
166 fn for_store(&mut self, store: &PreferencesRef) -> &mut PreferenceBatch {
167 let index = match self
168 .preferences
169 .iter()
170 .position(|batch| Arc::ptr_eq(&batch.store, store))
171 {
172 Some(index) => index,
173 None => {
174 self.preferences.push(PreferenceBatch {
175 store: Arc::clone(store),
176 values: BTreeMap::new(),
177 forget: Vec::new(),
178 });
179 self.preferences.len() - 1
180 }
181 };
182 &mut self.preferences[index]
183 }
184
185 fn push(&mut self, store: &PreferencesRef, key: String, value: String) {
186 self.for_store(store).values.insert(key, value);
187 }
188
189 fn forget(&mut self, store: &PreferencesRef, keys: Arc<local_saves::Forget>) {
190 self.for_store(store).forget.push(keys);
191 }
192
193 fn collect() -> (Self, local_saves::Sampled) {
195 let mut batch = Self {
196 callbacks: saves()
197 .lock()
198 .unwrap_or_else(PoisonError::into_inner)
199 .values()
200 .cloned()
201 .collect(),
202 ..Self::default()
203 };
204 let sampled = local_saves::sample(&mut batch);
205 (batch, sampled)
206 }
207
208 fn is_empty(&self) -> bool {
209 self.preferences.is_empty() && self.callbacks.is_empty()
210 }
211
212 fn run(mut self) -> DurableSaveOutcome {
213 let mut succeeded = true;
214 for work in std::mem::take(&mut self.callbacks) {
215 succeeded &= match &*work {
216 SaveWork::Callback(save) => run_callback(save),
217 SaveWork::Preference { store, key, read } => {
218 match catch_unwind(AssertUnwindSafe(read)) {
219 Ok(value) => {
220 self.push(store, key.clone(), value);
221 true
222 }
223 Err(_) => false,
224 }
225 }
226 };
227 }
228 for batch in self.preferences {
229 succeeded &= run_callback(|| {
230 batch
231 .store
232 .update_many(&batch.values, &batch.removed_keys())?;
233 batch.store.flush()?;
234 Ok(())
235 });
236 }
237 if succeeded {
238 DurableSaveOutcome::Completed
239 } else {
240 DurableSaveOutcome::Failed
241 }
242 }
243}
244
245fn run_callback(save: impl FnOnce() -> Result<(), DurableSaveError>) -> bool {
246 match catch_unwind(AssertUnwindSafe(save)) {
247 Ok(Ok(())) => true,
248 Ok(Err(error)) => {
249 log::warn!("cranpose: {error}");
250 false
251 }
252 Err(_) => {
253 log::warn!("cranpose: durable save panicked");
254 false
255 }
256 }
257}
258
259#[inline(never)]
267pub fn run_durable_saves(deadline: Duration) -> DurableSaveOutcome {
268 #[cfg(not(target_arch = "wasm32"))]
269 {
270 executor::run(deadline)
271 }
272 #[cfg(target_arch = "wasm32")]
273 {
274 let started = web_time::Instant::now();
275 let (batch, sampled) = SaveBatch::collect();
276 if batch.is_empty() {
277 return DurableSaveOutcome::Nothing;
278 }
279 let result = batch.run();
280 if result == DurableSaveOutcome::Completed {
281 sampled.completed();
282 }
283 if result == DurableSaveOutcome::Completed && started.elapsed() > deadline {
284 DurableSaveOutcome::TimedOut
285 } else {
286 result
287 }
288 }
289}
290
291#[cfg(not(target_arch = "wasm32"))]
292mod executor;