feat(job-registry): handle job without periodicity but with trigger
This commit is contained in:
@@ -26,7 +26,11 @@ use super::types::JobOutcome;
|
||||
/// `await` without pinning the registry's outer lock.
|
||||
pub struct JobEntry {
|
||||
pub(super) handler: Arc<dyn JobHandler>,
|
||||
pub(super) interval: Duration,
|
||||
/// `None` = on-demand only; the supervisor never fires this job
|
||||
/// (`pick_next` skips it). Admin/programmatic callers reach it
|
||||
/// via [`JobRegistry::trigger`].
|
||||
/// `Some(dur)` = periodic; supervisor dispatches every `dur`.
|
||||
pub(super) interval: Option<Duration>,
|
||||
pub(super) timeout: Option<Duration>,
|
||||
/// Single-permit gate enforcing the "one in-flight run per
|
||||
/// `job_name`" invariant. A tick that finds the permit taken
|
||||
@@ -45,10 +49,10 @@ pub(super) struct JobState {
|
||||
/// Wall-clock time + outcome of the most recent completed run.
|
||||
/// `None` until the first run finishes.
|
||||
pub last_outcome: Option<(DateTime<Utc>, JobOutcome)>,
|
||||
/// Wall-clock time of the next scheduled dispatch. Advanced by
|
||||
/// one interval after every tick — both successful dispatch and
|
||||
/// skipped (in-flight) tick.
|
||||
pub next_run_at: DateTime<Utc>,
|
||||
/// Wall-clock time of the next scheduled dispatch. `None` for
|
||||
/// on-demand jobs (never fires periodically); `Some(...)` for
|
||||
/// scheduled jobs, advanced by one interval after every tick.
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// In-memory job registry. `Arc<JobRegistry>` lives on `AppState`;
|
||||
@@ -69,14 +73,19 @@ impl JobRegistry {
|
||||
/// everywhere (logs, admin URLs, env vars) and collisions would
|
||||
/// hide bugs.
|
||||
///
|
||||
/// `first_run_at` = `Utc::now() + interval` by convention —
|
||||
/// registration doesn't fire the job immediately. Callers that
|
||||
/// want an at-startup run should invoke the service's own
|
||||
/// initialiser once before registering.
|
||||
/// - `interval = Some(dur)` → **scheduled**. The supervisor fires
|
||||
/// the job every `dur`, starting `now + dur`. Registration does
|
||||
/// NOT fire the job immediately — callers that want an at-startup
|
||||
/// run should invoke the service's own initialiser once before
|
||||
/// registering.
|
||||
/// - `interval = None` → **on-demand only**. The supervisor never
|
||||
/// fires this job. Admin endpoint (or programmatic callers) can
|
||||
/// still invoke it via [`JobRegistry::trigger`] — the dispatch
|
||||
/// goes through the same panic/timeout/exclusivity gates.
|
||||
pub async fn register(
|
||||
&self,
|
||||
handler: Arc<dyn JobHandler>,
|
||||
interval: Duration,
|
||||
interval: Option<Duration>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<(), RegisterError> {
|
||||
let name = handler.name().to_string();
|
||||
@@ -84,9 +93,11 @@ impl JobRegistry {
|
||||
if guard.contains_key(&name) {
|
||||
return Err(RegisterError::DuplicateName(name));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let next_run_at = now
|
||||
+ chrono::Duration::from_std(interval).unwrap_or_else(|_| chrono::Duration::seconds(0));
|
||||
let next_run_at = interval.map(|dur| {
|
||||
Utc::now()
|
||||
+ chrono::Duration::from_std(dur)
|
||||
.unwrap_or_else(|_| chrono::Duration::seconds(0))
|
||||
});
|
||||
let entry = Arc::new(JobEntry {
|
||||
handler,
|
||||
interval,
|
||||
@@ -102,18 +113,24 @@ impl JobRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the name and next-due timestamp of the job that fires
|
||||
/// soonest, or `None` if no jobs are registered. Read-lock only —
|
||||
/// safe to call frequently from the supervisor loop.
|
||||
/// Return the name and next-due timestamp of the earliest-firing
|
||||
/// **scheduled** job, or `None` if no scheduled jobs are registered.
|
||||
/// On-demand jobs (registered with `interval = None`) are invisible
|
||||
/// to `pick_next` — they only run when reached via
|
||||
/// [`Self::trigger`]. Read-lock only — safe to call frequently from
|
||||
/// the supervisor loop.
|
||||
pub async fn pick_next(&self) -> Option<(String, DateTime<Utc>)> {
|
||||
let guard = self.entries.read().await;
|
||||
let mut earliest: Option<(String, DateTime<Utc>)> = None;
|
||||
for (name, entry) in guard.iter() {
|
||||
let next_at = entry
|
||||
let Some(next_at) = entry
|
||||
.state
|
||||
.lock()
|
||||
.expect("JobState mutex poisoned")
|
||||
.next_run_at;
|
||||
.next_run_at
|
||||
else {
|
||||
continue; // on-demand only — never picked
|
||||
};
|
||||
match &earliest {
|
||||
None => earliest = Some((name.clone(), next_at)),
|
||||
Some((_, current)) if next_at < *current => {
|
||||
@@ -151,6 +168,31 @@ impl JobRegistry {
|
||||
pub async fn is_empty(&self) -> bool {
|
||||
self.entries.read().await.is_empty()
|
||||
}
|
||||
|
||||
/// Manual dispatch — the single entry point for running a
|
||||
/// registered job outside the scheduler's tick loop. Called by:
|
||||
///
|
||||
/// - The admin endpoint `POST /api/admin/internal/trigger-job/{name}`.
|
||||
/// - Any service that wants a scheduler-uniform dispatch of a
|
||||
/// peer job (uniform log line, exclusivity, panic containment,
|
||||
/// timeout enforcement).
|
||||
///
|
||||
/// Returns `None` when the name isn't registered. Returns
|
||||
/// `Some(JobOutcome)` when it is — including the case where
|
||||
/// exclusivity denied the trigger (previous run still in flight),
|
||||
/// which surfaces as
|
||||
/// `Ok { count: 0, extra: { "skipped": "already_running" } }` per
|
||||
/// the engine's dispatch protocol.
|
||||
///
|
||||
/// Works for BOTH scheduled and on-demand jobs — for on-demand
|
||||
/// jobs this is the only way they ever run.
|
||||
pub async fn trigger(
|
||||
self: &Arc<Self>,
|
||||
name: &str,
|
||||
) -> Option<JobOutcome> {
|
||||
let entry = self.get(name).await?;
|
||||
Some(super::engine::dispatch(name, entry).await)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JobRegistry {
|
||||
@@ -193,10 +235,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn register_and_pick_next() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("job_a"), Duration::from_secs(60), None)
|
||||
reg.register(handler("job_a"), Some(Duration::from_secs(60)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
reg.register(handler("job_b"), Duration::from_secs(10), None)
|
||||
reg.register(handler("job_b"), Some(Duration::from_secs(10)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -208,11 +250,11 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn duplicate_registration_rejected() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("job_x"), Duration::from_secs(60), None)
|
||||
reg.register(handler("job_x"), Some(Duration::from_secs(60)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = reg
|
||||
.register(handler("job_x"), Duration::from_secs(60), None)
|
||||
.register(handler("job_x"), Some(Duration::from_secs(60)), None)
|
||||
.await
|
||||
.expect_err("duplicate name must be rejected");
|
||||
assert!(matches!(err, RegisterError::DuplicateName(_)));
|
||||
@@ -227,13 +269,45 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn snapshot_all_returns_every_entry() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("a"), Duration::from_secs(1), None)
|
||||
reg.register(handler("a"), Some(Duration::from_secs(1)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
reg.register(handler("b"), Duration::from_secs(1), None)
|
||||
reg.register(handler("b"), Some(Duration::from_secs(1)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let all = reg.snapshot_all().await;
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_demand_job_invisible_to_pick_next() {
|
||||
let reg = JobRegistry::new();
|
||||
// Scheduled job with a long interval.
|
||||
reg.register(handler("scheduled"), Some(Duration::from_secs(3600)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
// On-demand job — supervisor must never pick it.
|
||||
reg.register(handler("on_demand"), None, None).await.unwrap();
|
||||
|
||||
let (next_name, _) = reg.pick_next().await.expect("scheduled job due");
|
||||
assert_eq!(
|
||||
next_name, "scheduled",
|
||||
"pick_next must ignore on-demand jobs"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_dispatches_on_demand_job() {
|
||||
let reg = Arc::new(JobRegistry::new());
|
||||
reg.register(handler("gc"), None, None).await.unwrap();
|
||||
|
||||
let outcome = reg.trigger("gc").await.expect("job exists");
|
||||
assert!(outcome.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_returns_none_for_unknown_job() {
|
||||
let reg = Arc::new(JobRegistry::new());
|
||||
assert!(reg.trigger("nope").await.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user