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