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