use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use super::claim::{self, ClaimedJob};
use super::complete::{self, ClaimTransition, DispatchOutcome, ErrorKind, JobState, Outcome};
use super::config::{RetryPolicy, WorkerConfig};
use super::dialect::{JobPool, sql};
use super::error::WorkerError;
use super::observe::{Event, FailReason, Observer};
use super::registry::{self, Registry};
const PANIC_MESSAGE: &str = "handler panicked";
#[derive(Clone)]
pub struct Worker {
pool: JobPool,
registry: Registry,
config: WorkerConfig,
retry: RetryPolicy,
worker_id: String,
observer: Option<Arc<dyn Observer>>,
}
impl Worker {
pub fn new(pool: JobPool, registry: Registry) -> Self {
Self::builder(pool, registry).build()
}
pub fn builder(pool: JobPool, registry: Registry) -> WorkerBuilder {
WorkerBuilder {
inner: Worker {
pool,
registry,
config: WorkerConfig::default(),
retry: RetryPolicy::default(),
worker_id: format!("worker-{}", Uuid::new_v4().simple()),
observer: None,
},
}
}
pub async fn run(self, shutdown: CancellationToken) -> Result<(), WorkerError> {
run_loop(
self.pool,
self.registry,
self.config,
self.retry,
self.worker_id,
self.observer,
shutdown,
)
.await
}
}
pub struct WorkerBuilder {
inner: Worker,
}
impl WorkerBuilder {
pub fn worker_id(mut self, id: impl Into<String>) -> Self {
let id = id.into();
if !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_graphic()) {
self.inner.worker_id = id;
}
self
}
pub fn config(mut self, config: WorkerConfig) -> Self {
self.inner.config = config;
self
}
pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
self.inner.retry = policy;
self
}
pub fn observer(mut self, observer: impl Observer) -> Self {
self.inner.observer = Some(Arc::new(observer));
self
}
pub fn build(self) -> Worker {
self.inner
}
}
#[allow(clippy::too_many_arguments)]
async fn run_loop(
pool: JobPool,
registry: Registry,
config: WorkerConfig,
retry: RetryPolicy,
worker_id: String,
observer: Option<Arc<dyn Observer>>,
shutdown: CancellationToken,
) -> Result<(), WorkerError> {
config.validate()?;
retry.validate()?;
let semaphore = Arc::new(Semaphore::new(config.get_concurrency()));
let mut join_set: JoinSet<()> = JoinSet::new();
let mut next_sweep = tokio::time::Instant::now() + config.get_sweep_interval();
loop {
while join_set.try_join_next().is_some() {}
if shutdown.is_cancelled() {
break;
}
let now = tokio::time::Instant::now();
if now >= next_sweep {
let _ = complete::sweep_expired_leases(&pool, config.get_sweep_batch()).await;
next_sweep = now + config.get_sweep_interval();
}
let claimed = claim::claim_jobs(
&pool,
&worker_id,
config.get_lease(),
config.get_poll_batch(),
)
.await?;
if claimed.is_empty() {
tokio::select! {
_ = shutdown.cancelled() => break,
_ = tokio::time::sleep(config.get_poll_interval()) => {}
}
continue;
}
for job in claimed {
if shutdown.is_cancelled() {
release_claim(&pool, job.id, job.claim_token).await?;
continue;
}
let permit = {
let sem = semaphore.clone();
tokio::select! {
res = sem.acquire_owned() => match res {
Ok(permit) => permit,
Err(_) => {
release_claim(&pool, job.id, job.claim_token).await?;
continue;
}
},
_ = shutdown.cancelled() => {
release_claim(&pool, job.id, job.claim_token).await?;
continue;
}
}
};
let pool_clone = pool.clone();
let registry_clone = registry.clone();
let observer_clone = observer.clone();
let retry_clone = retry;
let config_clone = config;
join_set.spawn(async move {
let _permit = permit;
let result = dispatch_one(
&pool_clone,
®istry_clone,
retry_clone,
config_clone,
observer_clone.as_deref(),
job,
)
.await;
if let Err(e) = result {
eprintln!("job dispatch error: {e}");
}
});
}
}
while let Some(res) = join_set.join_next().await {
if let Err(e) = res {
eprintln!("job dispatch task panicked: {e}");
}
}
Ok(())
}
async fn release_claim(pool: &JobPool, job_id: Uuid, claim_token: Uuid) -> Result<(), WorkerError> {
let _ = sqlx::query(sql::RELEASE_CLAIM)
.bind(job_id)
.bind(claim_token)
.execute(pool)
.await?;
Ok(())
}
fn emit(observer: Option<&dyn Observer>, event: Event) {
if let Some(o) = observer {
o.observe(&event);
}
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_one(
pool: &JobPool,
registry: &Registry,
retry: RetryPolicy,
config: WorkerConfig,
observer: Option<&dyn Observer>,
job: ClaimedJob,
) -> Result<(), WorkerError> {
let job_timeout = config.get_job_timeout();
let lease = config.get_lease();
let heartbeat_interval = config.get_heartbeat_interval();
let started = Instant::now();
let job_id = job.id;
let claim_token = job.claim_token;
let kind = job.kind.clone();
let version = job.version;
let attempt = job.attempts;
let handler = match registry.get(&job.kind, job.version) {
Some(h) => h,
None => {
let transition = complete::mark_dead(
pool,
job_id,
claim_token,
ErrorKind::Unknown,
format!("no handler registered for kind {kind:?} version {version}"),
)
.await?;
if transition == ClaimTransition::Updated {
emit(
observer,
Event::Failed {
job_id,
attempt,
duration: started.elapsed(),
message: format!(
"no handler registered for kind {kind:?} version {version}"
),
reason: FailReason::Unknown,
},
);
}
return Ok(());
}
};
emit(
observer,
Event::Started {
job_id,
kind: kind.clone(),
version,
attempt,
},
);
let state = JobState {
attempts: job.attempts,
max_attempts: job.max_attempts,
};
let payload = job.payload.clone();
let mut handler_task = {
let handler = handler.clone();
tokio::spawn(async move { handler.handle(&payload, job_id).await })
};
let handler_start = tokio::time::Instant::now();
let timeout_deadline = handler_start + job_timeout;
let dispatch = loop {
tokio::select! {
res = &mut handler_task => {
break match res {
Ok(handler_result) => match handler_result {
Ok(()) => DispatchOutcome::Succeeded,
Err(registry::HandlerError::Malformed) => DispatchOutcome::Malformed,
Err(registry::HandlerError::Job(e)) => DispatchOutcome::HandlerError(e),
},
Err(join_err) => {
let transition = complete::mark_dead(
pool,
job_id,
claim_token,
ErrorKind::Panic,
PANIC_MESSAGE.to_string(),
)
.await?;
if transition == ClaimTransition::Updated {
emit(
observer,
Event::Failed {
job_id,
attempt,
duration: started.elapsed(),
message: PANIC_MESSAGE.to_string(),
reason: FailReason::Panic,
},
);
}
let _ = join_err;
return Ok(());
}
};
}
_ = tokio::time::sleep_until(timeout_deadline) => {
handler_task.abort();
break DispatchOutcome::Timeout;
}
_ = tokio::time::sleep(heartbeat_interval) => {
let _ = complete::heartbeat(pool, job_id, claim_token, lease).await;
}
}
};
let duration = started.elapsed();
let now = chrono::Utc::now();
let outcome = complete::decide(&state, &dispatch, &retry, now);
match outcome {
Outcome::Succeeded => {
let transition = complete::mark_succeeded(pool, job_id, claim_token).await?;
if transition == ClaimTransition::Updated {
emit(
observer,
Event::Succeeded {
job_id,
attempt,
duration,
},
);
}
}
Outcome::Retry { available_at } => {
let message = match &dispatch {
DispatchOutcome::HandlerError(err) => err.stored_message(),
DispatchOutcome::Timeout => {
format!(
"job {job_id} exceeded its {}s timeout",
job_timeout.as_secs()
)
}
_ => String::new(),
};
let transition =
complete::mark_retry(pool, job_id, claim_token, available_at, message.clone())
.await?;
if transition == ClaimTransition::Updated {
emit(
observer,
Event::Retried {
job_id,
attempt,
duration,
message,
next_run_at: available_at,
},
);
}
}
Outcome::Dead {
error_kind,
message,
} => {
let reason = match error_kind {
ErrorKind::Permanent => FailReason::Permanent,
ErrorKind::Exhausted => FailReason::Exhausted,
ErrorKind::Malformed => FailReason::Malformed,
ErrorKind::Unknown => FailReason::Unknown,
ErrorKind::Panic => FailReason::Panic,
ErrorKind::Timeout => FailReason::Timeout,
};
let transition =
complete::mark_dead(pool, job_id, claim_token, error_kind, message.clone()).await?;
if transition == ClaimTransition::Updated {
emit(
observer,
Event::Failed {
job_id,
attempt,
duration,
message,
reason,
},
);
}
}
}
Ok(())
}