Skip to main content

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/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 (`crates/uni/src/scheduler.rs`)
414/// invokes `record_started` / `record_finished` / `cancel` on each
415/// lifecycle transition; on startup the driver calls `load_all` and
416/// re-registers persisted jobs (followed by
417/// [`Scheduler::requeue_orphaned_runs`] for any that were `Running`
418/// at the previous shutdown / crash).
419///
420/// Two impls ship in-tree:
421///
422/// - [`MemoryPersistence`] — no-op tests + as the default before the
423///   host wires a system-label backend.
424/// - `SystemLabelPersistence` (in `uni-query`, lands with the M9
425///   cutover): round-trips through `uni_system.background_jobs` via
426///   the write-enabled
427///   `QueryProcedureHost::execute_inner_query`.
428pub trait SchedulerPersistence: Send + Sync + std::fmt::Debug {
429    /// Persist a job's schedule at registration time.
430    ///
431    /// Called by the host wrapper (e.g. `SchedulerHost`) whenever a
432    /// caller invokes `add_scheduled_job`, so the schedule kind
433    /// (`Periodic` / `Cron` / `Once` / `Manual`) survives restart and
434    /// can be round-tripped through [`Self::load_all`]. The default
435    /// no-op suits in-memory backends and pre-existing impls that do
436    /// not need durability.
437    ///
438    /// # Errors
439    ///
440    /// Returns [`SchedulerPersistenceError`] on backend failure.
441    fn record_scheduled(
442        &self,
443        _id: &QName,
444        _schedule: &Schedule,
445    ) -> Result<(), SchedulerPersistenceError> {
446        Ok(())
447    }
448
449    /// Persist a job's transition into a new run.
450    ///
451    /// # Errors
452    ///
453    /// Returns [`SchedulerPersistenceError`] on backend failure.
454    fn record_started(
455        &self,
456        id: &QName,
457        started_at: SystemTime,
458    ) -> Result<(), SchedulerPersistenceError>;
459
460    /// Persist the outcome of a finished run.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`SchedulerPersistenceError`] on backend failure.
465    fn record_finished(
466        &self,
467        id: &QName,
468        finished_at: SystemTime,
469        success: bool,
470    ) -> Result<(), SchedulerPersistenceError>;
471
472    /// Persist a cancellation.
473    ///
474    /// # Errors
475    ///
476    /// Returns [`SchedulerPersistenceError`] on backend failure.
477    fn cancel(&self, id: &QName) -> Result<(), SchedulerPersistenceError>;
478
479    /// Reload all known job records (used on host startup to restore
480    /// scheduler state across restart). Order is unspecified — the
481    /// driver re-registers them in any order.
482    ///
483    /// # Errors
484    ///
485    /// Returns [`SchedulerPersistenceError`] on backend failure.
486    fn load_all(&self) -> Result<Vec<SchedulerJobRecord>, SchedulerPersistenceError>;
487
488    /// Force any in-memory buffers to durable storage.
489    ///
490    /// Invoked by `uni.periodic.commit` so operators can drive a
491    /// synchronous checkpoint flush. Backends that write through on
492    /// every event (the default for the system-label backend) leave
493    /// this as the default no-op; buffered backends override.
494    ///
495    /// # Errors
496    ///
497    /// Returns [`SchedulerPersistenceError`] on backend failure.
498    fn flush_checkpoint(&self) -> Result<(), SchedulerPersistenceError> {
499        Ok(())
500    }
501}
502
503/// In-memory [`SchedulerPersistence`] backend. Always returns an empty
504/// `load_all`; every other call is a no-op.
505///
506/// Used by tests and as the default backend when the host has not yet
507/// wired a durable backend (e.g., during early `Uni::build` before the
508/// storage manager is available).
509#[derive(Debug, Default)]
510pub struct MemoryPersistence;
511
512impl SchedulerPersistence for MemoryPersistence {
513    fn record_started(
514        &self,
515        _id: &QName,
516        _started_at: SystemTime,
517    ) -> Result<(), SchedulerPersistenceError> {
518        Ok(())
519    }
520
521    fn record_finished(
522        &self,
523        _id: &QName,
524        _finished_at: SystemTime,
525        _success: bool,
526    ) -> Result<(), SchedulerPersistenceError> {
527        Ok(())
528    }
529
530    fn cancel(&self, _id: &QName) -> Result<(), SchedulerPersistenceError> {
531        Ok(())
532    }
533
534    fn load_all(&self) -> Result<Vec<SchedulerJobRecord>, SchedulerPersistenceError> {
535        Ok(Vec::new())
536    }
537}
538
539/// Trait-object handle to a scheduler, for cross-crate callers that
540/// can't depend on the concrete host-side `SchedulerHost` type.
541///
542/// The built-in `uni.periodic.*` procedures hold an `Arc<dyn
543/// SchedulerControl>` so they can register / cancel / list jobs
544/// without depending on `uni-db`. The host crate (`uni-db`) implements
545/// this on its `SchedulerHost` and passes it down at registration
546/// time.
547pub trait SchedulerControl: Send + Sync + std::fmt::Debug {
548    /// Register a job to fire on `schedule`.
549    fn add_scheduled_job(&self, id: QName, schedule: Schedule);
550
551    /// Cancel a job by id. Returns `true` if it existed.
552    fn cancel(&self, id: &QName) -> bool;
553
554    /// Snapshot of every known job.
555    fn list(&self) -> Vec<SchedulerJobRecord>;
556
557    /// Submit an inline write-mode Cypher body for synchronous
558    /// execution. The default impl returns an error so simple
559    /// scheduler primitives (without a host) can still satisfy the
560    /// trait shape; the `uni-db::scheduler::SchedulerHost` override
561    /// dispatches through its [`crate::traits::background::JobHost`].
562    ///
563    /// Used by `uni.periodic.submit(...)` and as the inner-loop body
564    /// of `uni.periodic.iterate(...)`.
565    ///
566    /// # Errors
567    ///
568    /// Returns [`crate::FnError`] when the scheduler is not wired to a
569    /// Cypher-execution host (default impl) or when the submitted
570    /// statement fails.
571    fn submit_cypher(&self, _cypher: &str) -> Result<(), crate::FnError> {
572        Err(crate::FnError::new(
573            0xD20,
574            "scheduler: submit_cypher not supported by this control (no host wired)",
575        ))
576    }
577
578    /// Drive the persistence backend to flush its checkpoint buffer.
579    ///
580    /// Default impl is a no-op so the bare [`Scheduler`] (with
581    /// [`MemoryPersistence`]) and any control that has no durable
582    /// backend keep working without an override. The host-side
583    /// `SchedulerHost` override forwards to its
584    /// [`SchedulerPersistence::flush_checkpoint`].
585    ///
586    /// # Errors
587    ///
588    /// Returns [`crate::FnError`] when the persistence backend reports
589    /// a flush failure.
590    fn flush_checkpoint(&self) -> Result<(), crate::FnError> {
591        Ok(())
592    }
593}
594
595impl SchedulerControl for Scheduler {
596    fn add_scheduled_job(&self, id: QName, schedule: Schedule) {
597        Self::add_scheduled_job(self, id, schedule);
598    }
599
600    fn cancel(&self, id: &QName) -> bool {
601        Self::cancel(self, id)
602    }
603
604    fn list(&self) -> Vec<SchedulerJobRecord> {
605        Self::list(self)
606    }
607}
608
609/// Cooperative-cancel handle handed to job implementations.
610#[derive(Clone, Debug)]
611pub struct SchedulerHandle {
612    inner: Arc<Scheduler>,
613}
614
615impl SchedulerHandle {
616    /// Wrap a scheduler in a clonable handle.
617    #[must_use]
618    pub fn new(scheduler: Arc<Scheduler>) -> Self {
619        Self { inner: scheduler }
620    }
621
622    /// Borrow the underlying scheduler.
623    #[must_use]
624    pub fn scheduler(&self) -> &Scheduler {
625        &self.inner
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn scheduler_default_is_paused() {
635        let s = Scheduler::new();
636        assert!(s.is_paused());
637        assert!(s.list().is_empty());
638    }
639
640    #[test]
641    fn scheduler_resume_pause_round_trip() {
642        let s = Scheduler::new();
643        s.resume();
644        assert!(!s.is_paused());
645        s.pause();
646        assert!(s.is_paused());
647    }
648
649    #[test]
650    fn add_job_and_cancel() {
651        let s = Scheduler::new();
652        s.add_job(QName::builtin("ttl_sweep"));
653        assert_eq!(s.list().len(), 1);
654        assert!(s.cancel(&QName::builtin("ttl_sweep")));
655        let recs = s.list();
656        assert_eq!(recs[0].status, SchedulerJobStatus::Cancelled);
657        assert!(recs[0].cancel.is_cancelled());
658    }
659
660    #[test]
661    fn cancel_unknown_job_returns_false() {
662        let s = Scheduler::new();
663        assert!(!s.cancel(&QName::builtin("nope")));
664    }
665
666    #[test]
667    fn run_lifecycle_increments_failures_then_resets() {
668        let s = Scheduler::new();
669        let id = QName::builtin("flaky");
670        s.add_job(id.clone());
671
672        s.mark_started(&id);
673        s.mark_finished(&id, false);
674        s.mark_started(&id);
675        s.mark_finished(&id, false);
676
677        let recs = s.list();
678        assert_eq!(recs[0].consecutive_failures, 2);
679        assert_eq!(recs[0].status, SchedulerJobStatus::FailedRetrying);
680
681        s.mark_started(&id);
682        s.mark_finished(&id, true);
683
684        let recs = s.list();
685        assert_eq!(recs[0].consecutive_failures, 0);
686        assert_eq!(recs[0].status, SchedulerJobStatus::Idle);
687    }
688
689    // ── tick / driver primitive tests ──────────────────────────────
690
691    #[test]
692    fn tick_returns_empty_when_paused() {
693        let s = Scheduler::new();
694        s.add_job(QName::builtin("job1"));
695        // Scheduler defaults to paused.
696        assert!(s.tick().is_empty());
697    }
698
699    #[test]
700    fn tick_dispatches_pending_jobs_when_resumed() {
701        let s = Scheduler::new();
702        s.add_job(QName::builtin("job1"));
703        s.add_job(QName::builtin("job2"));
704        s.resume();
705        let due = s.tick();
706        assert_eq!(due.len(), 2);
707        assert!(due.iter().any(|q| q.local() == "job1"));
708        assert!(due.iter().any(|q| q.local() == "job2"));
709        // Each ticked job is now Running.
710        assert_eq!(s.running_count(), 2);
711        assert_eq!(s.pending_count(), 0);
712    }
713
714    #[test]
715    fn tick_skips_cancelled_jobs() {
716        let s = Scheduler::new();
717        s.add_job(QName::builtin("doomed"));
718        s.cancel(&QName::builtin("doomed"));
719        s.resume();
720        let due = s.tick();
721        assert!(due.is_empty(), "cancelled job should not be dispatched");
722    }
723
724    #[test]
725    fn second_tick_returns_empty_until_jobs_marked_pending() {
726        let s = Scheduler::new();
727        s.add_job(QName::builtin("once"));
728        s.resume();
729        assert_eq!(s.tick().len(), 1);
730        // Without mark_finished, the job stays Running; second tick
731        // doesn't redispatch.
732        assert!(s.tick().is_empty());
733        s.mark_finished(&QName::builtin("once"), true);
734        // Now Idle, not Pending — still won't redispatch (idempotent).
735        assert!(s.tick().is_empty());
736    }
737
738    #[test]
739    fn requeue_orphaned_runs_moves_running_back_to_pending() {
740        let s = Scheduler::new();
741        s.add_job(QName::builtin("orphan"));
742        s.resume();
743        s.tick();
744        assert_eq!(s.running_count(), 1);
745        let count = s.requeue_orphaned_runs();
746        assert_eq!(count, 1);
747        assert_eq!(s.running_count(), 0);
748        assert_eq!(s.pending_count(), 1);
749        // After requeue, next tick dispatches again.
750        assert_eq!(s.tick().len(), 1);
751    }
752
753    // ── Schedule semantics tests ────────────────────────────────
754
755    #[test]
756    fn schedule_once_fires_only_after_instant() {
757        use std::time::Duration;
758        let s = Scheduler::new();
759        s.resume();
760        let future = SystemTime::now() + Duration::from_secs(60);
761        s.add_scheduled_job(QName::builtin("once"), Schedule::Once(future));
762        let due_now = s.tick_at(SystemTime::now());
763        assert!(
764            due_now.is_empty(),
765            "Once job should not fire before its instant"
766        );
767        let due_after = s.tick_at(future + Duration::from_secs(1));
768        assert_eq!(due_after.len(), 1);
769        assert_eq!(due_after[0].local(), "once");
770    }
771
772    #[test]
773    fn schedule_once_does_not_reschedule_after_finish() {
774        use std::time::Duration;
775        let s = Scheduler::new();
776        s.resume();
777        let past = SystemTime::now() - Duration::from_secs(1);
778        s.add_scheduled_job(QName::builtin("once"), Schedule::Once(past));
779        let due = s.tick_at(SystemTime::now());
780        assert_eq!(due.len(), 1);
781        s.mark_finished(&QName::builtin("once"), true);
782        let recs = s.list();
783        assert_eq!(recs[0].status, SchedulerJobStatus::Idle);
784        assert!(recs[0].next_fire_at.is_none());
785        assert!(
786            s.tick_at(SystemTime::now() + Duration::from_secs(3600))
787                .is_empty()
788        );
789    }
790
791    #[test]
792    fn schedule_periodic_reschedules_after_finish() {
793        use std::time::Duration;
794        let s = Scheduler::new();
795        s.resume();
796        let start = SystemTime::now();
797        s.add_scheduled_job(
798            QName::builtin("ticker"),
799            Schedule::Periodic(Duration::from_secs(10)),
800        );
801        assert!(s.tick_at(start + Duration::from_secs(5)).is_empty());
802        let due = s.tick_at(start + Duration::from_secs(11));
803        assert_eq!(due.len(), 1);
804        s.mark_finished(&QName::builtin("ticker"), true);
805        let recs = s.list();
806        assert_eq!(recs[0].status, SchedulerJobStatus::Pending);
807        assert!(recs[0].next_fire_at.is_some());
808    }
809
810    #[test]
811    fn schedule_cron_emits_future_fire() {
812        use std::time::Duration;
813        let s = Scheduler::new();
814        s.resume();
815        s.add_scheduled_job(
816            QName::builtin("every_min"),
817            Schedule::Cron(smol_str::SmolStr::new("0 * * * * *")),
818        );
819        let recs = s.list();
820        let next = recs[0].next_fire_at.expect("cron must produce a next fire");
821        assert!(next > SystemTime::now() - Duration::from_secs(1));
822    }
823
824    #[test]
825    fn manual_schedule_is_immediately_due() {
826        let s = Scheduler::new();
827        s.resume();
828        s.add_scheduled_job(QName::builtin("legacy"), Schedule::Manual);
829        let due = s.tick();
830        assert_eq!(due.len(), 1);
831        assert_eq!(due[0].local(), "legacy");
832    }
833
834    #[test]
835    fn pending_count_and_running_count_track_lifecycle() {
836        let s = Scheduler::new();
837        for n in 0..5 {
838            s.add_job(QName::builtin(format!("job{n}")));
839        }
840        s.resume();
841        assert_eq!(s.pending_count(), 5);
842        assert_eq!(s.running_count(), 0);
843        let due = s.tick();
844        assert_eq!(due.len(), 5);
845        assert_eq!(s.pending_count(), 0);
846        assert_eq!(s.running_count(), 5);
847        s.mark_finished(&QName::builtin("job0"), true);
848        s.mark_finished(&QName::builtin("job1"), false);
849        assert_eq!(s.running_count(), 3, "two have finished");
850    }
851}