uni_plugin/scheduler.rs
1// Rust guideline compliant
2
3//! Background-job scheduler skeleton.
4//!
5//! The host owns a single scheduler that drives every registered
6//! [`crate::traits::background::BackgroundJobProvider`]. This module
7//! ships the scheduler's public API + persistent state record + a
8//! `SchedulerPersistence` trait. The host-side Tokio driver
9//! (`crates/uni-plugin-host/src/scheduler.rs`) wraps a loop that calls
10//! `tick_at(SystemTime::now())`, dispatches the returned jobs through
11//! the plugin registry, and forwards lifecycle transitions to the
12//! configured persistence backend.
13
14use std::sync::Arc;
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::time::SystemTime;
17
18use parking_lot::Mutex;
19use thiserror::Error;
20
21use crate::qname::QName;
22use crate::traits::background::{CancellationToken, Schedule};
23
24/// Lifecycle state of one scheduled job.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum SchedulerJobStatus {
28 /// Registered but not yet started.
29 Pending,
30 /// Currently running.
31 Running,
32 /// Last run finished successfully.
33 Idle,
34 /// Last run failed; retry-policy applies.
35 FailedRetrying,
36 /// Cancelled by `cancel()`.
37 Cancelled,
38}
39
40/// Persistable record of a scheduled job's state.
41///
42/// Round-trips through `uni_system.background_jobs` in M11 cutover.
43#[derive(Clone, Debug)]
44pub struct SchedulerJobRecord {
45 /// Job id.
46 pub id: QName,
47 /// Lifecycle status.
48 pub status: SchedulerJobStatus,
49 /// When the next fire of this job is due. `None` for `Manual`
50 /// schedules until [`Scheduler::add_job`] marks the job `Pending`,
51 /// at which point it is eligible immediately.
52 pub next_fire_at: Option<SystemTime>,
53 /// When the most-recent run started.
54 pub last_started_at: Option<SystemTime>,
55 /// When the most-recent run finished.
56 pub last_finished_at: Option<SystemTime>,
57 /// Number of consecutive failures since the last success.
58 pub consecutive_failures: u32,
59 /// Schedule describing when fires are eligible.
60 pub schedule: Schedule,
61 /// Cancellation token; flipped on `cancel()` or shutdown.
62 pub cancel: CancellationToken,
63}
64
65impl SchedulerJobRecord {
66 /// Construct a pending record with the legacy `Manual` schedule.
67 ///
68 /// Equivalent to `pending_with_schedule(id, Schedule::Manual,
69 /// SystemTime::now())`.
70 #[must_use]
71 pub fn pending(id: QName) -> Self {
72 Self::pending_with_schedule(id, Schedule::Manual, SystemTime::now())
73 }
74
75 /// Construct a pending record with an explicit schedule.
76 ///
77 /// `now` is used both as the initial registration instant and as
78 /// the reference point for the first `next_fire_at` computation.
79 #[must_use]
80 pub fn pending_with_schedule(id: QName, schedule: Schedule, now: SystemTime) -> Self {
81 let next_fire_at = schedule.next_after(now);
82 Self {
83 id,
84 status: SchedulerJobStatus::Pending,
85 next_fire_at,
86 last_started_at: None,
87 last_finished_at: None,
88 consecutive_failures: 0,
89 schedule,
90 cancel: CancellationToken::new(),
91 }
92 }
93}
94
95/// Host-side scheduler skeleton.
96///
97/// One per Uni instance. M11 cutover wires `tokio::spawn` driving and
98/// persistence into `uni_system.background_jobs`. Currently the
99/// scheduler is paused — registered jobs are stored but not executed.
100#[derive(Debug)]
101pub struct Scheduler {
102 records: Mutex<Vec<SchedulerJobRecord>>,
103 paused: AtomicBool,
104}
105
106impl Default for Scheduler {
107 fn default() -> Self {
108 Self::new()
109 }
110}
111
112impl Scheduler {
113 /// Construct a paused scheduler.
114 #[must_use]
115 pub fn new() -> Self {
116 Self {
117 records: Mutex::new(Vec::new()),
118 paused: AtomicBool::new(true),
119 }
120 }
121
122 /// Register a new job with the legacy `Manual` schedule.
123 ///
124 /// Equivalent to `add_scheduled_job(id, Schedule::Manual)`. The
125 /// job becomes eligible immediately and fires on the next tick
126 /// (no-op while paused).
127 pub fn add_job(&self, id: QName) {
128 self.add_scheduled_job(id, Schedule::Manual);
129 }
130
131 /// Register a new job with an explicit schedule.
132 ///
133 /// The job's `next_fire_at` is computed from the schedule plus
134 /// the current `SystemTime`. The scheduler picks it up on the
135 /// first [`Self::tick`] / [`Self::tick_at`] whose `now` is at or
136 /// past `next_fire_at` (no-op while paused).
137 pub fn add_scheduled_job(&self, id: QName, schedule: Schedule) {
138 let now = SystemTime::now();
139 let record = SchedulerJobRecord::pending_with_schedule(id.clone(), schedule, now);
140 let mut records = self.records.lock();
141 // Upsert by id: re-registering the same job REPLACES its record rather
142 // than pushing a duplicate. Two records for one id would double-fire, and
143 // a single `cancel` would leave the stale copy behind.
144 if let Some(existing) = records.iter_mut().find(|r| r.id == id) {
145 *existing = record;
146 } else {
147 records.push(record);
148 }
149 }
150
151 /// Cancel a scheduled job by id.
152 ///
153 /// Returns `true` if the job was found and cancelled.
154 pub fn cancel(&self, id: &QName) -> bool {
155 let mut records = self.records.lock();
156 let Some(r) = records.iter_mut().find(|r| &r.id == id) else {
157 return false;
158 };
159 r.status = SchedulerJobStatus::Cancelled;
160 r.cancel.cancel();
161 true
162 }
163
164 /// List all known jobs and their statuses (snapshot).
165 #[must_use]
166 pub fn list(&self) -> Vec<SchedulerJobRecord> {
167 self.records.lock().clone()
168 }
169
170 /// Look up the cancellation token associated with a registered job.
171 ///
172 /// Returns `None` if no job matches `id`. The returned clone shares
173 /// state with the record's token, so callers can both await
174 /// `cancelled().await` and observe the same cancel signal trip via
175 /// [`Self::cancel`].
176 ///
177 /// Used by the host driver to wrap each dispatched
178 /// `spawn_blocking` in a `tokio::select!` against `cancelled().await`,
179 /// so shutdown / explicit cancel propagates without waiting for the
180 /// job body to poll [`CancellationToken::is_cancelled`].
181 #[must_use]
182 pub fn cancel_token_for(&self, id: &QName) -> Option<CancellationToken> {
183 self.records
184 .lock()
185 .iter()
186 .find(|r| &r.id == id)
187 .map(|r| r.cancel.clone())
188 }
189
190 /// Resume the scheduler (M11 cutover wires actual driving here).
191 pub fn resume(&self) {
192 self.paused.store(false, Ordering::SeqCst);
193 }
194
195 /// Drive the scheduler with the current wall-clock time.
196 ///
197 /// Equivalent to `tick_at(SystemTime::now())`. See [`Self::tick_at`]
198 /// for the full semantics.
199 pub fn tick(&self) -> Vec<QName> {
200 self.tick_at(SystemTime::now())
201 }
202
203 /// Pop every pending job whose schedule has fired at or before
204 /// `now`, transition each to `Running`, and return their ids for
205 /// the caller to dispatch.
206 ///
207 /// **M11 substantive driver primitive.** This is the synchronous,
208 /// runtime-free heart of the scheduler — the eventual Tokio
209 /// driver wraps a poll loop that calls `tick_at(SystemTime::now())`,
210 /// dispatches the returned jobs (e.g., via `tokio::spawn` invoking
211 /// each job's `BackgroundJobProvider::execute`), and calls
212 /// [`Scheduler::mark_finished`] when each completes.
213 ///
214 /// Schedule semantics (delegated to
215 /// [`crate::traits::background::Schedule::next_after`]):
216 ///
217 /// - A job is "due" iff `status == Pending`,
218 /// `next_fire_at.is_none()` or `next_fire_at <= now`, and the
219 /// cancel token is not already triggered.
220 /// - `Manual` jobs have `next_fire_at = now` at registration and
221 /// so are immediately due (matching legacy `tick()` behavior).
222 /// - `Once(at)` jobs become due only when `now >= at`.
223 /// - `Periodic(every)` jobs become due `every` after each fire.
224 /// - `Cron(expr)` jobs become due at the next cron instant
225 /// computed via the [`cron`] crate.
226 ///
227 /// Honors pause: returns empty when [`Self::is_paused`].
228 /// Honors cancellation: skips jobs whose `cancel` token is
229 /// already triggered (filtering them out of the return).
230 pub fn tick_at(&self, now: SystemTime) -> Vec<QName> {
231 if self.is_paused() {
232 return Vec::new();
233 }
234 let mut records = self.records.lock();
235 let mut due: Vec<QName> = Vec::new();
236 for r in records.iter_mut() {
237 if !matches!(r.status, SchedulerJobStatus::Pending) {
238 continue;
239 }
240 if r.cancel.is_cancelled() {
241 r.status = SchedulerJobStatus::Cancelled;
242 continue;
243 }
244 // Time-gate.
245 match r.next_fire_at {
246 Some(fire_at) if fire_at > now => continue,
247 Some(_) => {}
248 None => {
249 // No computed fire time. For a `Cron` this means the
250 // expression FAILED TO PARSE (`next_after` logged + returned
251 // None) — the job must NEVER fire, so skip it rather than
252 // falling through and dispatching it once. For an overdue
253 // `Once`/`Manual` (whose single instant is already past)
254 // `next_after` also returns None, but that job SHOULD fire once
255 // — a finished one is later gated out by its non-`Pending`
256 // status, not by `next_fire_at`. So only a fire-time-less Cron
257 // is skipped.
258 if matches!(r.schedule, Schedule::Cron(_)) {
259 continue;
260 }
261 }
262 }
263 r.status = SchedulerJobStatus::Running;
264 r.last_started_at = Some(now);
265 due.push(r.id.clone());
266 }
267 due
268 }
269
270 /// Number of jobs currently in `Running` state. Useful for
271 /// observability (e.g., a metrics gauge).
272 #[must_use]
273 pub fn running_count(&self) -> usize {
274 self.records
275 .lock()
276 .iter()
277 .filter(|r| matches!(r.status, SchedulerJobStatus::Running))
278 .count()
279 }
280
281 /// Number of pending jobs ready for the next `tick`.
282 #[must_use]
283 pub fn pending_count(&self) -> usize {
284 self.records
285 .lock()
286 .iter()
287 .filter(|r| matches!(r.status, SchedulerJobStatus::Pending))
288 .count()
289 }
290
291 /// Reset every `Running` job back to `Pending` — used by the
292 /// driver to recover from a crash where jobs were started but not
293 /// finished. The host restores the scheduler state from
294 /// `uni_system.background_jobs` and calls this to make all
295 /// previously-`Running` jobs eligible for re-dispatch.
296 pub fn requeue_orphaned_runs(&self) -> usize {
297 let mut records = self.records.lock();
298 let mut count = 0;
299 for r in records.iter_mut() {
300 if matches!(r.status, SchedulerJobStatus::Running) {
301 r.status = SchedulerJobStatus::Pending;
302 count += 1;
303 }
304 }
305 count
306 }
307
308 /// Pause the scheduler.
309 pub fn pause(&self) {
310 self.paused.store(true, Ordering::SeqCst);
311 }
312
313 /// Returns `true` if currently paused.
314 #[must_use]
315 pub fn is_paused(&self) -> bool {
316 self.paused.load(Ordering::SeqCst)
317 }
318
319 /// Mark a job as starting a new run.
320 ///
321 /// Used by tests + the M11 cutover driver. Updates the record's
322 /// `status` to `Running` and stamps `last_started_at`.
323 pub fn mark_started(&self, id: &QName) {
324 let mut records = self.records.lock();
325 if let Some(r) = records.iter_mut().find(|r| &r.id == id) {
326 r.status = SchedulerJobStatus::Running;
327 r.last_started_at = Some(SystemTime::now());
328 }
329 }
330
331 /// Mark a job's run as finished (success or failure).
332 ///
333 /// Recomputes `next_fire_at` from the job's [`Schedule`] using
334 /// `SystemTime::now()` as the reference point. If the schedule has
335 /// another fire upcoming (Periodic, Cron, or a Once whose instant
336 /// is still in the future — which shouldn't normally happen after
337 /// it has just fired), the job transitions back to `Pending` so
338 /// the next [`Self::tick_at`] can pick it up. Otherwise the job
339 /// stays in its terminal state (`Idle` on success,
340 /// `FailedRetrying` on failure).
341 pub fn mark_finished(&self, id: &QName, success: bool) {
342 let now = SystemTime::now();
343 let mut records = self.records.lock();
344 let Some(r) = records.iter_mut().find(|r| &r.id == id) else {
345 return;
346 };
347 r.last_finished_at = Some(now);
348
349 // Only Periodic / Cron reschedule; Once / Manual terminate after a run.
350 let next = r.schedule.next_after(now);
351 let has_next =
352 matches!(r.schedule, Schedule::Periodic(_) | Schedule::Cron(_)) && next.is_some();
353
354 // Periodic / Cron jobs keep firing on schedule even after a
355 // failed run; the `consecutive_failures` counter and the
356 // [`crate::circuit_breaker::CircuitBreaker`] decide when to
357 // stop dispatching a flapping job. A `Once` job that failed
358 // stays in `FailedRetrying` since `has_next` is false.
359 if has_next {
360 r.status = SchedulerJobStatus::Pending;
361 r.next_fire_at = next;
362 } else {
363 r.status = if success {
364 SchedulerJobStatus::Idle
365 } else {
366 SchedulerJobStatus::FailedRetrying
367 };
368 if success {
369 r.next_fire_at = None;
370 }
371 }
372
373 if success {
374 r.consecutive_failures = 0;
375 } else {
376 r.consecutive_failures = r.consecutive_failures.saturating_add(1);
377 }
378 }
379}
380
381impl PartialEq for SchedulerJobRecord {
382 /// Two records are equal iff all their persisted state matches.
383 ///
384 /// Previously this compared only `id` and `status`, so two records
385 /// that differed in `schedule`, `next_fire_at`, or
386 /// `consecutive_failures` (i.e. genuinely-different job states)
387 /// would still compare equal. The `cancel` field is intentionally
388 /// excluded because it is per-process identity (an `Arc<AtomicBool>`)
389 /// and is not part of the persisted record.
390 fn eq(&self, other: &Self) -> bool {
391 self.id == other.id
392 && self.status == other.status
393 && self.next_fire_at == other.next_fire_at
394 && self.last_started_at == other.last_started_at
395 && self.last_finished_at == other.last_finished_at
396 && self.consecutive_failures == other.consecutive_failures
397 && self.schedule == other.schedule
398 }
399}
400
401/// Errors raised by [`SchedulerPersistence`] backends.
402#[derive(Debug, Error)]
403#[non_exhaustive]
404pub enum SchedulerPersistenceError {
405 /// Backend-specific failure (I/O, Cypher execution, serialization).
406 #[error("scheduler persistence: {0}")]
407 Backend(String),
408}
409
410/// Persistence backend for [`Scheduler`] job state.
411///
412/// Mirrors the meta-plugin's `Persistence` trait in shape but scoped
413/// to scheduler records. The Tokio driver
414/// (`crates/uni-plugin-host/src/scheduler.rs`)
415/// invokes `record_started` / `record_finished` / `cancel` on each
416/// lifecycle transition; on startup the driver calls `load_all` and
417/// re-registers persisted jobs (followed by
418/// [`Scheduler::requeue_orphaned_runs`] for any that were `Running`
419/// at the previous shutdown / crash).
420///
421/// Two impls ship in-tree:
422///
423/// - [`MemoryPersistence`] — no-op tests + as the default before the
424/// host wires a system-label backend.
425/// - `SystemLabelPersistence` (in `uni-query`, lands with the M9
426/// cutover): round-trips through `uni_system.background_jobs` via
427/// the write-enabled
428/// `QueryProcedureHost::execute_inner_query`.
429pub trait SchedulerPersistence: Send + Sync + std::fmt::Debug {
430 /// Persist a job's schedule at registration time.
431 ///
432 /// Called by the host wrapper (e.g. `SchedulerHost`) whenever a
433 /// caller invokes `add_scheduled_job`, so the schedule kind
434 /// (`Periodic` / `Cron` / `Once` / `Manual`) survives restart and
435 /// can be round-tripped through [`Self::load_all`]. The default
436 /// no-op suits in-memory backends and pre-existing impls that do
437 /// not need durability.
438 ///
439 /// # Errors
440 ///
441 /// Returns [`SchedulerPersistenceError`] on backend failure.
442 fn record_scheduled(
443 &self,
444 _id: &QName,
445 _schedule: &Schedule,
446 ) -> Result<(), SchedulerPersistenceError> {
447 Ok(())
448 }
449
450 /// Persist a job's transition into a new run.
451 ///
452 /// # Errors
453 ///
454 /// Returns [`SchedulerPersistenceError`] on backend failure.
455 fn record_started(
456 &self,
457 id: &QName,
458 started_at: SystemTime,
459 ) -> Result<(), SchedulerPersistenceError>;
460
461 /// Persist the outcome of a finished run.
462 ///
463 /// # Errors
464 ///
465 /// Returns [`SchedulerPersistenceError`] on backend failure.
466 fn record_finished(
467 &self,
468 id: &QName,
469 finished_at: SystemTime,
470 success: bool,
471 ) -> Result<(), SchedulerPersistenceError>;
472
473 /// Persist a cancellation.
474 ///
475 /// # Errors
476 ///
477 /// Returns [`SchedulerPersistenceError`] on backend failure.
478 fn cancel(&self, id: &QName) -> Result<(), SchedulerPersistenceError>;
479
480 /// Reload all known job records (used on host startup to restore
481 /// scheduler state across restart). Order is unspecified — the
482 /// driver re-registers them in any order.
483 ///
484 /// # Errors
485 ///
486 /// Returns [`SchedulerPersistenceError`] on backend failure.
487 fn load_all(&self) -> Result<Vec<SchedulerJobRecord>, SchedulerPersistenceError>;
488
489 /// Force any in-memory buffers to durable storage.
490 ///
491 /// Invoked by `uni.periodic.commit` so operators can drive a
492 /// synchronous checkpoint flush. Backends that write through on
493 /// every event (the default for the system-label backend) leave
494 /// this as the default no-op; buffered backends override.
495 ///
496 /// # Errors
497 ///
498 /// Returns [`SchedulerPersistenceError`] on backend failure.
499 fn flush_checkpoint(&self) -> Result<(), SchedulerPersistenceError> {
500 Ok(())
501 }
502}
503
504/// In-memory [`SchedulerPersistence`] backend. Always returns an empty
505/// `load_all`; every other call is a no-op.
506///
507/// Used by tests and as the default backend when the host has not yet
508/// wired a durable backend (e.g., during early `Uni::build` before the
509/// storage manager is available).
510#[derive(Debug, Default)]
511pub struct MemoryPersistence;
512
513impl SchedulerPersistence for MemoryPersistence {
514 fn record_started(
515 &self,
516 _id: &QName,
517 _started_at: SystemTime,
518 ) -> Result<(), SchedulerPersistenceError> {
519 Ok(())
520 }
521
522 fn record_finished(
523 &self,
524 _id: &QName,
525 _finished_at: SystemTime,
526 _success: bool,
527 ) -> Result<(), SchedulerPersistenceError> {
528 Ok(())
529 }
530
531 fn cancel(&self, _id: &QName) -> Result<(), SchedulerPersistenceError> {
532 Ok(())
533 }
534
535 fn load_all(&self) -> Result<Vec<SchedulerJobRecord>, SchedulerPersistenceError> {
536 Ok(Vec::new())
537 }
538}
539
540/// Trait-object handle to a scheduler, for cross-crate callers that
541/// can't depend on the concrete host-side `SchedulerHost` type.
542///
543/// The built-in `uni.periodic.*` procedures hold an `Arc<dyn
544/// SchedulerControl>` so they can register / cancel / list jobs
545/// without depending on `uni-db`. The host crate (`uni-db`) implements
546/// this on its `SchedulerHost` and passes it down at registration
547/// time.
548pub trait SchedulerControl: Send + Sync + std::fmt::Debug {
549 /// Register a job to fire on `schedule`.
550 fn add_scheduled_job(&self, id: QName, schedule: Schedule);
551
552 /// Cancel a job by id. Returns `true` if it existed.
553 fn cancel(&self, id: &QName) -> bool;
554
555 /// Snapshot of every known job.
556 fn list(&self) -> Vec<SchedulerJobRecord>;
557
558 /// Submit an inline write-mode Cypher body for synchronous
559 /// execution. The default impl returns an error so simple
560 /// scheduler primitives (without a host) can still satisfy the
561 /// trait shape; the `uni-db::scheduler::SchedulerHost` override
562 /// dispatches through its [`crate::traits::background::JobHost`].
563 ///
564 /// Used by `uni.periodic.submit(...)` and as the inner-loop body
565 /// of `uni.periodic.iterate(...)`.
566 ///
567 /// # Errors
568 ///
569 /// Returns [`crate::FnError`] when the scheduler is not wired to a
570 /// Cypher-execution host (default impl) or when the submitted
571 /// statement fails.
572 fn submit_cypher(&self, _cypher: &str) -> Result<(), crate::FnError> {
573 Err(crate::FnError::new(
574 0xD20,
575 "scheduler: submit_cypher not supported by this control (no host wired)",
576 ))
577 }
578
579 /// Drive the persistence backend to flush its checkpoint buffer.
580 ///
581 /// Default impl is a no-op so the bare [`Scheduler`] (with
582 /// [`MemoryPersistence`]) and any control that has no durable
583 /// backend keep working without an override. The host-side
584 /// `SchedulerHost` override forwards to its
585 /// [`SchedulerPersistence::flush_checkpoint`].
586 ///
587 /// # Errors
588 ///
589 /// Returns [`crate::FnError`] when the persistence backend reports
590 /// a flush failure.
591 fn flush_checkpoint(&self) -> Result<(), crate::FnError> {
592 Ok(())
593 }
594}
595
596impl SchedulerControl for Scheduler {
597 fn add_scheduled_job(&self, id: QName, schedule: Schedule) {
598 Self::add_scheduled_job(self, id, schedule);
599 }
600
601 fn cancel(&self, id: &QName) -> bool {
602 Self::cancel(self, id)
603 }
604
605 fn list(&self) -> Vec<SchedulerJobRecord> {
606 Self::list(self)
607 }
608}
609
610/// Cooperative-cancel handle handed to job implementations.
611#[derive(Clone, Debug)]
612pub struct SchedulerHandle {
613 inner: Arc<Scheduler>,
614}
615
616impl SchedulerHandle {
617 /// Wrap a scheduler in a clonable handle.
618 #[must_use]
619 pub fn new(scheduler: Arc<Scheduler>) -> Self {
620 Self { inner: scheduler }
621 }
622
623 /// Borrow the underlying scheduler.
624 #[must_use]
625 pub fn scheduler(&self) -> &Scheduler {
626 &self.inner
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn scheduler_default_is_paused() {
636 let s = Scheduler::new();
637 assert!(s.is_paused());
638 assert!(s.list().is_empty());
639 }
640
641 #[test]
642 fn scheduler_resume_pause_round_trip() {
643 let s = Scheduler::new();
644 s.resume();
645 assert!(!s.is_paused());
646 s.pause();
647 assert!(s.is_paused());
648 }
649
650 #[test]
651 fn add_job_and_cancel() {
652 let s = Scheduler::new();
653 s.add_job(QName::builtin("ttl_sweep"));
654 assert_eq!(s.list().len(), 1);
655 assert!(s.cancel(&QName::builtin("ttl_sweep")));
656 let recs = s.list();
657 assert_eq!(recs[0].status, SchedulerJobStatus::Cancelled);
658 assert!(recs[0].cancel.is_cancelled());
659 }
660
661 #[test]
662 fn cancel_unknown_job_returns_false() {
663 let s = Scheduler::new();
664 assert!(!s.cancel(&QName::builtin("nope")));
665 }
666
667 #[test]
668 fn run_lifecycle_increments_failures_then_resets() {
669 let s = Scheduler::new();
670 let id = QName::builtin("flaky");
671 s.add_job(id.clone());
672
673 s.mark_started(&id);
674 s.mark_finished(&id, false);
675 s.mark_started(&id);
676 s.mark_finished(&id, false);
677
678 let recs = s.list();
679 assert_eq!(recs[0].consecutive_failures, 2);
680 assert_eq!(recs[0].status, SchedulerJobStatus::FailedRetrying);
681
682 s.mark_started(&id);
683 s.mark_finished(&id, true);
684
685 let recs = s.list();
686 assert_eq!(recs[0].consecutive_failures, 0);
687 assert_eq!(recs[0].status, SchedulerJobStatus::Idle);
688 }
689
690 // ── tick / driver primitive tests ──────────────────────────────
691
692 #[test]
693 fn tick_returns_empty_when_paused() {
694 let s = Scheduler::new();
695 s.add_job(QName::builtin("job1"));
696 // Scheduler defaults to paused.
697 assert!(s.tick().is_empty());
698 }
699
700 #[test]
701 fn tick_dispatches_pending_jobs_when_resumed() {
702 let s = Scheduler::new();
703 s.add_job(QName::builtin("job1"));
704 s.add_job(QName::builtin("job2"));
705 s.resume();
706 let due = s.tick();
707 assert_eq!(due.len(), 2);
708 assert!(due.iter().any(|q| q.local() == "job1"));
709 assert!(due.iter().any(|q| q.local() == "job2"));
710 // Each ticked job is now Running.
711 assert_eq!(s.running_count(), 2);
712 assert_eq!(s.pending_count(), 0);
713 }
714
715 #[test]
716 fn tick_skips_cancelled_jobs() {
717 let s = Scheduler::new();
718 s.add_job(QName::builtin("doomed"));
719 s.cancel(&QName::builtin("doomed"));
720 s.resume();
721 let due = s.tick();
722 assert!(due.is_empty(), "cancelled job should not be dispatched");
723 }
724
725 #[test]
726 fn second_tick_returns_empty_until_jobs_marked_pending() {
727 let s = Scheduler::new();
728 s.add_job(QName::builtin("once"));
729 s.resume();
730 assert_eq!(s.tick().len(), 1);
731 // Without mark_finished, the job stays Running; second tick
732 // doesn't redispatch.
733 assert!(s.tick().is_empty());
734 s.mark_finished(&QName::builtin("once"), true);
735 // Now Idle, not Pending — still won't redispatch (idempotent).
736 assert!(s.tick().is_empty());
737 }
738
739 #[test]
740 fn requeue_orphaned_runs_moves_running_back_to_pending() {
741 let s = Scheduler::new();
742 s.add_job(QName::builtin("orphan"));
743 s.resume();
744 s.tick();
745 assert_eq!(s.running_count(), 1);
746 let count = s.requeue_orphaned_runs();
747 assert_eq!(count, 1);
748 assert_eq!(s.running_count(), 0);
749 assert_eq!(s.pending_count(), 1);
750 // After requeue, next tick dispatches again.
751 assert_eq!(s.tick().len(), 1);
752 }
753
754 // ── Schedule semantics tests ────────────────────────────────
755
756 #[test]
757 fn schedule_once_fires_only_after_instant() {
758 use std::time::Duration;
759 let s = Scheduler::new();
760 s.resume();
761 let future = SystemTime::now() + Duration::from_secs(60);
762 s.add_scheduled_job(QName::builtin("once"), Schedule::Once(future));
763 let due_now = s.tick_at(SystemTime::now());
764 assert!(
765 due_now.is_empty(),
766 "Once job should not fire before its instant"
767 );
768 let due_after = s.tick_at(future + Duration::from_secs(1));
769 assert_eq!(due_after.len(), 1);
770 assert_eq!(due_after[0].local(), "once");
771 }
772
773 #[test]
774 fn schedule_once_does_not_reschedule_after_finish() {
775 use std::time::Duration;
776 let s = Scheduler::new();
777 s.resume();
778 let past = SystemTime::now() - Duration::from_secs(1);
779 s.add_scheduled_job(QName::builtin("once"), Schedule::Once(past));
780 let due = s.tick_at(SystemTime::now());
781 assert_eq!(due.len(), 1);
782 s.mark_finished(&QName::builtin("once"), true);
783 let recs = s.list();
784 assert_eq!(recs[0].status, SchedulerJobStatus::Idle);
785 assert!(recs[0].next_fire_at.is_none());
786 assert!(
787 s.tick_at(SystemTime::now() + Duration::from_secs(3600))
788 .is_empty()
789 );
790 }
791
792 #[test]
793 fn schedule_periodic_reschedules_after_finish() {
794 use std::time::Duration;
795 let s = Scheduler::new();
796 s.resume();
797 let start = SystemTime::now();
798 s.add_scheduled_job(
799 QName::builtin("ticker"),
800 Schedule::Periodic(Duration::from_secs(10)),
801 );
802 assert!(s.tick_at(start + Duration::from_secs(5)).is_empty());
803 let due = s.tick_at(start + Duration::from_secs(11));
804 assert_eq!(due.len(), 1);
805 s.mark_finished(&QName::builtin("ticker"), true);
806 let recs = s.list();
807 assert_eq!(recs[0].status, SchedulerJobStatus::Pending);
808 assert!(recs[0].next_fire_at.is_some());
809 }
810
811 #[test]
812 fn schedule_cron_emits_future_fire() {
813 use std::time::Duration;
814 let s = Scheduler::new();
815 s.resume();
816 s.add_scheduled_job(
817 QName::builtin("every_min"),
818 Schedule::Cron(smol_str::SmolStr::new("0 * * * * *")),
819 );
820 let recs = s.list();
821 let next = recs[0].next_fire_at.expect("cron must produce a next fire");
822 assert!(next > SystemTime::now() - Duration::from_secs(1));
823 }
824
825 #[test]
826 fn manual_schedule_is_immediately_due() {
827 let s = Scheduler::new();
828 s.resume();
829 s.add_scheduled_job(QName::builtin("legacy"), Schedule::Manual);
830 let due = s.tick();
831 assert_eq!(due.len(), 1);
832 assert_eq!(due[0].local(), "legacy");
833 }
834
835 #[test]
836 fn pending_count_and_running_count_track_lifecycle() {
837 let s = Scheduler::new();
838 for n in 0..5 {
839 s.add_job(QName::builtin(format!("job{n}")));
840 }
841 s.resume();
842 assert_eq!(s.pending_count(), 5);
843 assert_eq!(s.running_count(), 0);
844 let due = s.tick();
845 assert_eq!(due.len(), 5);
846 assert_eq!(s.pending_count(), 0);
847 assert_eq!(s.running_count(), 5);
848 s.mark_finished(&QName::builtin("job0"), true);
849 s.mark_finished(&QName::builtin("job1"), false);
850 assert_eq!(s.running_count(), 3, "two have finished");
851 }
852}