use super::autoscale::{QueueDepthHistory, apply_queue_depth};
use super::*;
struct Scaling<DB: Database> {
template: Worker<DB>,
desired: watch::Receiver<usize>,
initial: usize,
}
async fn wait_for_change(rx: &mut Option<watch::Receiver<usize>>) -> Option<usize> {
match rx {
Some(rx) => match rx.changed().await {
Ok(()) => Some(*rx.borrow_and_update()),
Err(_) => None,
},
None => std::future::pending().await,
}
}
pub struct WorkerPool<DB: Database> {
workers: Vec<Worker<DB>>,
shutdown_signal: Option<watch::Sender<bool>>,
supervisor_done: Option<watch::Receiver<bool>>,
reaper_interval: Option<Duration>,
encrypted_job_purge_interval: Option<Duration>,
archival: Option<PoolArchival>,
result_cleanup_interval: Option<Duration>,
#[cfg(feature = "encryption")]
key_rotation: Option<PoolKeyRotation>,
stats_collector: Option<Arc<dyn StatisticsCollector>>,
worker_template: Option<Worker<DB>>,
autoscale_config: AutoscaleConfig,
autoscale_metrics: Arc<std::sync::RwLock<AutoscaleMetrics>>,
queue_depth_history: QueueDepthHistory,
autoscale_task: Option<tokio::task::JoinHandle<()>>,
}
impl<DB: Database + Send + Sync + 'static> WorkerPool<DB>
where
JobQueue<DB>: DatabaseQueue<Database = DB> + Send + Sync,
{
pub fn new() -> Self {
Self {
workers: Vec::new(),
shutdown_signal: None,
supervisor_done: None,
reaper_interval: Some(DEFAULT_STALE_JOB_REAPER_INTERVAL),
encrypted_job_purge_interval: None,
archival: None,
result_cleanup_interval: Some(DEFAULT_RESULT_CLEANUP_INTERVAL),
#[cfg(feature = "encryption")]
key_rotation: None,
stats_collector: None,
worker_template: None,
autoscale_config: AutoscaleConfig::disabled(),
autoscale_metrics: Arc::new(std::sync::RwLock::new(AutoscaleMetrics::default())),
queue_depth_history: Arc::new(std::sync::RwLock::new(Vec::new())),
autoscale_task: None,
}
}
pub fn from_config(worker: Worker<DB>, config: &crate::config::WorkerConfig) -> Self {
let mut pool = Self::new()
.with_autoscaling(config.autoscale_config())
.with_worker_template(worker.clone());
pool.result_cleanup_interval = config
.result_cleanup_enabled
.then_some(config.result_cleanup_interval);
for _ in 1..config.pool_size {
pool.add_worker(worker.clone());
}
pool.add_worker(worker);
pool
}
pub fn from_hammerwork_config(
worker: Worker<DB>,
config: &crate::config::HammerworkConfig,
) -> Result<Self> {
config.encryption.validate()?;
config.worker.validate()?;
config.archive.validate()?;
config.rate_limiting.validate()?;
let rotation = &config.encryption.key_rotation;
#[cfg(feature = "encryption")]
let key_rotation = if rotation.enabled {
Some(PoolKeyRotation {
interval: rotation.check_interval(),
start: key_rotation_from_config(&worker.queue.pool, rotation.key_manager_config())?,
})
} else {
None
};
#[cfg(not(feature = "encryption"))]
if rotation.enabled {
return Err(HammerworkError::Config(
"encryption.key_rotation.enabled is set but hammerwork was built without the \
`encryption` feature"
.to_string(),
));
}
let mut pool = Self::from_config(worker, &config.worker);
#[cfg(feature = "encryption")]
{
pool.key_rotation = key_rotation;
}
pool.encrypted_job_purge_interval = config.encryption.purge_interval();
if config.archive.enabled {
pool = pool.with_archival(
config.archive.archival_policy(),
config.archive.archival_config(),
config.archive.check_interval,
);
}
Ok(pool)
}
pub fn with_stats_collector(mut self, stats_collector: Arc<dyn StatisticsCollector>) -> Self {
for worker in &mut self.workers {
worker.stats_collector = Some(Arc::clone(&stats_collector));
}
self.stats_collector = Some(stats_collector);
self
}
pub fn with_autoscaling(mut self, config: AutoscaleConfig) -> Self {
self.autoscale_config = config;
self
}
pub fn without_autoscaling(mut self) -> Self {
self.autoscale_config = AutoscaleConfig::disabled();
self
}
pub fn with_stale_job_reaper(mut self, interval: Duration) -> Self {
self.reaper_interval = Some(interval);
self
}
pub fn without_stale_job_reaper(mut self) -> Self {
self.reaper_interval = None;
self
}
pub fn with_encrypted_job_purge(mut self, interval: Duration) -> Self {
self.encrypted_job_purge_interval = Some(interval);
self
}
pub fn without_encrypted_job_purge(mut self) -> Self {
self.encrypted_job_purge_interval = None;
self
}
pub fn with_archival(
mut self,
policy: crate::archive::ArchivalPolicy,
config: crate::archive::ArchivalConfig,
interval: Duration,
) -> Self {
self.archival = Some(PoolArchival {
policy,
config,
interval,
});
self
}
pub fn without_archival(mut self) -> Self {
self.archival = None;
self
}
pub fn with_result_cleanup(mut self, interval: Duration) -> Self {
self.result_cleanup_interval = Some(interval);
self
}
pub fn without_result_cleanup(mut self) -> Self {
self.result_cleanup_interval = None;
self
}
#[cfg(feature = "encryption")]
pub fn with_key_rotation<K: crate::encryption::KeyManagerBackend>(
mut self,
key_manager: crate::encryption::KeyManager<K>,
check_interval: Duration,
) -> Self {
let pass = rotation_pass(key_manager);
self.key_rotation = Some(PoolKeyRotation {
interval: check_interval,
start: Arc::new(move || {
let pass = pass.clone();
Box::pin(async move { Ok(pass) })
}),
});
self
}
#[cfg(feature = "encryption")]
pub fn without_key_rotation(mut self) -> Self {
self.key_rotation = None;
self
}
pub fn with_worker_template(mut self, worker: Worker<DB>) -> Self {
self.worker_template = Some(worker);
self
}
pub fn add_worker(&mut self, mut worker: Worker<DB>) {
if let Some(stats_collector) = &self.stats_collector {
worker.stats_collector = Some(Arc::clone(stats_collector));
}
if self.worker_template.is_none()
&& self.autoscale_config.enabled
&& self.workers.is_empty()
{
self.worker_template = Some(worker.clone());
}
self.workers.push(worker);
}
pub async fn start(&mut self) -> Result<()> {
info!("Starting worker pool with {} workers", self.workers.len());
#[cfg(feature = "encryption")]
let key_rotation = match &self.key_rotation {
Some(rotation) => (rotation.start)()
.await?
.map(|pass| (pass, rotation.interval)),
None => None,
};
let (signal_tx, signal_rx) = watch::channel(false);
let (done_tx, done_rx) = watch::channel(false);
self.shutdown_signal = Some(signal_tx);
self.supervisor_done = Some(done_rx);
let workers = std::mem::take(&mut self.workers);
#[cfg_attr(not(feature = "encryption"), allow(unused_mut))]
let mut maintenance: Vec<tokio::task::JoinHandle<()>> = [
self.start_stale_job_reaper(&workers, signal_rx.clone()),
self.start_encrypted_job_purge(&workers, signal_rx.clone()),
self.start_archival(&workers, signal_rx.clone()),
self.start_result_cleanup(&workers, signal_rx.clone()),
]
.into_iter()
.flatten()
.collect();
#[cfg(feature = "encryption")]
if let Some((pass, interval)) = key_rotation {
info!("Rotating due encryption keys every {:?}", interval);
maintenance.push(spawn_periodic(interval, signal_rx.clone(), move || pass()));
}
let scaling = if self.autoscale_config.enabled {
self.start_autoscaling_task(&workers)
} else {
None
};
if let Ok(mut metrics) = self.autoscale_metrics.write() {
metrics.active_workers = match &scaling {
Some(scaling) => scaling.initial,
None => workers.len(),
};
}
let supervisor = tokio::spawn(Self::supervise(
workers,
signal_rx,
done_tx,
maintenance,
scaling,
));
supervisor.await.map_err(|e| HammerworkError::Worker {
message: format!("Worker pool supervisor failed: {}", e),
})?
}
fn spawn_worker(
set: &mut JoinSet<(usize, std::thread::Result<Result<()>>)>,
index: usize,
worker: Worker<DB>,
delay: Option<Duration>,
) -> mpsc::Sender<()> {
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
set.spawn(async move {
if let Some(delay) = delay {
sleep(delay).await;
}
let run = CatchUnwind {
inner: Box::pin(async move { worker.run(shutdown_rx).await }),
};
(index, run.await)
});
shutdown_tx
}
async fn supervise(
workers: Vec<Worker<DB>>,
mut signal_rx: watch::Receiver<bool>,
done_tx: watch::Sender<bool>,
maintenance: Vec<tokio::task::JoinHandle<()>>,
scaling: Option<Scaling<DB>>,
) -> Result<()> {
let mut templates: Vec<Worker<DB>> = workers.to_vec();
let mut set = JoinSet::new();
let mut senders: Vec<mpsc::Sender<()>> = workers
.into_iter()
.enumerate()
.map(|(index, worker)| Self::spawn_worker(&mut set, index, worker, None))
.collect();
let mut retired: Vec<bool> = vec![false; senders.len()];
let mut finished: Vec<bool> = vec![false; senders.len()];
let (template, mut desired_rx) = match scaling {
Some(scaling) => (Some(scaling.template), Some(scaling.desired)),
None => (None, None),
};
let mut shutting_down = *signal_rx.borrow();
if shutting_down {
for tx in &senders {
let _ = tx.try_send(());
}
}
loop {
tokio::select! {
_ = signal_rx.changed(), if !shutting_down => {
info!("Worker pool shutting down; waiting for in-flight jobs");
shutting_down = true;
for tx in &senders {
let _ = tx.try_send(());
}
}
changed = wait_for_change(&mut desired_rx), if !shutting_down => {
let (Some(desired), Some(template)) = (changed, template.as_ref()) else {
desired_rx = None;
continue;
};
let active: Vec<usize> = (0..senders.len())
.filter(|i| !retired[*i] && templates[*i].queue_name == template.queue_name)
.collect();
if desired > active.len() {
for _ in active.len()..desired {
let worker = template.clone();
let reusable = (0..senders.len()).find(|i| retired[*i] && finished[*i]);
let index = reusable.unwrap_or(senders.len());
let tx = Self::spawn_worker(&mut set, index, worker.clone(), None);
if index == senders.len() {
templates.push(worker);
senders.push(tx);
retired.push(false);
finished.push(false);
} else {
templates[index] = worker;
senders[index] = tx;
retired[index] = false;
finished[index] = false;
}
}
info!(
"Autoscaling: started workers for queue {}, now {}",
template.queue_name, desired
);
} else {
for index in active.iter().rev().take(active.len() - desired) {
retired[*index] = true;
let _ = senders[*index].try_send(());
}
if desired < active.len() {
info!(
"Autoscaling: retiring workers of queue {}, now {}",
template.queue_name, desired
);
}
}
}
joined = set.join_next() => {
let Some(joined) = joined else { break };
let index = match joined {
Ok((index, Ok(Ok(())))) => {
if shutting_down {
continue;
}
if retired[index] {
finished[index] = true;
continue;
}
warn!("Worker {} stopped unexpectedly", index);
index
}
Ok((index, Ok(Err(e)))) => {
error!("Worker {} failed: {}", index, e);
index
}
Ok((index, Err(payload))) => {
error!(
"Worker {} panicked: {}",
index,
panic_message(payload.as_ref())
);
index
}
Err(e) => {
error!("Worker task ended abnormally: {}", e);
continue;
}
};
if retired[index] {
finished[index] = true;
} else if !shutting_down {
info!("Restarting worker {} in {:?}", index, WORKER_RESTART_DELAY);
senders[index] = Self::spawn_worker(
&mut set,
index,
templates[index].clone(),
Some(WORKER_RESTART_DELAY),
);
}
}
}
}
for task in maintenance {
task.abort();
}
info!("All workers stopped");
done_tx.send_replace(true);
Ok(())
}
fn start_stale_job_reaper(
&self,
workers: &[Worker<DB>],
signal_rx: watch::Receiver<bool>,
) -> Option<tokio::task::JoinHandle<()>> {
let interval = self.reaper_interval?;
spawn_queue_task(workers, interval, signal_rx, |queue| async move {
match queue.requeue_stale_jobs(None).await {
Ok(recovery) if !recovery.is_empty() => info!(
"Stale job reaper requeued {} and marked {} dead",
recovery.requeued.len(),
recovery.dead.len()
),
Ok(_) => {}
Err(e) => warn!("Stale job reaper failed: {}", e),
}
})
}
fn start_encrypted_job_purge(
&self,
workers: &[Worker<DB>],
signal_rx: watch::Receiver<bool>,
) -> Option<tokio::task::JoinHandle<()>> {
let interval = self.encrypted_job_purge_interval?;
spawn_queue_task(workers, interval, signal_rx, |queue| async move {
match queue.purge_expired_encrypted_jobs().await {
Ok(purge) if purge.total() > 0 => info!(
"Encrypted job purge deleted {} jobs and {} archived jobs",
purge.jobs, purge.archived_jobs
),
Ok(_) => {}
Err(e) => warn!("Encrypted job purge failed: {}", e),
}
})
}
fn start_archival(
&self,
workers: &[Worker<DB>],
signal_rx: watch::Receiver<bool>,
) -> Option<tokio::task::JoinHandle<()>> {
let archival = self.archival.clone()?;
let queue = Arc::clone(&workers.first()?.queue);
if !archival.policy.enabled {
info!("Automatic archival is configured with a disabled policy; not starting it");
return None;
}
let archiver = Arc::new(
crate::archive::JobArchiver::new()
.with_default_policy(archival.policy)
.with_config(archival.config),
);
let stop_rx = signal_rx.clone();
Some(spawn_periodic(archival.interval, signal_rx, move || {
let (queue, archiver, stop_rx) =
(Arc::clone(&queue), Arc::clone(&archiver), stop_rx.clone());
async move {
let pass = archiver
.run_scheduled_pass(queue.as_ref(), Some("worker_pool"), || *stop_rx.borrow())
.await;
match pass {
Ok(pass) if pass.jobs_archived > 0 || pass.jobs_purged > 0 => info!(
"Archival archived {} jobs and purged {} archived jobs{}",
pass.jobs_archived,
pass.jobs_purged,
if pass.stopped_early {
" (stopped early for shutdown)"
} else {
""
}
),
Ok(_) => debug!("Archival found nothing to archive or purge"),
Err(e) => warn!("Archival failed: {}", e),
}
}
}))
}
fn start_result_cleanup(
&self,
workers: &[Worker<DB>],
signal_rx: watch::Receiver<bool>,
) -> Option<tokio::task::JoinHandle<()>> {
let interval = self.result_cleanup_interval?;
if !workers.is_empty() {
debug!("Clearing expired job results every {:?}", interval);
}
spawn_queue_task(workers, interval, signal_rx, |queue| async move {
match queue.cleanup_expired_results().await {
Ok(cleared) if cleared > 0 => {
info!("Result cleanup cleared {} expired job results", cleared)
}
Ok(_) => debug!("Result cleanup found no expired job results"),
Err(e) => warn!("Result cleanup failed: {}", e),
}
})
}
fn start_autoscaling_task(&mut self, workers: &[Worker<DB>]) -> Option<Scaling<DB>> {
let Some(worker_template) = self.worker_template.as_ref().or(workers.first()) else {
warn!("Cannot start autoscaling: no worker template available");
return None;
};
let mut template = worker_template.clone();
let initial_workers = workers
.iter()
.filter(|worker| worker.queue_name == template.queue_name)
.count();
if let Some(stats_collector) = &self.stats_collector {
template.stats_collector = Some(Arc::clone(stats_collector));
}
let queue = Arc::clone(&template.queue);
let queue_name = template.queue_name.clone();
let config = self.autoscale_config.clone();
let metrics = Arc::clone(&self.autoscale_metrics);
let history = Arc::clone(&self.queue_depth_history);
let (desired_tx, desired_rx) = watch::channel(initial_workers);
let task = tokio::spawn(async move {
Self::autoscaling_loop(queue, queue_name, config, metrics, history, desired_tx).await;
});
if let Some(previous) = self.autoscale_task.replace(task) {
previous.abort();
}
info!(
"Autoscaling task started for queue: {}",
template.queue_name
);
Some(Scaling {
template,
desired: desired_rx,
initial: initial_workers,
})
}
async fn autoscaling_loop(
queue: Arc<JobQueue<DB>>,
queue_name: String,
config: AutoscaleConfig,
metrics: Arc<std::sync::RwLock<AutoscaleMetrics>>,
history: QueueDepthHistory,
desired: watch::Sender<usize>,
) {
let mut interval =
tokio::time::interval((config.evaluation_window / 2).max(Duration::from_millis(1)));
loop {
interval.tick().await;
match Self::evaluate_scaling_decision(&queue, &queue_name, &config, &metrics, &history)
.await
{
Ok(Some(count)) => {
if desired.send(count).is_err() {
return;
}
}
Ok(None) => {}
Err(e) => warn!("Autoscaling evaluation error: {}", e),
}
}
}
async fn evaluate_scaling_decision(
queue: &Arc<JobQueue<DB>>,
queue_name: &str,
config: &AutoscaleConfig,
metrics: &Arc<std::sync::RwLock<AutoscaleMetrics>>,
history: &QueueDepthHistory,
) -> Result<Option<usize>> {
let current_depth = queue.get_queue_depth(queue_name).await?;
Ok(apply_queue_depth(
current_depth,
Utc::now(),
config,
metrics,
history,
))
}
pub fn get_autoscale_metrics(&self) -> AutoscaleMetrics {
self.autoscale_metrics
.read()
.map(|metrics| metrics.clone())
.unwrap_or_default()
}
pub async fn shutdown(&self) -> Result<()> {
info!("Shutting down worker pool");
if let Some(task) = &self.autoscale_task {
task.abort();
info!("Autoscaling task stopped");
}
if let Some(signal) = &self.shutdown_signal {
signal.send_replace(true);
}
if let Some(done) = &self.supervisor_done {
let mut done = done.clone();
let _ = done.wait_for(|finished| *finished).await;
}
Ok(())
}
pub fn stats_collector(&self) -> Option<Arc<dyn StatisticsCollector>> {
self.stats_collector.clone()
}
}
impl<DB: Database + Send + Sync + 'static> Default for WorkerPool<DB>
where
JobQueue<DB>: DatabaseQueue<Database = DB> + Send + Sync,
{
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
struct PoolArchival {
policy: crate::archive::ArchivalPolicy,
config: crate::archive::ArchivalConfig,
interval: Duration,
}
pub const DEFAULT_RESULT_CLEANUP_INTERVAL: Duration =
crate::config::DEFAULT_RESULT_CLEANUP_INTERVAL;
#[cfg(feature = "encryption")]
type RotationPass = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
#[cfg(feature = "encryption")]
type RotationStarter = Arc<
dyn Fn() -> Pin<
Box<
dyn Future<
Output = std::result::Result<
Option<RotationPass>,
crate::encryption::EncryptionError,
>,
> + Send,
>,
> + Send
+ Sync,
>;
#[cfg(feature = "encryption")]
#[derive(Clone)]
struct PoolKeyRotation {
interval: Duration,
start: RotationStarter,
}
#[cfg(feature = "encryption")]
fn rotation_pass<K: crate::encryption::KeyManagerBackend>(
manager: crate::encryption::KeyManager<K>,
) -> Option<RotationPass> {
if !manager.auto_rotation_enabled() {
warn!(
"Key rotation is configured with a KeyManager whose automatic rotation is \
disabled; not starting it"
);
return None;
}
Some(Arc::new(move || Box::pin(manager.clone().rotation_pass())))
}
#[cfg(feature = "encryption")]
#[allow(dead_code)] fn key_manager_starter<K: crate::encryption::KeyManagerBackend>(
pool: sqlx::Pool<K>,
config: crate::encryption::KeyManagerConfig,
) -> RotationStarter {
Arc::new(move || {
let (pool, config) = (pool.clone(), config.clone());
Box::pin(async move {
let manager = crate::encryption::KeyManager::new(config, pool).await?;
Ok(rotation_pass(manager))
})
})
}
#[cfg(feature = "encryption")]
#[allow(unused_variables)] fn key_rotation_from_config<DB: Database>(
pool: &sqlx::Pool<DB>,
config: crate::encryption::KeyManagerConfig,
) -> Result<RotationStarter> {
let pool: &dyn std::any::Any = pool;
#[cfg(feature = "postgres")]
if let Some(pool) = pool.downcast_ref::<sqlx::PgPool>() {
return Ok(key_manager_starter(pool.clone(), config));
}
#[cfg(feature = "mysql")]
if let Some(pool) = pool.downcast_ref::<sqlx::MySqlPool>() {
return Ok(key_manager_starter(pool.clone(), config));
}
Err(HammerworkError::Config(
"encryption.key_rotation needs a PostgreSQL or MySQL database".to_string(),
))
}
fn spawn_periodic<F, Fut>(
interval: Duration,
mut signal_rx: watch::Receiver<bool>,
run: F,
) -> tokio::task::JoinHandle<()>
where
F: Fn() -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval.max(Duration::from_millis(1)));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = signal_rx.changed() => break,
_ = ticker.tick() => {}
}
if *signal_rx.borrow() {
break;
}
if let Err(e) = tokio::spawn(run()).await
&& e.is_panic()
{
error!("Periodic maintenance task panicked: {}", e);
}
}
})
}
fn spawn_queue_task<DB, F, Fut>(
workers: &[Worker<DB>],
interval: Duration,
signal_rx: watch::Receiver<bool>,
pass: F,
) -> Option<tokio::task::JoinHandle<()>>
where
DB: Database + Send + Sync + 'static,
F: Fn(Arc<JobQueue<DB>>) -> Fut + Send + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
let queue = Arc::clone(&workers.first()?.queue);
Some(spawn_periodic(interval, signal_rx, move || {
pass(Arc::clone(&queue))
}))
}
impl<DB: Database> Drop for WorkerPool<DB> {
fn drop(&mut self) {
if let Some(task) = &self.autoscale_task {
task.abort();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "postgres")]
use crate::worker::tests::lazy_worker;
#[tokio::test]
async fn spawn_periodic_runs_until_shutdown() {
let runs = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let (signal_tx, signal_rx) = watch::channel(false);
let counter = Arc::clone(&runs);
let task = spawn_periodic(Duration::from_millis(10), signal_rx, move || {
let counter = Arc::clone(&counter);
async move {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
});
tokio::time::sleep(Duration::from_millis(100)).await;
signal_tx.send_replace(true);
tokio::time::timeout(Duration::from_secs(5), task)
.await
.expect("the loop stops on shutdown")
.unwrap();
let seen = runs.load(std::sync::atomic::Ordering::SeqCst);
assert!(seen >= 2, "ran {seen} times");
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(runs.load(std::sync::atomic::Ordering::SeqCst), seen);
}
#[tokio::test]
async fn spawn_periodic_never_cancels_a_run() {
use std::sync::atomic::{AtomicBool, Ordering};
let started = Arc::new(tokio::sync::Notify::new());
let finished = Arc::new(AtomicBool::new(false));
let (signal_tx, signal_rx) = watch::channel(false);
let (run_started, run_finished) = (Arc::clone(&started), Arc::clone(&finished));
let task = spawn_periodic(Duration::from_secs(3600), signal_rx, move || {
let (started, finished) = (Arc::clone(&run_started), Arc::clone(&run_finished));
async move {
started.notify_one();
tokio::time::sleep(Duration::from_millis(200)).await;
finished.store(true, Ordering::SeqCst);
}
});
started.notified().await;
signal_tx.send_replace(true);
task.abort();
assert!(!finished.load(Ordering::SeqCst));
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
finished.load(Ordering::SeqCst),
"the in-flight run completed after the loop was stopped"
);
}
#[cfg(any(feature = "postgres", feature = "mysql"))]
#[test]
fn encrypted_job_purge_is_opt_in_and_configurable() {
#[cfg(feature = "postgres")]
type Db = sqlx::Postgres;
#[cfg(all(feature = "mysql", not(feature = "postgres")))]
type Db = sqlx::MySql;
let pool = WorkerPool::<Db>::new();
assert_eq!(pool.encrypted_job_purge_interval, None);
let pool = pool.with_encrypted_job_purge(Duration::from_secs(30));
assert_eq!(
pool.encrypted_job_purge_interval,
Some(Duration::from_secs(30))
);
assert_eq!(
pool.without_encrypted_job_purge()
.encrypted_job_purge_interval,
None
);
}
#[cfg(any(feature = "postgres", feature = "mysql"))]
#[test]
fn result_cleanup_is_on_by_default_and_configurable() {
#[cfg(feature = "postgres")]
type Db = sqlx::Postgres;
#[cfg(all(feature = "mysql", not(feature = "postgres")))]
type Db = sqlx::MySql;
let pool = WorkerPool::<Db>::new();
assert_eq!(
pool.result_cleanup_interval,
Some(DEFAULT_RESULT_CLEANUP_INTERVAL)
);
assert_eq!(DEFAULT_RESULT_CLEANUP_INTERVAL, Duration::from_secs(300));
let pool = pool.with_result_cleanup(Duration::from_secs(42));
assert_eq!(pool.result_cleanup_interval, Some(Duration::from_secs(42)));
assert_eq!(pool.without_result_cleanup().result_cleanup_interval, None);
}
#[cfg(feature = "postgres")]
#[tokio::test]
async fn pool_from_config_applies_the_result_cleanup_settings() {
let mut config = crate::HammerworkConfig::new();
let pool = WorkerPool::from_hammerwork_config(lazy_worker("r"), &config).unwrap();
assert_eq!(
pool.result_cleanup_interval,
Some(DEFAULT_RESULT_CLEANUP_INTERVAL),
"on by default"
);
config.worker.result_cleanup_interval = Duration::from_secs(60);
let pool = WorkerPool::from_hammerwork_config(lazy_worker("r"), &config).unwrap();
assert_eq!(pool.result_cleanup_interval, Some(Duration::from_secs(60)));
config.worker.result_cleanup_enabled = false;
let pool = WorkerPool::from_config(lazy_worker("r"), &config.worker);
assert_eq!(pool.result_cleanup_interval, None);
config.worker.result_cleanup_enabled = true;
config.worker.result_cleanup_interval = Duration::ZERO;
assert!(
WorkerPool::from_hammerwork_config(lazy_worker("r"), &config).is_err(),
"a zero interval is rejected"
);
}
#[cfg(all(feature = "postgres", feature = "encryption"))]
#[tokio::test]
async fn pool_from_config_schedules_key_rotation_when_enabled() {
let mut config = crate::HammerworkConfig::new();
let pool = WorkerPool::from_hammerwork_config(lazy_worker("k"), &config).unwrap();
assert!(pool.key_rotation.is_none(), "off by default");
config.encryption.key_rotation.enabled = true;
config.encryption.key_rotation.check_interval_secs = 90;
let pool = WorkerPool::from_hammerwork_config(lazy_worker("k"), &config).unwrap();
let rotation = pool.key_rotation.as_ref().expect("rotation scheduled");
assert_eq!(rotation.interval, Duration::from_secs(90));
assert!(pool.without_key_rotation().key_rotation.is_none());
config.encryption.key_rotation.check_interval_secs = 0;
assert!(WorkerPool::from_hammerwork_config(lazy_worker("k"), &config).is_err());
}
#[cfg(all(feature = "postgres", feature = "encryption"))]
#[tokio::test]
async fn pool_with_unloadable_master_key_fails_to_start() {
let mut config = crate::HammerworkConfig::new();
config.encryption.key_rotation.enabled = true;
config.encryption.key_rotation.master_key_source =
crate::config::KeySourceRef::parse("env://HAMMERWORK_TEST_UNSET_MASTER_KEY_7F3A")
.unwrap();
let mut pool = WorkerPool::from_hammerwork_config(lazy_worker("k"), &config).unwrap();
let err = pool.start().await.unwrap_err();
assert!(
matches!(err, HammerworkError::Encryption { .. }),
"unexpected error: {err}"
);
assert!(pool.shutdown_signal.is_none(), "no worker was started");
}
#[cfg(all(feature = "postgres", not(feature = "encryption")))]
#[tokio::test]
async fn key_rotation_without_the_encryption_feature_is_rejected() {
let mut config = crate::HammerworkConfig::new();
config.encryption.key_rotation.enabled = true;
let err = WorkerPool::from_hammerwork_config(lazy_worker("k"), &config)
.err()
.expect("rejected");
assert!(err.to_string().contains("`encryption` feature"), "{err}");
}
#[cfg(any(feature = "postgres", feature = "mysql"))]
#[test]
fn archival_is_opt_in() {
#[cfg(feature = "postgres")]
type Db = sqlx::Postgres;
#[cfg(all(feature = "mysql", not(feature = "postgres")))]
type Db = sqlx::MySql;
let pool = WorkerPool::<Db>::new();
assert!(pool.archival.is_none());
let policy = crate::archive::ArchivalPolicy::new().with_batch_size(7);
let pool = pool.with_archival(
policy,
crate::archive::ArchivalConfig::new().with_compression_level(2),
Duration::from_secs(90),
);
let archival = pool.archival.as_ref().unwrap();
assert_eq!(archival.interval, Duration::from_secs(90));
assert_eq!(archival.policy.batch_size, 7);
assert_eq!(archival.config.compression_level, 2);
assert!(pool.without_archival().archival.is_none());
}
#[cfg(feature = "postgres")]
#[tokio::test]
async fn pool_from_config_uses_pool_size_and_autoscaling() {
let config = crate::config::WorkerConfig {
pool_size: 3,
autoscaling_enabled: true,
min_workers: 2,
max_workers: 6,
..Default::default()
};
let pool = WorkerPool::from_config(lazy_worker("p").with_config(&config), &config);
assert_eq!(pool.workers.len(), 3);
assert!(pool.worker_template.is_some());
assert!(pool.autoscale_config.enabled);
assert_eq!(pool.autoscale_config.min_workers, 2);
assert_eq!(pool.autoscale_config.max_workers, 6);
let config = crate::config::WorkerConfig {
pool_size: 0,
..Default::default()
};
let pool = WorkerPool::from_config(lazy_worker("p"), &config);
assert_eq!(pool.workers.len(), 1, "a pool always has a worker");
assert!(!pool.autoscale_config.enabled);
}
#[cfg(feature = "postgres")]
#[tokio::test]
async fn pool_from_hammerwork_config_schedules_purge_and_validates_encryption() {
let mut config = crate::HammerworkConfig::new();
config.worker.pool_size = 2;
config.encryption.purge_interval_secs = Some(90);
let pool = WorkerPool::from_hammerwork_config(lazy_worker("p"), &config).unwrap();
assert_eq!(pool.workers.len(), 2);
assert_eq!(
pool.encrypted_job_purge_interval,
Some(Duration::from_secs(90))
);
config.encryption.purge_interval_secs = Some(0);
assert!(
WorkerPool::from_hammerwork_config(lazy_worker("p"), &config).is_err(),
"an invalid encryption section is rejected"
);
}
#[cfg(feature = "postgres")]
#[tokio::test]
async fn pool_stats_collector_is_applied_to_workers() {
use crate::stats::InMemoryStatsCollector;
let stats: Arc<dyn StatisticsCollector> = Arc::new(InMemoryStatsCollector::new_default());
let mut pool = WorkerPool::new().with_stats_collector(Arc::clone(&stats));
pool.add_worker(lazy_worker("s"));
assert!(Arc::ptr_eq(
pool.workers[0].stats_collector.as_ref().unwrap(),
&stats
));
assert!(Arc::ptr_eq(
pool.stats_collector().as_ref().unwrap(),
&stats
));
let mut pool = WorkerPool::new().with_autoscaling(AutoscaleConfig::new());
assert!(pool.worker_template.is_none());
pool.add_worker(lazy_worker("s"));
assert!(pool.worker_template.is_some());
let mut pool = WorkerPool::new().without_autoscaling();
pool.add_worker(lazy_worker("s"));
assert!(pool.worker_template.is_none());
let mut pool = WorkerPool::new();
assert!(!pool.autoscale_config.enabled);
pool.add_worker(lazy_worker("s"));
assert!(pool.worker_template.is_none());
}
#[cfg(feature = "postgres")]
#[tokio::test]
async fn stats_collector_set_after_workers_reaches_them() {
use crate::stats::InMemoryStatsCollector;
let stats: Arc<dyn StatisticsCollector> = Arc::new(InMemoryStatsCollector::new_default());
let config = crate::config::WorkerConfig {
pool_size: 2,
..Default::default()
};
let pool = WorkerPool::from_config(lazy_worker("late_stats"), &config)
.with_stats_collector(Arc::clone(&stats));
assert_eq!(pool.workers.len(), 2);
for worker in &pool.workers {
assert!(Arc::ptr_eq(
worker.stats_collector.as_ref().unwrap(),
&stats
));
}
}
}