gemini_memory_rs/ingestion/
checkpoint.rs

1//! Session cadence: when to micro-reconcile, when to checkpoint, when to seal.
2//!
3//! A long conversation crossing several transport connections must not lose an
4//! hour of evidence to one unclean disconnect, and must not pay for a full
5//! reconciliation every time a WebSocket reconnects. Checkpointing bounds the
6//! recovery cost without ending the logical session.
7
8use chrono::{DateTime, Duration, Utc};
9
10use crate::core::{CadenceConfig, MemoryRuntimeConfig, SessionConfig};
11
12/// Work the cadence says is due.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ScheduledWork {
15    /// Merge duplicates and resolve in-session contradictions.
16    MicroReconcile,
17    /// Persist pending evidence and refresh the index, session still live.
18    Checkpoint,
19    /// The session has gone idle; seal it and reconcile fully.
20    SealSession,
21}
22
23/// Tracks turn counts and elapsed time against the configured cadences.
24#[derive(Debug)]
25pub struct CadenceTracker {
26    micro: CadenceConfig,
27    checkpoint: CadenceConfig,
28    session: SessionConfig,
29    turns_since_micro: u32,
30    turns_since_checkpoint: u32,
31    last_micro_at: DateTime<Utc>,
32    last_checkpoint_at: DateTime<Utc>,
33    last_activity_at: DateTime<Utc>,
34    total_turns: u64,
35    sealed: bool,
36}
37
38impl CadenceTracker {
39    /// Start tracking from `now`.
40    pub fn new(config: &MemoryRuntimeConfig, now: DateTime<Utc>) -> Self {
41        Self {
42            micro: config.micro_reconciliation,
43            checkpoint: config.checkpoint,
44            session: config.session,
45            turns_since_micro: 0,
46            turns_since_checkpoint: 0,
47            last_micro_at: now,
48            last_checkpoint_at: now,
49            last_activity_at: now,
50            total_turns: 0,
51            sealed: false,
52        }
53    }
54
55    /// Turns completed in this logical session.
56    pub fn total_turns(&self) -> u64 {
57        self.total_turns
58    }
59
60    /// Whether the session has been sealed.
61    pub fn is_sealed(&self) -> bool {
62        self.sealed
63    }
64
65    /// Record activity without completing a turn — this defers idle sealing.
66    pub fn touch(&mut self, now: DateTime<Utc>) {
67        self.last_activity_at = now;
68    }
69
70    /// Record a completed user turn and report what is now due.
71    ///
72    /// Checkpoint implies micro-reconciliation, so only the larger unit is
73    /// reported when both come due on the same turn.
74    pub fn on_turn_complete(&mut self, now: DateTime<Utc>) -> Vec<ScheduledWork> {
75        self.total_turns += 1;
76        self.turns_since_micro += 1;
77        self.turns_since_checkpoint += 1;
78        self.last_activity_at = now;
79
80        let mut due = Vec::new();
81        if self
82            .checkpoint
83            .is_due(self.turns_since_checkpoint, now - self.last_checkpoint_at)
84        {
85            self.turns_since_checkpoint = 0;
86            self.last_checkpoint_at = now;
87            self.turns_since_micro = 0;
88            self.last_micro_at = now;
89            due.push(ScheduledWork::Checkpoint);
90        } else if self
91            .micro
92            .is_due(self.turns_since_micro, now - self.last_micro_at)
93        {
94            self.turns_since_micro = 0;
95            self.last_micro_at = now;
96            due.push(ScheduledWork::MicroReconcile);
97        }
98        due
99    }
100
101    /// Whether the session has been idle long enough to seal.
102    pub fn is_idle(&self, now: DateTime<Utc>) -> bool {
103        !self.sealed
104            && (now - self.last_activity_at)
105                >= Duration::seconds(self.session.logical_idle_timeout_seconds as i64)
106    }
107
108    /// Report sealing work if the session has gone idle.
109    ///
110    /// A transport reconnect is deliberately not an input here: a WebSocket
111    /// closing is not the user ending the conversation.
112    pub fn poll_idle(&mut self, now: DateTime<Utc>) -> Option<ScheduledWork> {
113        if self.is_idle(now) {
114            self.sealed = true;
115            Some(ScheduledWork::SealSession)
116        } else {
117            None
118        }
119    }
120
121    /// Seal explicitly, e.g. because the user ended the conversation.
122    pub fn seal(&mut self) {
123        self.sealed = true;
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn tracker(now: DateTime<Utc>) -> CadenceTracker {
132        CadenceTracker::new(&MemoryRuntimeConfig::default(), now)
133    }
134
135    #[test]
136    fn micro_reconciliation_fires_on_the_configured_turn_count() {
137        let now = Utc::now();
138        let mut tracker = tracker(now);
139        for turn in 1..4 {
140            assert!(
141                tracker.on_turn_complete(now).is_empty(),
142                "nothing due at turn {turn}"
143            );
144        }
145        assert_eq!(
146            tracker.on_turn_complete(now),
147            vec![ScheduledWork::MicroReconcile]
148        );
149    }
150
151    #[test]
152    fn micro_reconciliation_also_fires_on_elapsed_time() {
153        let now = Utc::now();
154        let mut tracker = tracker(now);
155        assert_eq!(
156            tracker.on_turn_complete(now + Duration::seconds(120)),
157            vec![ScheduledWork::MicroReconcile]
158        );
159    }
160
161    #[test]
162    fn a_checkpoint_subsumes_the_micro_pass_it_coincides_with() {
163        let now = Utc::now();
164        let mut tracker = tracker(now);
165        let mut checkpoints = 0;
166        let mut micros = 0;
167        for _ in 0..20 {
168            for work in tracker.on_turn_complete(now) {
169                match work {
170                    ScheduledWork::Checkpoint => checkpoints += 1,
171                    ScheduledWork::MicroReconcile => micros += 1,
172                    ScheduledWork::SealSession => unreachable!(),
173                }
174            }
175        }
176        assert_eq!(checkpoints, 1, "one checkpoint in twenty turns");
177        assert_eq!(micros, 4, "turn 20 reports the checkpoint, not both");
178        assert_eq!(tracker.total_turns(), 20);
179    }
180
181    #[test]
182    fn an_idle_session_seals_exactly_once() {
183        let now = Utc::now();
184        let mut tracker = tracker(now);
185        tracker.on_turn_complete(now);
186
187        assert!(tracker.poll_idle(now + Duration::seconds(60)).is_none());
188        assert_eq!(
189            tracker.poll_idle(now + Duration::seconds(200)),
190            Some(ScheduledWork::SealSession)
191        );
192        assert!(tracker.poll_idle(now + Duration::seconds(400)).is_none());
193        assert!(tracker.is_sealed());
194    }
195
196    #[test]
197    fn activity_defers_idle_sealing() {
198        let now = Utc::now();
199        let mut tracker = tracker(now);
200        tracker.on_turn_complete(now);
201        tracker.touch(now + Duration::seconds(170));
202        assert!(tracker.poll_idle(now + Duration::seconds(200)).is_none());
203        assert!(tracker.poll_idle(now + Duration::seconds(360)).is_some());
204    }
205}