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}