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-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}