use super::*;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct JobHookEvent {
pub job: Job,
pub timestamp: DateTime<Utc>,
pub duration: Option<Duration>,
pub error: Option<String>,
}
pub type JobHookHandler = Arc<dyn Fn(JobHookEvent) + Send + Sync>;
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct JobEventHooks {
pub on_job_start: Option<JobHookHandler>,
pub on_job_complete: Option<JobHookHandler>,
pub on_job_fail: Option<JobHookHandler>,
pub on_job_timeout: Option<JobHookHandler>,
pub on_job_retry: Option<JobHookHandler>,
}
impl JobEventHooks {
pub fn new() -> Self {
Self::default()
}
pub fn on_start<F>(mut self, handler: F) -> Self
where
F: Fn(JobHookEvent) + Send + Sync + 'static,
{
self.on_job_start = Some(Arc::new(handler));
self
}
pub fn on_complete<F>(mut self, handler: F) -> Self
where
F: Fn(JobHookEvent) + Send + Sync + 'static,
{
self.on_job_complete = Some(Arc::new(handler));
self
}
pub fn on_fail<F>(mut self, handler: F) -> Self
where
F: Fn(JobHookEvent) + Send + Sync + 'static,
{
self.on_job_fail = Some(Arc::new(handler));
self
}
pub fn on_timeout<F>(mut self, handler: F) -> Self
where
F: Fn(JobHookEvent) + Send + Sync + 'static,
{
self.on_job_timeout = Some(Arc::new(handler));
self
}
pub fn on_retry<F>(mut self, handler: F) -> Self
where
F: Fn(JobHookEvent) + Send + Sync + 'static,
{
self.on_job_retry = Some(Arc::new(handler));
self
}
pub(crate) fn fire_job_start(&self, job: Job) {
if let Some(handler) = &self.on_job_start {
let event = JobHookEvent {
job,
timestamp: Utc::now(),
duration: None,
error: None,
};
run_hook("on_job_start", handler, event);
}
}
pub(crate) fn fire_job_complete(&self, job: Job, duration: Duration) {
if let Some(handler) = &self.on_job_complete {
let event = JobHookEvent {
job,
timestamp: Utc::now(),
duration: Some(duration),
error: None,
};
run_hook("on_job_complete", handler, event);
}
}
pub(crate) fn fire_job_fail(&self, job: Job, error: String) {
if let Some(handler) = &self.on_job_fail {
let event = JobHookEvent {
job,
timestamp: Utc::now(),
duration: None,
error: Some(error),
};
run_hook("on_job_fail", handler, event);
}
}
pub(crate) fn fire_job_timeout(&self, job: Job, duration: Duration) {
if let Some(handler) = &self.on_job_timeout {
let event = JobHookEvent {
job,
timestamp: Utc::now(),
duration: Some(duration),
error: Some("Job timed out".to_string()),
};
run_hook("on_job_timeout", handler, event);
}
}
pub(crate) fn fire_job_retry(&self, job: Job, error: String) {
if let Some(handler) = &self.on_job_retry {
let event = JobHookEvent {
job,
timestamp: Utc::now(),
duration: None,
error: Some(error),
};
run_hook("on_job_retry", handler, event);
}
}
}
fn run_hook(name: &str, handler: &JobHookHandler, event: JobHookEvent) {
let job_id = event.job.id;
if let Err(payload) = std::panic::catch_unwind(AssertUnwindSafe(|| handler(event))) {
error!(
"Job event hook {} panicked for job {}: {}",
name,
job_id,
panic_message(payload.as_ref())
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_event_hooks_default() {
let hooks = JobEventHooks::default();
assert!(hooks.on_job_start.is_none());
assert!(hooks.on_job_complete.is_none());
assert!(hooks.on_job_fail.is_none());
assert!(hooks.on_job_timeout.is_none());
assert!(hooks.on_job_retry.is_none());
}
#[test]
fn test_job_event_hooks_new() {
let hooks = JobEventHooks::new();
assert!(hooks.on_job_start.is_none());
assert!(hooks.on_job_complete.is_none());
assert!(hooks.on_job_fail.is_none());
assert!(hooks.on_job_timeout.is_none());
assert!(hooks.on_job_retry.is_none());
}
#[test]
fn test_job_event_hooks_builder() {
use std::sync::{Arc, Mutex};
let events = Arc::new(Mutex::new(Vec::new()));
let events_start = Arc::clone(&events);
let events_complete = Arc::clone(&events);
let events_fail = Arc::clone(&events);
let events_timeout = Arc::clone(&events);
let events_retry = Arc::clone(&events);
let hooks = JobEventHooks::new()
.on_start(move |event: JobHookEvent| {
events_start
.lock()
.unwrap()
.push(format!("start:{}", event.job.id));
})
.on_complete(move |event: JobHookEvent| {
events_complete
.lock()
.unwrap()
.push(format!("complete:{}", event.job.id));
})
.on_fail(move |event: JobHookEvent| {
events_fail
.lock()
.unwrap()
.push(format!("fail:{}", event.job.id));
})
.on_timeout(move |event: JobHookEvent| {
events_timeout
.lock()
.unwrap()
.push(format!("timeout:{}", event.job.id));
})
.on_retry(move |event: JobHookEvent| {
events_retry
.lock()
.unwrap()
.push(format!("retry:{}", event.job.id));
});
assert!(hooks.on_job_start.is_some());
assert!(hooks.on_job_complete.is_some());
assert!(hooks.on_job_fail.is_some());
assert!(hooks.on_job_timeout.is_some());
assert!(hooks.on_job_retry.is_some());
}
#[test]
fn test_job_hook_event_creation() {
use crate::Job;
use serde_json::json;
use std::time::Duration;
let job = Job::new("test_queue".to_string(), json!({"test": "data"}))
.with_trace_id("trace-123")
.with_correlation_id("corr-456");
let event = JobHookEvent {
job: job.clone(),
timestamp: Utc::now(),
duration: Some(Duration::from_millis(500)),
error: Some("Test error".to_string()),
};
assert_eq!(event.job.id, job.id);
assert_eq!(event.job.queue_name, "test_queue");
assert_eq!(event.job.trace_id, Some("trace-123".to_string()));
assert_eq!(event.job.correlation_id, Some("corr-456".to_string()));
assert_eq!(event.duration, Some(Duration::from_millis(500)));
assert_eq!(event.error, Some("Test error".to_string()));
}
#[test]
fn test_job_event_hooks_fire_methods() {
use crate::Job;
use serde_json::json;
use std::sync::{Arc, Mutex};
use std::time::Duration;
let events = Arc::new(Mutex::new(Vec::new()));
let job = Job::new("test_queue".to_string(), json!({"test": "data"}));
{
let events_clone = Arc::clone(&events);
let hooks = JobEventHooks::new().on_start(move |event: JobHookEvent| {
events_clone
.lock()
.unwrap()
.push(format!("start:{}", event.job.queue_name));
});
hooks.fire_job_start(job.clone());
let captured_events = events.lock().unwrap();
assert_eq!(captured_events.len(), 1);
assert_eq!(captured_events[0], "start:test_queue");
}
events.lock().unwrap().clear();
{
let events_clone = Arc::clone(&events);
let hooks = JobEventHooks::new().on_complete(move |event: JobHookEvent| {
events_clone.lock().unwrap().push(format!(
"complete:{}:{}ms",
event.job.queue_name,
event.duration.unwrap_or_default().as_millis()
));
});
hooks.fire_job_complete(job.clone(), Duration::from_millis(150));
let captured_events = events.lock().unwrap();
assert_eq!(captured_events.len(), 1);
assert_eq!(captured_events[0], "complete:test_queue:150ms");
}
events.lock().unwrap().clear();
{
let events_clone = Arc::clone(&events);
let hooks = JobEventHooks::new().on_fail(move |event: JobHookEvent| {
events_clone.lock().unwrap().push(format!(
"fail:{}:{}",
event.job.queue_name,
event.error.unwrap_or_default()
));
});
hooks.fire_job_fail(job.clone(), "Connection timeout".to_string());
let captured_events = events.lock().unwrap();
assert_eq!(captured_events.len(), 1);
assert_eq!(captured_events[0], "fail:test_queue:Connection timeout");
}
events.lock().unwrap().clear();
{
let events_clone = Arc::clone(&events);
let hooks = JobEventHooks::new().on_timeout(move |event: JobHookEvent| {
events_clone.lock().unwrap().push(format!(
"timeout:{}:{}ms",
event.job.queue_name,
event.duration.unwrap_or_default().as_millis()
));
});
hooks.fire_job_timeout(job.clone(), Duration::from_secs(30));
let captured_events = events.lock().unwrap();
assert_eq!(captured_events.len(), 1);
assert_eq!(captured_events[0], "timeout:test_queue:30000ms");
}
events.lock().unwrap().clear();
{
let events_clone = Arc::clone(&events);
let hooks = JobEventHooks::new().on_retry(move |event: JobHookEvent| {
events_clone.lock().unwrap().push(format!(
"retry:{}:{}",
event.job.queue_name,
event.error.unwrap_or_default()
));
});
hooks.fire_job_retry(job.clone(), "API rate limit exceeded".to_string());
let captured_events = events.lock().unwrap();
assert_eq!(captured_events.len(), 1);
assert_eq!(
captured_events[0],
"retry:test_queue:API rate limit exceeded"
);
}
}
#[test]
fn panicking_hooks_are_contained() {
use std::sync::atomic::{AtomicUsize, Ordering};
let calls = Arc::new(AtomicUsize::new(0));
let counted = |calls: &Arc<AtomicUsize>| {
let calls = Arc::clone(calls);
move |_event: JobHookEvent| {
calls.fetch_add(1, Ordering::SeqCst);
panic!("hook exploded");
}
};
let hooks = JobEventHooks::new()
.on_start(counted(&calls))
.on_complete(counted(&calls))
.on_fail(counted(&calls))
.on_timeout(counted(&calls))
.on_retry(counted(&calls));
let job = crate::Job::new("test".to_string(), serde_json::json!({}));
hooks.fire_job_start(job.clone());
hooks.fire_job_complete(job.clone(), Duration::from_millis(1));
hooks.fire_job_fail(job.clone(), "boom".to_string());
hooks.fire_job_timeout(job.clone(), Duration::from_secs(1));
hooks.fire_job_retry(job, "boom".to_string());
assert_eq!(calls.load(Ordering::SeqCst), 5);
}
#[test]
fn test_job_event_hooks_clone() {
use std::sync::{Arc, Mutex};
let events = Arc::new(Mutex::new(Vec::new()));
let events_clone = Arc::clone(&events);
let hooks = JobEventHooks::new().on_start(move |event: JobHookEvent| {
events_clone
.lock()
.unwrap()
.push(format!("cloned:{}", event.job.id));
});
let hooks_clone = hooks.clone();
let job = crate::Job::new("test".to_string(), serde_json::json!({}));
hooks.fire_job_start(job.clone());
hooks_clone.fire_job_start(job);
let captured_events = events.lock().unwrap();
assert_eq!(captured_events.len(), 2);
assert!(captured_events[0].starts_with("cloned:"));
assert!(captured_events[1].starts_with("cloned:"));
}
}