Skip to main content

cranpose_services/
durable_save.rs

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