gemini_memory_rs/ingestion/
checkpoint.rs1use chrono::{DateTime, Duration, Utc};
9
10use crate::core::{CadenceConfig, MemoryRuntimeConfig, SessionConfig};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ScheduledWork {
15 MicroReconcile,
17 Checkpoint,
19 SealSession,
21}
22
23#[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 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 pub fn total_turns(&self) -> u64 {
57 self.total_turns
58 }
59
60 pub fn is_sealed(&self) -> bool {
62 self.sealed
63 }
64
65 pub fn touch(&mut self, now: DateTime<Utc>) {
67 self.last_activity_at = now;
68 }
69
70 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 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 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 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}