Skip to main content

cranpose_services/
durable_save.rs

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/// Result of a lifecycle or explicit save boundary.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum DurableSaveOutcome {
18    /// No work was registered.
19    Nothing,
20    /// Every callback and preferences batch succeeded.
21    Completed,
22    /// A callback panicked, a worker could not start, or a write failed.
23    Failed,
24    /// The deadline expired. Running work can finish under its background lease.
25    TimedOut,
26}
27
28/// An error reported by a durable-save callback.
29#[derive(Clone, Debug, thiserror::Error)]
30pub enum DurableSaveError {
31    /// A preferences backend rejected a write.
32    #[error(transparent)]
33    Preferences(#[from] PreferencesError),
34    /// Application-specific durable storage failed.
35    #[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
59/// Keeps a shared durable save registered until its owner is dropped.
60pub 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
83/// Registers fallible work for the next save boundary. Callbacks run serially;
84/// timed-out runs are allowed to finish before another run can start.
85pub fn register_durable_save(
86    save: impl Fn() -> Result<(), DurableSaveError> + Send + Sync + 'static,
87) -> DurableSaveRegistration {
88    register(SaveWork::Callback(Box::new(save)))
89}
90
91/// Registers a value to be sampled when saving starts and batched with other
92/// keys using the same store. The callback must not perform storage I/O.
93pub 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/// Registers fallible durable work for this composition's lifetime.
106#[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
264/// Captures composition-local values on the calling thread and saves registered
265/// values in one serial batch. Call this on each composition's owning thread.
266///
267/// Native runs use at most one worker with no queued batches. A concurrent call
268/// waits within its budget before sampling fresh values. A timeout does not
269/// cancel a running write. Retry a failed or timed-out boundary before relying
270/// on restoration. Web writes execute synchronously on the browser thread.
271pub 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;