1use crate::memory::surreal::SurrealMemory;
5use serde::{Deserialize, Serialize};
6use std::str::FromStr;
7use std::sync::Arc;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CronJob {
12 pub id: Option<String>,
13 pub name: String,
14 pub schedule: String,
15 #[serde(default)]
16 pub one_shot: bool,
17 pub task: String,
18 pub channel: String,
19 #[serde(default)]
20 pub chat_id: String,
21 pub model: String,
22 pub enabled: bool,
23 pub last_run: Option<String>,
24 pub next_run: Option<String>,
25 #[serde(default = "default_job_status")]
26 pub status: String,
27 #[serde(default)]
28 pub retry_count: u32,
29 #[serde(default = "default_max_retries")]
30 pub max_retries: u32,
31 #[serde(default)]
32 pub last_error: Option<String>,
33 #[serde(default)]
34 pub lease_until: Option<String>,
35 #[serde(default)]
36 pub run_token: Option<String>,
37}
38
39fn default_job_status() -> String {
40 "active".to_string()
41}
42
43fn default_max_retries() -> u32 {
44 3
45}
46
47pub struct CronScheduler {
49 memory: Option<Arc<SurrealMemory>>,
50 noop_jobs: std::sync::Mutex<Vec<CronJob>>,
52}
53
54impl CronScheduler {
55 pub fn new(memory: Arc<SurrealMemory>) -> Self {
57 Self::new_maybe(Some(memory))
58 }
59
60 pub fn new_noop() -> Self {
62 Self::new_maybe(None)
63 }
64
65 fn new_maybe(memory: Option<Arc<SurrealMemory>>) -> Self {
66 Self {
67 memory,
68 noop_jobs: std::sync::Mutex::new(Vec::new()),
69 }
70 }
71
72 pub async fn add(
74 &self,
75 name: &str,
76 schedule: &str,
77 task: &str,
78 channel: &str,
79 chat_id: &str,
80 model: &str,
81 ) -> anyhow::Result<String> {
82 let parsed = cron::Schedule::from_str(schedule)
84 .map_err(|e| anyhow::anyhow!("Invalid cron expression: {}", e))?;
85
86 let next_run = parsed.upcoming(chrono::Utc).next().map(|t| t.to_rfc3339());
88
89 let job = CronJob {
90 id: Some(name.to_string()),
91 name: name.to_string(),
92 schedule: schedule.to_string(),
93 one_shot: false,
94 task: task.to_string(),
95 channel: channel.to_string(),
96 chat_id: chat_id.to_string(),
97 model: model.to_string(),
98 enabled: true,
99 last_run: None,
100 next_run,
101 status: default_job_status(),
102 retry_count: 0,
103 max_retries: default_max_retries(),
104 last_error: None,
105 lease_until: None,
106 run_token: None,
107 };
108
109 if let Some(ref memory) = self.memory {
110 let db = memory.db();
111 let created: Option<CronJob> = db.create("cron_jobs").content(job).await?;
112 let created = created.ok_or_else(|| anyhow::anyhow!("Failed to create cron job"))?;
113 Ok(created
114 .id
115 .ok_or_else(|| anyhow::anyhow!("Created cron job is missing an id"))?
116 .to_string())
117 } else {
118 let mut jobs = self.noop_jobs.lock().unwrap();
119 jobs.push(job);
120 Ok(name.to_string())
121 }
122 }
123
124 pub async fn add_once(
125 &self,
126 name: &str,
127 run_at: chrono::DateTime<chrono::Utc>,
128 task: &str,
129 channel: &str,
130 chat_id: &str,
131 model: &str,
132 ) -> anyhow::Result<String> {
133 if run_at <= chrono::Utc::now() {
134 anyhow::bail!("run_at must be in the future");
135 }
136 let job = CronJob {
137 id: Some(name.to_string()),
138 name: name.to_string(),
139 schedule: String::new(),
140 one_shot: true,
141 task: task.to_string(),
142 channel: channel.to_string(),
143 chat_id: chat_id.to_string(),
144 model: model.to_string(),
145 enabled: true,
146 last_run: None,
147 next_run: Some(run_at.to_rfc3339()),
148 status: default_job_status(),
149 retry_count: 0,
150 max_retries: default_max_retries(),
151 last_error: None,
152 lease_until: None,
153 run_token: None,
154 };
155 if let Some(ref memory) = self.memory {
156 let created: Option<CronJob> = memory.db().create("cron_jobs").content(job).await?;
157 Ok(created
158 .and_then(|job| job.id)
159 .ok_or_else(|| anyhow::anyhow!("Created one-shot job is missing an id"))?)
160 } else {
161 self.noop_jobs.lock().unwrap().push(job);
162 Ok(name.to_string())
163 }
164 }
165
166 pub async fn list(&self) -> anyhow::Result<Vec<CronJob>> {
168 if let Some(ref memory) = self.memory {
169 let db = memory.db();
170 let mut result: surrealdb::Response =
171 db.query("SELECT * FROM cron_jobs ORDER BY name").await?;
172 let jobs: Vec<CronJob> = result.take(0)?;
173 Ok(jobs)
174 } else {
175 let jobs = self.noop_jobs.lock().unwrap();
176 Ok(jobs.clone())
177 }
178 }
179
180 pub async fn remove(&self, id_or_name: &str) -> anyhow::Result<bool> {
182 if let Some(ref memory) = self.memory {
183 let db = memory.db();
184 let mut result: surrealdb::Response = db
185 .query("DELETE FROM cron_jobs WHERE id = $target OR name = $target")
186 .bind(("target", id_or_name.to_string()))
187 .await?;
188 let deleted: Vec<CronJob> = result.take(0)?;
189 Ok(!deleted.is_empty())
190 } else {
191 let mut jobs = self.noop_jobs.lock().unwrap();
192 let before = jobs.len();
193 jobs.retain(|j| j.name != id_or_name);
194 Ok(jobs.len() < before)
195 }
196 }
197
198 pub async fn enable(&self, id_or_name: &str) -> anyhow::Result<bool> {
200 if let Some(ref memory) = self.memory {
201 let db = memory.db();
202 let mut result: surrealdb::Response = db
203 .query("UPDATE cron_jobs SET enabled = true WHERE id = $target OR name = $target")
204 .bind(("target", id_or_name.to_string()))
205 .await?;
206 let updated: Vec<CronJob> = result.take(0)?;
207 Ok(!updated.is_empty())
208 } else {
209 let mut jobs = self.noop_jobs.lock().unwrap();
210 if let Some(job) = jobs.iter_mut().find(|j| j.name == id_or_name) {
211 job.enabled = true;
212 Ok(true)
213 } else {
214 Ok(false)
215 }
216 }
217 }
218
219 pub async fn disable(&self, id_or_name: &str) -> anyhow::Result<bool> {
221 if let Some(ref memory) = self.memory {
222 let db = memory.db();
223 let mut result: surrealdb::Response = db
224 .query("UPDATE cron_jobs SET enabled = false WHERE id = $target OR name = $target")
225 .bind(("target", id_or_name.to_string()))
226 .await?;
227 let updated: Vec<CronJob> = result.take(0)?;
228 Ok(!updated.is_empty())
229 } else {
230 let mut jobs = self.noop_jobs.lock().unwrap();
231 if let Some(job) = jobs.iter_mut().find(|j| j.name == id_or_name) {
232 job.enabled = false;
233 Ok(true)
234 } else {
235 Ok(false)
236 }
237 }
238 }
239
240 pub async fn due_jobs(&self) -> anyhow::Result<Vec<CronJob>> {
242 let now = chrono::Utc::now().to_rfc3339();
243 if let Some(ref memory) = self.memory {
244 let db = memory.db();
245 let mut result: surrealdb::Response = db
246 .query("SELECT * FROM cron_jobs WHERE enabled = true AND ((status = 'active' OR status = NONE) AND next_run != NONE AND next_run <= $now OR status = 'running' AND lease_until != NONE AND lease_until <= $now)")
247 .bind(("now", now))
248 .await?;
249 let jobs: Vec<CronJob> = result.take(0)?;
250 Ok(jobs)
251 } else {
252 let jobs = self.noop_jobs.lock().unwrap();
253 Ok(jobs
254 .iter()
255 .filter(|j| {
256 j.enabled
257 && ((j.status == "active" && j.next_run.as_deref() <= Some(&now))
258 || (j.status == "running" && j.lease_until.as_deref() <= Some(&now)))
259 })
260 .cloned()
261 .collect())
262 }
263 }
264
265 pub async fn claim_due_jobs(&self, channel: &str) -> anyhow::Result<Vec<CronJob>> {
266 let due = self.due_jobs().await?;
267 let claim_now = chrono::Utc::now().to_rfc3339();
268 let lease_until = (chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339();
269 let mut claimed = Vec::new();
270 for job in due {
271 if job.channel != channel {
272 continue;
273 }
274 let Some(job_id) = job.id.as_deref() else {
275 continue;
276 };
277 let run_token = uuid::Uuid::new_v4().to_string();
278 if let Some(ref memory) = self.memory {
279 let mut result = memory
280 .db()
281 .query("UPDATE cron_jobs SET status = 'running', lease_until = $lease, run_token = $run_token WHERE id = $id AND enabled = true AND (status = 'active' OR status = NONE OR status = 'running' AND lease_until <= $now) RETURN AFTER")
282 .bind(("id", job_id.to_string()))
283 .bind(("lease", lease_until.clone()))
284 .bind(("run_token", run_token.clone()))
285 .bind(("now", claim_now.clone()))
286 .await?;
287 let updated: Vec<CronJob> = result.take(0)?;
288 claimed.extend(updated);
289 } else {
290 let mut jobs = self.noop_jobs.lock().unwrap();
291 if let Some(stored) = jobs.iter_mut().find(|stored| {
292 stored.id.as_deref() == Some(job_id)
293 && (stored.status == "active"
294 || stored.lease_until.as_deref() <= Some(claim_now.as_str()))
295 }) {
296 stored.status = "running".to_string();
297 stored.lease_until = Some(lease_until.clone());
298 stored.run_token = Some(run_token);
299 claimed.push(stored.clone());
300 }
301 }
302 }
303 Ok(claimed)
304 }
305
306 pub async fn mark_run(
308 &self,
309 job_id: &str,
310 run_token: &str,
311 schedule: &str,
312 ) -> anyhow::Result<()> {
313 let now = chrono::Utc::now();
314 let next_run = cron::Schedule::from_str(schedule)
315 .ok()
316 .and_then(|s| s.upcoming(chrono::Utc).next())
317 .map(|t| t.to_rfc3339());
318 let status = if schedule.is_empty() {
319 "completed"
320 } else {
321 "active"
322 };
323
324 if let Some(ref memory) = self.memory {
325 let db = memory.db();
326 let _ = db
327 .query("UPDATE cron_jobs SET last_run = $last, next_run = $next, status = $status, retry_count = 0, last_error = NONE, lease_until = NONE, run_token = NONE WHERE id = $id AND status = 'running' AND run_token = $run_token")
328 .bind(("last", now.to_rfc3339()))
329 .bind(("next", next_run))
330 .bind(("status", status.to_string()))
331 .bind(("id", job_id.to_string()))
332 .bind(("run_token", run_token.to_string()))
333 .await?;
334 } else {
335 let mut jobs = self.noop_jobs.lock().unwrap();
336 if let Some(job) = jobs.iter_mut().find(|job| {
337 job.id.as_deref() == Some(job_id) && job.run_token.as_deref() == Some(run_token)
338 }) {
339 job.last_run = Some(now.to_rfc3339());
340 job.next_run = next_run;
341 job.status = status.to_string();
342 job.retry_count = 0;
343 job.last_error = None;
344 job.lease_until = None;
345 job.run_token = None;
346 }
347 }
348 Ok(())
349 }
350
351 pub async fn release_run(&self, job_id: &str, run_token: &str) -> anyhow::Result<()> {
352 if let Some(ref memory) = self.memory {
353 let _ = memory
354 .db()
355 .query("UPDATE cron_jobs SET status = 'active', lease_until = NONE, run_token = NONE WHERE id = $id AND status = 'running' AND run_token = $run_token")
356 .bind(("id", job_id.to_string()))
357 .bind(("run_token", run_token.to_string()))
358 .await?;
359 } else {
360 let mut jobs = self.noop_jobs.lock().unwrap();
361 if let Some(job) = jobs.iter_mut().find(|job| {
362 job.id.as_deref() == Some(job_id) && job.run_token.as_deref() == Some(run_token)
363 }) {
364 job.status = "active".to_string();
365 job.lease_until = None;
366 job.run_token = None;
367 }
368 }
369 Ok(())
370 }
371
372 pub async fn fail_run(&self, job_id: &str, run_token: &str, error: &str) -> anyhow::Result<()> {
373 let redacted = crate::redaction::redact_text(error);
374 if let Some(ref memory) = self.memory {
375 let db = memory.db();
376 let Some(job) = self.list().await?.into_iter().find(|job| {
377 job.id.as_deref() == Some(job_id) && job.run_token.as_deref() == Some(run_token)
378 }) else {
379 return Ok(());
380 };
381 let retry_count = job.retry_count.saturating_add(1);
382 let status = if retry_count >= job.max_retries {
383 "failed"
384 } else {
385 "active"
386 };
387 let delay = 2_i64.saturating_pow(retry_count.min(10)) * 60;
388 let next_run = (chrono::Utc::now() + chrono::Duration::seconds(delay)).to_rfc3339();
389 let _ = db
390 .query("UPDATE cron_jobs SET status = $status, retry_count = $retry_count, last_error = $error, next_run = $next_run, lease_until = NONE, run_token = NONE WHERE id = $id AND status = 'running' AND run_token = $run_token")
391 .bind(("status", status.to_string()))
392 .bind(("retry_count", retry_count))
393 .bind(("error", redacted))
394 .bind(("next_run", next_run))
395 .bind(("id", job_id.to_string()))
396 .bind(("run_token", run_token.to_string()))
397 .await?;
398 } else {
399 let mut jobs = self.noop_jobs.lock().unwrap();
400 if let Some(job) = jobs.iter_mut().find(|job| {
401 job.id.as_deref() == Some(job_id) && job.run_token.as_deref() == Some(run_token)
402 }) {
403 job.retry_count = job.retry_count.saturating_add(1);
404 job.status = if job.retry_count >= job.max_retries {
405 "failed".to_string()
406 } else {
407 "active".to_string()
408 };
409 job.last_error = Some(redacted);
410 job.lease_until = None;
411 job.run_token = None;
412 }
413 }
414 Ok(())
415 }
416}
417
418#[derive(Debug, Clone)]
420pub struct DueJob {
421 pub job: CronJob,
422}
423
424pub fn start_cron_ticker(
426 scheduler: Arc<CronScheduler>,
427 channel: String,
428) -> (
429 tokio::sync::mpsc::Receiver<DueJob>,
430 Arc<tokio::sync::Notify>,
431) {
432 let (tx, rx) = tokio::sync::mpsc::channel(32);
433 let shutdown = Arc::new(tokio::sync::Notify::new());
434 let shutdown_clone = shutdown.clone();
435
436 tokio::spawn(async move {
437 let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60));
438 interval.tick().await;
440
441 loop {
442 tokio::select! {
443 _ = interval.tick() => {
444 match scheduler.claim_due_jobs(&channel).await {
445 Ok(jobs) => {
446 for job in jobs {
447 tracing::info!("Cron: job '{}' is due", job.name);
448
449 if tx.send(DueJob { job }).await.is_err() {
450 return; }
452 }
453 }
454 Err(e) => {
455 tracing::error!("Cron ticker error: {}", e);
456 }
457 }
458 }
459 _ = shutdown_clone.notified() => {
460 tracing::info!("Cron ticker: shutting down");
461 break;
462 }
463 }
464 }
465 });
466
467 (rx, shutdown)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 fn due_job(channel: &str) -> CronJob {
475 CronJob {
476 id: Some("job-1".to_string()),
477 name: "job".to_string(),
478 schedule: "0 0 0 * * * *".to_string(),
479 one_shot: false,
480 task: "task".to_string(),
481 channel: channel.to_string(),
482 chat_id: "chat".to_string(),
483 model: "model".to_string(),
484 enabled: true,
485 last_run: None,
486 next_run: Some((chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339()),
487 status: "active".to_string(),
488 retry_count: 0,
489 max_retries: 3,
490 last_error: None,
491 lease_until: None,
492 run_token: None,
493 }
494 }
495
496 #[tokio::test]
497 async fn claims_only_active_channel() {
498 let scheduler = CronScheduler::new_noop();
499 scheduler
500 .noop_jobs
501 .lock()
502 .unwrap()
503 .push(due_job("telegram"));
504 assert!(scheduler.claim_due_jobs("cli").await.unwrap().is_empty());
505 let claimed = scheduler.claim_due_jobs("telegram").await.unwrap();
506 assert_eq!(claimed.len(), 1);
507 assert!(claimed[0].run_token.is_some());
508 }
509
510 #[tokio::test]
511 async fn stale_run_token_cannot_complete_job() {
512 let scheduler = CronScheduler::new_noop();
513 scheduler.noop_jobs.lock().unwrap().push(due_job("cli"));
514 let job = scheduler.claim_due_jobs("cli").await.unwrap().remove(0);
515 scheduler
516 .mark_run("job-1", "stale-token", &job.schedule)
517 .await
518 .unwrap();
519 assert_eq!(scheduler.list().await.unwrap()[0].status, "running");
520 scheduler
521 .mark_run("job-1", job.run_token.as_deref().unwrap(), &job.schedule)
522 .await
523 .unwrap();
524 assert_eq!(scheduler.list().await.unwrap()[0].status, "active");
525 }
526}