refactor(job-registry): simplify the job registering*
This commit is contained in:
@@ -264,6 +264,10 @@ fn translate_join(
|
||||
/// Distinct Ok/Err branches so the tracing macros pick up the fields at
|
||||
/// compile time — `tracing` doesn't expand conditional field lists.
|
||||
fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapsed_ms: u128) {
|
||||
// Also render elapsed inline in the human-readable message so
|
||||
// `tail -f` operators see the duration without waiting on a
|
||||
// structured log renderer to project the `elapsed_ms` field.
|
||||
let elapsed = format_elapsed(elapsed_ms);
|
||||
match outcome {
|
||||
JobOutcome::Ok { count, extra } => {
|
||||
tracing::info!(
|
||||
@@ -274,8 +278,10 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapse
|
||||
count = *count,
|
||||
elapsed_ms = elapsed_ms,
|
||||
extra = %extra,
|
||||
"job {} ran",
|
||||
"job {} ran in {} — count={}",
|
||||
name,
|
||||
elapsed,
|
||||
count,
|
||||
);
|
||||
}
|
||||
JobOutcome::Err { message: msg } => {
|
||||
@@ -287,13 +293,31 @@ fn log_outcome(name: &str, outcome: &JobOutcome, cause: Option<ErrCause>, elapse
|
||||
cause = %cause.unwrap_or(ErrCause::Handler),
|
||||
elapsed_ms = elapsed_ms,
|
||||
error = %msg,
|
||||
"job {} failed",
|
||||
"job {} failed after {} — {}",
|
||||
name,
|
||||
elapsed,
|
||||
msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-friendly elapsed rendering — `12ms` / `340ms` / `1.4s` /
|
||||
/// `12.3s` / `4m30s`. The structured `elapsed_ms` field still carries
|
||||
/// the raw millisecond number for log aggregators.
|
||||
fn format_elapsed(ms: u128) -> String {
|
||||
if ms < 1000 {
|
||||
format!("{}ms", ms)
|
||||
} else if ms < 60_000 {
|
||||
format!("{:.1}s", (ms as f64) / 1000.0)
|
||||
} else {
|
||||
let secs = ms / 1000;
|
||||
let m = secs / 60;
|
||||
let s = secs % 60;
|
||||
format!("{}m{}s", m, s)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -361,8 +385,7 @@ mod tests {
|
||||
let registry = Arc::new(JobRegistry::new());
|
||||
registry
|
||||
.register(handler, Some(Duration::from_millis(100)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
let entry = registry.get("overrun").await.unwrap();
|
||||
|
||||
// Kick off dispatch 1 in the background — it holds the permit
|
||||
@@ -402,8 +425,7 @@ mod tests {
|
||||
Some(Duration::from_millis(100)),
|
||||
Some(Duration::from_millis(50)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
let entry = registry.get("slow").await.unwrap();
|
||||
|
||||
dispatch("slow", entry.clone(), &JobRunArgs::default()).await;
|
||||
|
||||
@@ -69,10 +69,7 @@ impl JobRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a job. Returns an error if a job with the same name
|
||||
/// is already registered — names are the primary identifier
|
||||
/// everywhere (logs, admin URLs, env vars) and collisions would
|
||||
/// hide bugs.
|
||||
/// Register a job — production wiring path.
|
||||
///
|
||||
/// - `interval = Some(dur)` → **scheduled**. The supervisor fires
|
||||
/// the job every `dur`, starting `now + dur`. Registration does
|
||||
@@ -83,11 +80,61 @@ impl JobRegistry {
|
||||
/// 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.
|
||||
///
|
||||
/// **Panics on error.** Registration failure (duplicate name today)
|
||||
/// is a DI-wiring bug — the server must not start with a mis-wired
|
||||
/// scheduler. Emits a uniform `job.registered` log line on success
|
||||
/// so callers don't reinvent the log message at every site.
|
||||
///
|
||||
/// For unit tests that need to assert the error path use
|
||||
/// [`Self::try_register`] instead.
|
||||
pub async fn register(
|
||||
&self,
|
||||
handler: Arc<dyn JobHandler>,
|
||||
interval: Option<Duration>,
|
||||
timeout: Option<Duration>,
|
||||
) {
|
||||
let name = handler.name().to_string();
|
||||
match self.try_register(handler, interval, timeout).await {
|
||||
Ok(()) => {
|
||||
let cadence = match interval {
|
||||
Some(dur) => {
|
||||
let secs = dur.as_secs();
|
||||
if secs % 3600 == 0 {
|
||||
format!("every {} h", secs / 3600)
|
||||
} else if secs % 60 == 0 {
|
||||
format!("every {} min", secs / 60)
|
||||
} else {
|
||||
format!("every {} s", secs)
|
||||
}
|
||||
}
|
||||
None => "on-demand".to_string(),
|
||||
};
|
||||
tracing::info!(
|
||||
target: "oxicloud::scheduler",
|
||||
event = "job.registered",
|
||||
job = %name,
|
||||
cadence = %cadence,
|
||||
"job {} registered ({})",
|
||||
name,
|
||||
cadence,
|
||||
);
|
||||
}
|
||||
Err(e) => panic!(
|
||||
"JobRegistry::register({name}) failed — DI wiring bug: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallible sibling of [`Self::register`]. Returns `Err` on
|
||||
/// duplicate-name instead of panicking, and does NOT emit the
|
||||
/// `job.registered` log line — for unit tests that need to
|
||||
/// assert failure without triggering the boot panic path.
|
||||
pub async fn try_register(
|
||||
&self,
|
||||
handler: Arc<dyn JobHandler>,
|
||||
interval: Option<Duration>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<(), RegisterError> {
|
||||
let name = handler.name().to_string();
|
||||
let mut guard = self.entries.write().await;
|
||||
@@ -286,11 +333,9 @@ mod tests {
|
||||
async fn register_and_pick_next() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("job_a"), Some(Duration::from_secs(60)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
reg.register(handler("job_b"), Some(Duration::from_secs(10)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
|
||||
let (next_name, _) = reg.pick_next().await.expect("expected a due job");
|
||||
// job_b has the shorter interval → earlier next_run_at.
|
||||
@@ -300,16 +345,30 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn duplicate_registration_rejected() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("job_x"), Some(Duration::from_secs(60)), None)
|
||||
// Use the fallible `try_register` here so we can assert the
|
||||
// Err path without triggering `register`'s boot-time panic.
|
||||
reg.try_register(handler("job_x"), Some(Duration::from_secs(60)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = reg
|
||||
.register(handler("job_x"), Some(Duration::from_secs(60)), None)
|
||||
.try_register(handler("job_x"), Some(Duration::from_secs(60)), None)
|
||||
.await
|
||||
.expect_err("duplicate name must be rejected");
|
||||
assert!(matches!(err, RegisterError::DuplicateName(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[should_panic(expected = "DI wiring bug")]
|
||||
async fn register_panics_on_duplicate() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("job_dup"), Some(Duration::from_secs(60)), None)
|
||||
.await;
|
||||
// Second register with same name — boot panic. Anything doing
|
||||
// this outside a #[should_panic] test is a mis-wired DI.
|
||||
reg.register(handler("job_dup"), Some(Duration::from_secs(60)), None)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_registry_picks_nothing() {
|
||||
let reg = JobRegistry::new();
|
||||
@@ -320,11 +379,9 @@ mod tests {
|
||||
async fn snapshot_all_returns_every_entry() {
|
||||
let reg = JobRegistry::new();
|
||||
reg.register(handler("a"), Some(Duration::from_secs(1)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
reg.register(handler("b"), Some(Duration::from_secs(1)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
let all = reg.snapshot_all().await;
|
||||
assert_eq!(all.len(), 2);
|
||||
}
|
||||
@@ -334,12 +391,9 @@ mod tests {
|
||||
let reg = JobRegistry::new();
|
||||
// Scheduled job with a long interval.
|
||||
reg.register(handler("scheduled"), Some(Duration::from_secs(3600)), None)
|
||||
.await
|
||||
.unwrap();
|
||||
.await;
|
||||
// On-demand job — supervisor must never pick it.
|
||||
reg.register(handler("on_demand"), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
reg.register(handler("on_demand"), None, None).await;
|
||||
|
||||
let (next_name, _) = reg.pick_next().await.expect("scheduled job due");
|
||||
assert_eq!(
|
||||
@@ -351,7 +405,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn trigger_dispatches_on_demand_job() {
|
||||
let reg = Arc::new(JobRegistry::new());
|
||||
reg.register(handler("gc"), None, None).await.unwrap();
|
||||
reg.register(handler("gc"), None, None).await;
|
||||
|
||||
let outcome = reg
|
||||
.trigger("gc", &JobRunArgs::default())
|
||||
|
||||
Reference in New Issue
Block a user