Skip to main content

gam_runtime/warm_start/
session.rs

1//! A `Session` ties a `WarmStartStore` to a specific `Fingerprint` so callers
2//! can resume + checkpoint a single fit without re-passing the key on every
3//! call. One session corresponds to one in-flight fit; periodic checkpoints
4//! overwrite a single run-id slot so we don't accumulate one entry per write.
5
6use crate::warm_start::ConfiguredWarmStartStore;
7use crate::warm_start::key::Fingerprint;
8use crate::warm_start::store::{EntryKind, WarmStartEntry, WarmStartStore};
9use std::sync::Mutex;
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12/// Minimum gap between checkpoint writes. Auto-derived; never less, so a
13/// tight loop can't thrash disk. Improvements over the best-so-far always
14/// bypass the rate limit — losing the best iterate to a hard crash is the
15/// failure mode this whole module exists to prevent.
16const MIN_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(2);
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum LoadSource {
20    Exact,
21    Preloaded,
22}
23
24#[derive(Debug, Clone)]
25pub struct LoadedEntry {
26    pub entry: WarmStartEntry,
27    pub source: LoadSource,
28}
29
30#[derive(Debug)]
31pub struct Session {
32    store: WarmStartStore,
33    configured_store: Option<ConfiguredWarmStartStore>,
34    key: Fingerprint,
35    run_id: String,
36    inner: Mutex<Inner>,
37    /// Pre-loaded seed payload from a hierarchical near-match key.
38    ///
39    /// Populated by callers who looked up a related (but not exact-match)
40    /// entry from a different key in the same store. The first call to
41    /// [`Self::try_load`] returns and clears this slot — so the session
42    /// can be used as a unified "load best seed, save under exact key"
43    /// abstraction regardless of where the seed came from.
44    preloaded: Mutex<Option<WarmStartEntry>>,
45}
46
47#[derive(Debug)]
48struct Inner {
49    last_write: Option<Instant>,
50    best_seen: Option<f64>,
51}
52
53impl Session {
54    pub fn open(store: WarmStartStore, key: Fingerprint) -> Self {
55        let nanos = SystemTime::now()
56            .duration_since(UNIX_EPOCH)
57            .map(|d| d.as_nanos())
58            .unwrap_or(0);
59        let pid = std::process::id();
60        let run_id = format!("ckpt-r{pid:x}-{nanos:x}");
61        Self {
62            store,
63            configured_store: None,
64            key,
65            run_id,
66            inner: Mutex::new(Inner {
67                last_write: None,
68                best_seen: None,
69            }),
70            preloaded: Mutex::new(None),
71        }
72    }
73
74    pub(super) fn open_configured(
75        store: WarmStartStore,
76        key: Fingerprint,
77        configured_store: ConfiguredWarmStartStore,
78    ) -> Self {
79        let mut session = Self::open(store, key);
80        session.configured_store = Some(configured_store);
81        session
82    }
83
84    fn configured_store_is_available(&self) -> bool {
85        self.configured_store
86            .as_ref()
87            .is_none_or(ConfiguredWarmStartStore::is_available)
88    }
89
90    fn record_store_error(&self, operation: &str, error: &crate::warm_start::StoreError) {
91        if let Some(configured_store) = &self.configured_store {
92            configured_store.record_store_error(operation, error);
93        }
94    }
95
96    /// Stash a near-match payload that the next [`Self::try_load`] call
97    /// should return in preference to looking up this session's key.
98    ///
99    /// Used by the workflow dispatcher to seed a fresh fit's outer loop
100    /// from a related but not-exact-fingerprint prior fit (e.g.,
101    /// cross-validation folds of the same model). The exact-key keyspace
102    /// remains untouched by this — checkpoint and finalize writes still
103    /// go to the session's own key.
104    pub fn preload(&self, entry: WarmStartEntry) {
105        let mut slot = match self.preloaded.lock() {
106            Ok(g) => g,
107            Err(p) => p.into_inner(),
108        };
109        *slot = Some(entry);
110    }
111
112    pub fn key(&self) -> &Fingerprint {
113        &self.key
114    }
115
116    pub fn run_id(&self) -> &str {
117        &self.run_id
118    }
119
120    /// Read the best entry currently on disk for this session's key.
121    /// Lookup is read-only against the store and may return entries from
122    /// other runs (the whole point of cross-run resume).
123    ///
124    /// If a near-match seed has been preloaded via [`Self::preload`],
125    /// the seed is returned in preference to the store lookup AND
126    /// consumed (so subsequent calls fall back to the store). This
127    /// makes the session a unified abstraction over "exact-key hit"
128    /// and "hierarchical-prefix seed."
129    pub fn try_load(&self) -> Option<WarmStartEntry> {
130        self.try_load_with_source().map(|loaded| loaded.entry)
131    }
132
133    /// Read the best available warm-start entry and report whether it came
134    /// from this session's exact key or from a preloaded near-match seed.
135    ///
136    /// Callers that only need a seed can use [`Self::try_load`]. Callers that
137    /// may skip expensive validation on a finalized exact hit need this source
138    /// bit so a near-match prefix seed is never mistaken for a completed fit.
139    pub fn try_load_with_source(&self) -> Option<LoadedEntry> {
140        if let Ok(mut slot) = self.preloaded.lock()
141            && let Some(entry) = slot.take()
142        {
143            return Some(LoadedEntry {
144                entry,
145                source: LoadSource::Preloaded,
146            });
147        }
148        if !self.configured_store_is_available() {
149            return None;
150        }
151        match self.store.lookup(&self.key) {
152            Ok(Some(entry)) => Some(LoadedEntry {
153                entry,
154                source: LoadSource::Exact,
155            }),
156            Ok(None) => None,
157            Err(error) => {
158                self.record_store_error("load outer-iterate session", &error);
159                None
160            }
161        }
162    }
163
164
165    /// Read the currently available warm-start entry with source metadata,
166    /// without consuming a preloaded near-match seed.
167    pub fn peek_load_with_source(&self) -> Option<LoadedEntry> {
168        if let Ok(slot) = self.preloaded.lock()
169            && let Some(entry) = slot.as_ref()
170        {
171            return Some(LoadedEntry {
172                entry: entry.clone(),
173                source: LoadSource::Preloaded,
174            });
175        }
176        if !self.configured_store_is_available() {
177            return None;
178        }
179        match self.store.lookup(&self.key) {
180            Ok(Some(entry)) => Some(LoadedEntry {
181                entry,
182                source: LoadSource::Exact,
183            }),
184            Ok(None) => None,
185            Err(error) => {
186                self.record_store_error("peek outer-iterate session", &error);
187                None
188            }
189        }
190    }
191
192    /// Persist a mid-fit checkpoint. Rate-limited; returns true if a write
193    /// actually happened. Always writes when the new objective strictly
194    /// improves on the best-so-far observed in this session.
195    pub fn checkpoint(
196        &self,
197        payload: &[u8],
198        objective: Option<f64>,
199        iteration: Option<u64>,
200    ) -> bool {
201        if !self.configured_store_is_available() {
202            return false;
203        }
204        let now = Instant::now();
205        let mut guard = match self.inner.lock() {
206            Ok(g) => g,
207            Err(p) => p.into_inner(),
208        };
209        let improves = match (objective, guard.best_seen) {
210            (Some(o), Some(b)) => o < b - 1e-12,
211            (Some(_), None) => true,
212            _ => false,
213        };
214        if !improves
215            && let Some(last) = guard.last_write
216            && now.duration_since(last) < MIN_CHECKPOINT_INTERVAL
217        {
218            return false;
219        }
220        match self.store.save_overwrite(
221            &self.key,
222            &self.run_id,
223            payload,
224            objective,
225            iteration,
226            EntryKind::Checkpoint,
227        ) {
228            Ok(()) => {
229                guard.last_write = Some(now);
230                if let Some(o) = objective {
231                    guard.best_seen = Some(match guard.best_seen {
232                        Some(b) => b.min(o),
233                        None => o,
234                    });
235                }
236                true
237            }
238            Err(error) => {
239                self.record_store_error("checkpoint outer-iterate session", &error);
240                false
241            }
242        }
243    }
244
245    /// Persist the end-of-fit result, promoting this session's slot to
246    /// `EntryKind::Final`. Bypasses the rate limit.
247    pub fn finalize(&self, payload: &[u8], objective: Option<f64>, iteration: Option<u64>) -> bool {
248        if !self.configured_store_is_available() {
249            return false;
250        }
251        match self.store.save_overwrite(
252            &self.key,
253            &self.run_id,
254            payload,
255            objective,
256            iteration,
257            EntryKind::Final,
258        ) {
259            Ok(()) => true,
260            Err(error) => {
261                self.record_store_error("finalize outer-iterate session", &error);
262                false
263            }
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::warm_start::key::Fingerprinter;
272    use crate::warm_start::store::StoreOptions;
273
274    fn temp_session(label: &str) -> (tempfile::TempDir, Session) {
275        let dir = tempfile::tempdir().unwrap();
276        let store = WarmStartStore::open(
277            dir.path().to_path_buf(),
278            StoreOptions {
279                size_budget_bytes: 1024 * 1024,
280                ttl: Duration::from_secs(60),
281            },
282        )
283        .unwrap();
284        let mut fp = Fingerprinter::new();
285        fp.absorb_str(b"label", label);
286        let key = fp.finalize();
287        let s = Session::open(store, key);
288        (dir, s)
289    }
290
291    #[test]
292    fn checkpoint_then_load() {
293        let (_d, s) = temp_session("ckpt");
294        assert!(s.checkpoint(b"iter-1", Some(2.0), Some(1)));
295        let got = s.try_load().unwrap();
296        assert_eq!(got.payload, b"iter-1");
297        assert_eq!(got.objective, Some(2.0));
298        assert_eq!(got.kind, EntryKind::Checkpoint);
299    }
300
301    #[test]
302    fn improving_objective_bypasses_rate_limit() {
303        let (_d, s) = temp_session("improve");
304        assert!(s.checkpoint(b"a", Some(5.0), Some(1)));
305        // Immediately better objective — must write even though rate-limit
306        // window is open.
307        assert!(s.checkpoint(b"b", Some(3.0), Some(2)));
308        let got = s.try_load().unwrap();
309        assert_eq!(got.payload, b"b");
310        assert_eq!(got.objective, Some(3.0));
311    }
312
313    #[test]
314    fn non_improving_writes_are_throttled() {
315        let (_d, s) = temp_session("throttle");
316        assert!(s.checkpoint(b"a", Some(2.0), Some(1)));
317        // Worse objective inside the rate window — should be suppressed.
318        assert!(!s.checkpoint(b"b", Some(5.0), Some(2)));
319        // Disk still shows the better iterate.
320        let got = s.try_load().unwrap();
321        assert_eq!(got.payload, b"a");
322    }
323
324    #[test]
325    fn finalize_promotes_to_final_kind() {
326        let (_d, s) = temp_session("final");
327        s.checkpoint(b"ckpt", Some(2.0), Some(1));
328        s.finalize(b"done", Some(1.0), Some(5));
329        let got = s.try_load().unwrap();
330        assert_eq!(got.payload, b"done");
331        assert_eq!(got.kind, EntryKind::Final);
332    }
333
334    #[test]
335    fn preload_takes_precedence_over_store_lookup() {
336        // Hierarchical near-match semantics: when a session is opened on
337        // a fresh key (no entry) but preloaded with a near-match payload
338        // from a different key, try_load returns the preloaded entry.
339        let (_d, s) = temp_session("preload-empty");
340        assert!(s.try_load().is_none(), "fresh key should have no entry");
341
342        let seeded = WarmStartEntry {
343            payload: b"from-prefix".to_vec(),
344            objective: Some(7.0),
345            iteration: Some(42),
346            kind: EntryKind::Final,
347            written_unix_secs: 0,
348        };
349        s.preload(seeded);
350
351        let got = s.try_load().expect("preloaded seed should be returned");
352        assert_eq!(got.payload, b"from-prefix");
353        assert_eq!(got.objective, Some(7.0));
354    }
355
356    #[test]
357    fn preload_consumed_on_first_try_load() {
358        // The preload slot is consumed after one read so subsequent calls
359        // fall back to the store. This makes the session a unified
360        // "load best seed, save under exact key" abstraction without
361        // duplicating reads.
362        let (_d, s) = temp_session("preload-consume");
363        s.checkpoint(b"exact", Some(2.0), Some(5));
364
365        let seeded = WarmStartEntry {
366            payload: b"seed".to_vec(),
367            objective: Some(99.0),
368            iteration: Some(1),
369            kind: EntryKind::Checkpoint,
370            written_unix_secs: 0,
371        };
372        s.preload(seeded);
373
374        // First try_load: seed (preferred over store).
375        let first = s.try_load().expect("first call should return seed");
376        assert_eq!(first.payload, b"seed");
377
378        // Second try_load: store lookup after the seed is consumed.
379        let second = s.try_load().expect("second call should read from store");
380        assert_eq!(second.payload, b"exact");
381    }
382
383    #[test]
384    fn peek_load_does_not_consume_preloaded_seed() {
385        let (_d, s) = temp_session("preload-peek");
386        let seeded = WarmStartEntry {
387            payload: b"seed".to_vec(),
388            objective: Some(3.0),
389            iteration: Some(9),
390            kind: EntryKind::Final,
391            written_unix_secs: 0,
392        };
393        s.preload(seeded);
394
395        let peeked = s
396            .peek_load_with_source()
397            .expect("peek should see preloaded seed");
398        assert_eq!(peeked.entry.payload, b"seed");
399        assert_eq!(peeked.source, LoadSource::Preloaded);
400
401        let loaded = s
402            .try_load()
403            .expect("try_load should still receive the preloaded seed");
404        assert_eq!(loaded.payload, b"seed");
405        assert!(
406            s.try_load().is_none(),
407            "preloaded seed should be consumed only by try_load"
408        );
409    }
410
411    #[test]
412    fn second_session_reads_first_session_checkpoint() {
413        let dir = tempfile::tempdir().unwrap();
414        let mut fp = Fingerprinter::new();
415        fp.absorb_str(b"k", "shared");
416        let key = fp.finalize();
417
418        let store_a = WarmStartStore::open(
419            dir.path().to_path_buf(),
420            StoreOptions {
421                size_budget_bytes: 1024 * 1024,
422                ttl: Duration::from_secs(60),
423            },
424        )
425        .unwrap();
426        let s_a = Session::open(store_a, key);
427        s_a.checkpoint(b"from-a", Some(1.0), Some(3));
428
429        // Simulate a fresh process starting later.
430        let store_b = WarmStartStore::open(
431            dir.path().to_path_buf(),
432            StoreOptions {
433                size_budget_bytes: 1024 * 1024,
434                ttl: Duration::from_secs(60),
435            },
436        )
437        .unwrap();
438        let s_b = Session::open(store_b, key);
439        let got = s_b.try_load().unwrap();
440        assert_eq!(got.payload, b"from-a");
441        assert_eq!(got.objective, Some(1.0));
442    }
443}