1use std::{collections::HashMap, panic::AssertUnwindSafe, sync::Arc, time::Duration};
39
40use futures::{FutureExt, future::BoxFuture};
41use tokio::{sync::Semaphore, task::AbortHandle};
42use tokio_util::sync::CancellationToken;
43use tracing::{Instrument, debug, error, info, warn};
44
45use crate::{
46 Job, JobCtx, Payload, ReserveOptions, ReserveOptionsError,
47 client::{Lease, LeaseError, RetryDirective, SeppClient},
48 now_millis,
49};
50
51const EARLY_EMPTY_THRESHOLD: Duration = Duration::from_millis(100);
55const EARLY_EMPTY_BACKOFF: Duration = Duration::from_millis(250);
57
58type Handler = Arc<
59 dyn Fn(Option<Payload>, Arc<JobCtx>) -> BoxFuture<'static, Result<(), HandlerError>>
60 + Send
61 + Sync,
62>;
63
64fn wrap_handler<F, Fut>(h: F) -> Handler
65where
66 F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
67 Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
68{
69 let h = Arc::new(h);
70 Arc::new(move |payload, ctx| Box::pin(h(payload, ctx)))
71}
72
73#[derive(Debug, thiserror::Error)]
83pub enum HandlerError {
84 #[error("retry: {0}")]
86 Retry(String),
87 #[error("retry after {1:?}: {0}")]
89 RetryAfter(String, Duration),
90 #[error("permanent: {0}")]
92 Permanent(String),
93}
94
95impl HandlerError {
96 pub fn retry(reason: impl Into<String>) -> Self {
98 Self::Retry(reason.into())
99 }
100 pub fn retry_after(reason: impl Into<String>, delay: Duration) -> Self {
102 Self::RetryAfter(reason.into(), delay)
103 }
104 pub fn permanent(reason: impl Into<String>) -> Self {
107 Self::Permanent(reason.into())
108 }
109}
110
111#[derive(Debug, thiserror::Error)]
113pub enum WorkerBuilderError {
114 #[error("handler for job_type {0:?} is already registered")]
117 DuplicateHandler(String),
118 #[error(transparent)]
121 ReserveOptions(#[from] ReserveOptionsError),
122}
123
124pub struct Worker {
139 client: SeppClient,
140 opts: ReserveOptions,
141 handlers: HashMap<String, Handler>,
142 catch_all_handler: Option<Handler>,
143 max_in_flight: usize,
144 reserve_error_backoff: Duration,
145 auto_extend: Option<AutoExtend>,
146 shutdown: ShutdownHandle,
147 metrics: Arc<Metrics>,
148}
149
150#[derive(Debug, Clone, Copy)]
151struct AutoExtend {
152 explicit_interval: Option<Duration>,
158 extend_by: Duration,
159}
160
161#[cfg(feature = "opentelemetry")]
162struct Metrics {
163 jobs_processed: opentelemetry::metrics::Counter<u64>,
164 jobs_nacked: opentelemetry::metrics::Counter<u64>,
165 jobs_in_flight: opentelemetry::metrics::UpDownCounter<i64>,
166 reserves_completed: opentelemetry::metrics::Counter<u64>,
167 reserves_failed: opentelemetry::metrics::Counter<u64>,
168}
169
170#[cfg(not(feature = "opentelemetry"))]
171struct Metrics;
172
173impl Metrics {
174 #[cfg(feature = "opentelemetry")]
175 fn new() -> Self {
176 let meter = opentelemetry::global::meter("sepp-rs");
177 Self {
178 jobs_processed: meter
179 .u64_counter("sepp_rs.jobs.processed")
180 .with_description("Jobs successfully acked.")
181 .build(),
182 jobs_nacked: meter
183 .u64_counter("sepp_rs.jobs.nacked")
184 .with_description("Jobs nacked. Attribute `outcome` is `retry` or `dead_letter`.")
185 .build(),
186 jobs_in_flight: meter
187 .i64_up_down_counter("sepp_rs.jobs.in_flight")
188 .with_description("Jobs currently being processed by handlers.")
189 .build(),
190 reserves_completed: meter
191 .u64_counter("sepp_rs.reserves.completed")
192 .with_description(
193 "Reserve RPCs that returned. Attribute `jobs` is `some` or `empty`.",
194 )
195 .build(),
196 reserves_failed: meter
197 .u64_counter("sepp_rs.reserves.failed")
198 .with_description("Reserve RPCs that failed.")
199 .build(),
200 }
201 }
202
203 #[cfg(not(feature = "opentelemetry"))]
204 fn new() -> Self {
205 Self
206 }
207
208 fn record_processed(&self) {
209 #[cfg(feature = "opentelemetry")]
210 self.jobs_processed.add(1, &[]);
211 }
212
213 fn record_nacked(&self, dead_lettered: bool) {
214 #[cfg(feature = "opentelemetry")]
215 {
216 let outcome = if dead_lettered {
217 "dead_letter"
218 } else {
219 "retry"
220 };
221 self.jobs_nacked
222 .add(1, &[opentelemetry::KeyValue::new("outcome", outcome)]);
223 }
224 #[cfg(not(feature = "opentelemetry"))]
225 let _ = dead_lettered;
226 }
227
228 fn record_in_flight_delta(&self, delta: i64) {
229 #[cfg(feature = "opentelemetry")]
230 self.jobs_in_flight.add(delta, &[]);
231 #[cfg(not(feature = "opentelemetry"))]
232 let _ = delta;
233 }
234
235 fn record_reserve_ok(&self, empty: bool) {
236 #[cfg(feature = "opentelemetry")]
237 {
238 let jobs = if empty { "empty" } else { "some" };
239 self.reserves_completed
240 .add(1, &[opentelemetry::KeyValue::new("jobs", jobs)]);
241 }
242 #[cfg(not(feature = "opentelemetry"))]
243 let _ = empty;
244 }
245
246 fn record_reserve_failed(&self) {
247 #[cfg(feature = "opentelemetry")]
248 self.reserves_failed.add(1, &[]);
249 }
250}
251
252struct InFlightGuard {
253 metrics: Arc<Metrics>,
254}
255
256impl InFlightGuard {
257 fn new(metrics: Arc<Metrics>) -> Self {
258 metrics.record_in_flight_delta(1);
259 Self { metrics }
260 }
261}
262
263impl Drop for InFlightGuard {
264 fn drop(&mut self) {
265 self.metrics.record_in_flight_delta(-1);
266 }
267}
268
269#[derive(Debug, Clone)]
277pub struct ShutdownHandle {
278 token: CancellationToken,
279}
280
281impl ShutdownHandle {
282 fn new() -> Self {
283 Self {
284 token: CancellationToken::new(),
285 }
286 }
287
288 pub fn shutdown(&self) {
290 self.token.cancel();
291 }
292
293 pub fn is_shutdown(&self) -> bool {
295 self.token.is_cancelled()
296 }
297}
298
299impl Worker {
300 pub fn new(
309 client: SeppClient,
310 queues: impl IntoIterator<Item = impl Into<String>>,
311 lease_duration: Duration,
312 ) -> Result<Self, WorkerBuilderError> {
313 let mut opts = ReserveOptions::new(queues, lease_duration)?;
314 opts.worker_id = Some(default_worker_id());
315 Ok(Self {
316 client,
317 opts,
318 handlers: HashMap::new(),
319 catch_all_handler: None,
320 max_in_flight: 16,
321 reserve_error_backoff: Duration::from_secs(1),
322 auto_extend: None,
323 shutdown: ShutdownHandle::new(),
324 metrics: Arc::new(Metrics::new()),
325 })
326 }
327
328 pub fn with_wait_timeout(mut self, wait: Duration) -> Self {
336 assert!(!wait.is_zero(), "wait_timeout must be non-zero");
337 self.opts.wait_timeout = wait;
338 self
339 }
340
341 pub fn with_max_jobs(mut self, max: u32) -> Self {
350 assert!(max >= 1, "max_jobs must be at least 1");
351 self.opts.max_jobs = Some(max);
352 self
353 }
354
355 pub fn shutdown_handle(&self) -> ShutdownHandle {
358 self.shutdown.clone()
359 }
360
361 pub fn with_auto_extend(mut self) -> Self {
370 self.auto_extend = Some(AutoExtend {
371 explicit_interval: None,
372 extend_by: self.opts.lease_duration,
373 });
374 self
375 }
376
377 pub fn with_auto_extend_interval(mut self, interval: Duration) -> Self {
381 self.auto_extend = Some(AutoExtend {
382 explicit_interval: Some(interval.max(Duration::from_millis(1))),
383 extend_by: self.opts.lease_duration,
384 });
385 self
386 }
387
388 pub fn with_max_in_flight(mut self, max_in_flight: usize) -> Self {
391 self.max_in_flight = max_in_flight;
392 self
393 }
394
395 pub fn with_reserve_error_backoff(mut self, backoff: Duration) -> Self {
398 self.reserve_error_backoff = backoff;
399 self
400 }
401
402 pub fn with_worker_id(
404 mut self,
405 worker_id: impl Into<String>,
406 ) -> Result<Self, WorkerBuilderError> {
407 let id = worker_id.into();
408 if id.is_empty() {
409 return Err(ReserveOptionsError::EmptyWorkerId.into());
410 }
411 self.opts.worker_id = Some(id);
412 Ok(self)
413 }
414
415 pub fn handle<F, Fut>(mut self, job_type: &str, h: F) -> Result<Self, WorkerBuilderError>
422 where
423 F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
424 Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
425 {
426 if self.handlers.contains_key(job_type) {
427 return Err(WorkerBuilderError::DuplicateHandler(job_type.to_string()));
428 }
429 self.handlers.insert(job_type.to_string(), wrap_handler(h));
430 Ok(self)
431 }
432
433 pub fn with_catch_all_handler<F, Fut>(mut self, h: F) -> Self
435 where
436 F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
437 Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
438 {
439 self.catch_all_handler = Some(wrap_handler(h));
440 self
441 }
442
443 pub fn replace_handler<F, Fut>(mut self, job_type: &str, h: F) -> Self
446 where
447 F: Fn(Option<Payload>, Arc<JobCtx>) -> Fut + Send + Sync + 'static,
448 Fut: Future<Output = Result<(), HandlerError>> + Send + 'static,
449 {
450 self.handlers.insert(job_type.to_string(), wrap_handler(h));
451 self
452 }
453
454 pub fn remove_handler(mut self, job_type: &str) -> Self {
457 self.handlers.remove(job_type);
458 self
459 }
460
461 pub async fn run(self) {
479 let max_permits = self.max_in_flight.max(1);
480 let semaphore = Arc::new(Semaphore::new(max_permits));
481 let handlers = Arc::new(self.handlers);
482 let auto_extend = self.auto_extend;
483 let shutdown = self.shutdown.clone();
484 let metrics = Arc::clone(&self.metrics);
485 info!(
486 worker_id = self.opts.worker_id.as_deref().unwrap_or("<none>"),
487 max_in_flight = self.max_in_flight,
488 handlers = handlers.len(),
489 auto_extend = auto_extend.is_some(),
490 "worker started"
491 );
492
493 'outer: loop {
494 let permit = tokio::select! {
495 biased;
496 () = shutdown.token.cancelled() => break 'outer,
497 p = semaphore.clone().acquire_owned() => p.expect("semaphore is never closed"),
498 };
499
500 let mut opts = self.opts.clone();
501 let capacity = (1 + semaphore.available_permits()).min(u32::MAX as usize) as u32;
502 opts.max_jobs = Some(match opts.max_jobs {
503 Some(user_max) => user_max.min(capacity),
504 None => capacity,
505 });
506
507 let reserve_started = tokio::time::Instant::now();
513 let jobs = tokio::select! {
514 biased;
515 res = self.client.reserve(&opts) => match res {
516 Ok(Some(jobs)) => {
517 metrics.record_reserve_ok(false);
518 jobs
519 }
520 Ok(None) => {
521 metrics.record_reserve_ok(true);
522 if reserve_started.elapsed() < EARLY_EMPTY_THRESHOLD.min(opts.wait_timeout / 2) {
530 drop(permit);
531 tokio::select! {
532 biased;
533 () = shutdown.token.cancelled() => break 'outer,
534 () = tokio::time::sleep(EARLY_EMPTY_BACKOFF) => {},
535 }
536 }
537 continue;
538 }
539 Err(_err) => {
540 metrics.record_reserve_failed();
541 warn!(
542 "reserve error: {_err}; backing off for {:?}",
543 self.reserve_error_backoff
544 );
545 drop(permit);
546 tokio::select! {
547 biased;
548 () = shutdown.token.cancelled() => break 'outer,
549 () = tokio::time::sleep(self.reserve_error_backoff) => continue,
550 }
551 }
552 },
553 () = shutdown.token.cancelled() => {
554 drop(permit);
555 break 'outer;
556 }
557 };
558
559 let mut jobs = jobs.into_iter();
560 let Some(first) = jobs.next() else { continue };
561 {
562 let client = self.client.clone();
563 let handlers = Arc::clone(&handlers);
564 let catch_all_handler = self.catch_all_handler.clone();
565 let metrics = Arc::clone(&metrics);
566 let in_flight = InFlightGuard::new(Arc::clone(&metrics));
567 tokio::spawn(async move {
568 let _permit = permit; let _in_flight = in_flight;
570 process_job(
571 &client,
572 &handlers,
573 &catch_all_handler,
574 auto_extend,
575 first,
576 &metrics,
577 )
578 .await;
579 });
580 }
581
582 for job in jobs {
583 let permit = semaphore
584 .clone()
585 .acquire_owned()
586 .await
587 .expect("semaphore is never closed");
588 let client = self.client.clone();
589 let handlers = Arc::clone(&handlers);
590 let catch_all_handler = self.catch_all_handler.clone();
591 let metrics = Arc::clone(&metrics);
592 let in_flight = InFlightGuard::new(Arc::clone(&metrics));
593 tokio::spawn(async move {
594 let _permit = permit;
595 let _in_flight = in_flight;
596 process_job(
597 &client,
598 &handlers,
599 &catch_all_handler,
600 auto_extend,
601 job,
602 &metrics,
603 )
604 .await;
605 });
606 }
607 }
608
609 info!("worker shutting down; waiting for in-flight jobs to finish");
610 let _drain = semaphore
611 .acquire_many(max_permits as u32)
612 .await
613 .expect("semaphore is never closed");
614 info!("worker stopped");
615 }
616}
617
618async fn process_job(
619 client: &SeppClient,
620 handlers: &HashMap<String, Handler>,
621 catch_all_handler: &Option<Handler>,
622 auto_extend: Option<AutoExtend>,
623 job: Job,
624 metrics: &Metrics,
625) {
626 let span = tracing::info_span!(
627 "sepp-rs.process",
628 otel.kind = "consumer",
629 otel.status_code = tracing::field::Empty,
630 job_id = %job.ctx.id,
631 job_type = %job.ctx.job_type,
632 attempt = job.ctx.attempt,
633 );
634
635 #[cfg(feature = "opentelemetry")]
636 if let Some(link) = job
637 .ctx
638 .trace_context
639 .as_ref()
640 .and_then(crate::TraceContext::otel_span_context)
641 {
642 use tracing_opentelemetry::OpenTelemetrySpanExt;
643 span.add_link(link);
644 }
645
646 run_job(
647 client,
648 handlers,
649 catch_all_handler,
650 auto_extend,
651 job,
652 metrics,
653 )
654 .instrument(span)
655 .await
656}
657
658async fn run_job(
659 client: &SeppClient,
660 handlers: &HashMap<String, Handler>,
661 catch_all_handler: &Option<Handler>,
662 auto_extend: Option<AutoExtend>,
663 job: Job,
664 metrics: &Metrics,
665) {
666 let Job { payload, ctx } = job;
667 let lease = ctx.lease.clone();
668 let ctx = Arc::new(ctx);
669
670 let Some(handler) = handlers.get(&ctx.job_type).or(catch_all_handler.as_ref()) else {
671 warn!("no handler registered for job_type `{}`", ctx.job_type);
672
673 if let Err(err) = client
677 .nack(
678 &ctx,
679 RetryDirective::After(nack_backoff(ctx.attempt)),
680 "no handler registered for job_type",
681 )
682 .await
683 {
684 warn!("failed to nack job with no registered handler: {err}");
685 }
686 return;
687 };
688
689 let fut = handler(payload, Arc::clone(&ctx));
690
691 let disposition = match auto_extend {
692 None => match AssertUnwindSafe(fut).catch_unwind().await {
693 Ok(result) => Disposition::Completed(result),
694 Err(_panic) => Disposition::Panicked,
695 },
696 Some(cfg) => {
697 let handler_task = tokio::spawn(fut);
698 let abort = handler_task.abort_handle();
699 let heartbeat_task = tokio::spawn(heartbeat(lease, cfg, abort));
700
701 let joined = handler_task.await;
702 heartbeat_task.abort(); match joined {
705 Ok(result) => Disposition::Completed(result),
706 Err(err) if err.is_cancelled() => {
707 error!("lease lost; handler aborted");
708 return;
709 }
710 Err(_panic) => Disposition::Panicked,
711 }
712 }
713 };
714
715 if let Err(err) = dispose(client, &ctx, disposition, metrics).await {
716 error!(
717 "failed to ack/nack job: {err}; either the lease was lost and the job will be redelivered, or a retried attempt already succeeded and only its response was lost"
718 );
719 }
720}
721
722enum Disposition {
723 Completed(Result<(), HandlerError>),
724 Panicked,
725}
726
727async fn dispose(
728 client: &SeppClient,
729 ctx: &JobCtx,
730 disposition: Disposition,
731 metrics: &Metrics,
732) -> Result<(), LeaseError> {
733 match disposition {
734 Disposition::Completed(Ok(())) => {
735 debug!("job completed; acking");
736 client.ack(ctx).await?;
737 metrics.record_processed();
738 Ok(())
739 }
740 Disposition::Completed(Err(err)) => {
741 tracing::Span::current().record("otel.status_code", "error");
742 warn!("handler returned error; nacking: {err}");
743 let (retry, reason) = match err {
744 HandlerError::Retry(r) => (RetryDirective::Default, r),
745 HandlerError::RetryAfter(r, d) => (RetryDirective::After(d), r),
746 HandlerError::Permanent(r) => (RetryDirective::DeadLetter, r),
747 };
748 let dead_lettered = client.nack(ctx, retry, reason).await?;
749 metrics.record_nacked(dead_lettered);
750 Ok(())
751 }
752 Disposition::Panicked => {
753 tracing::Span::current().record("otel.status_code", "error");
754 error!("handler panicked; nacking");
755 let dead_lettered = client
758 .nack(
759 ctx,
760 RetryDirective::After(nack_backoff(ctx.attempt)),
761 "handler panicked",
762 )
763 .await?;
764 metrics.record_nacked(dead_lettered);
765 Ok(())
766 }
767 }
768}
769
770fn nack_backoff(attempt: u32) -> Duration {
773 Duration::from_secs(2u64.saturating_pow(attempt).min(60))
774}
775
776async fn heartbeat(lease: Lease, cfg: AutoExtend, handler: AbortHandle) {
777 loop {
778 let interval = cfg.explicit_interval.unwrap_or_else(|| {
780 let remaining_ms = lease.known_expiry_ms().saturating_sub(now_millis()).max(0) as u64;
781 heartbeat_interval(Duration::from_millis(remaining_ms))
782 });
783 tokio::time::sleep(interval).await;
784
785 match lease.extend(cfg.extend_by).await {
786 Ok(expiry) => debug!(?expiry, "lease extended"),
787 Err(err @ (LeaseError::AttemptMismatch | LeaseError::JobNotFound)) => {
788 error!(
789 "lease reassigned by server ({err}); aborting handler to avoid double processing"
790 );
791 handler.abort();
792 return;
793 }
794 Err(err) => {
795 if now_millis() >= lease.known_expiry_ms() {
796 error!("lease lost ({err}); aborting handler to avoid double processing");
797 handler.abort();
798 return;
799 }
800 warn!("lease extend failed ({err}); lease still valid, will retry");
801 }
802 }
803 }
804}
805
806fn heartbeat_interval(lease: Duration) -> Duration {
807 (lease / 3).max(Duration::from_millis(1))
808}
809
810fn default_worker_id() -> String {
811 let host = hostname();
812 let rand = uuid::Uuid::new_v4().simple().to_string();
813 format!("{host}-{}-{}", std::process::id(), &rand[..8])
814}
815
816fn hostname() -> String {
820 std::env::var("HOSTNAME")
821 .or_else(|_| std::env::var("COMPUTERNAME"))
822 .ok()
823 .filter(|h| !h.is_empty())
824 .unwrap_or_else(|| gethostname::gethostname().to_string_lossy().into_owned())
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 #[test]
832 fn heartbeat_interval_third_of_lease() {
833 assert_eq!(
834 heartbeat_interval(Duration::from_secs(3)),
835 Duration::from_secs(1)
836 );
837 }
838
839 #[test]
840 fn heartbeat_interval_nine_seconds() {
841 assert_eq!(
842 heartbeat_interval(Duration::from_secs(9)),
843 Duration::from_secs(3)
844 );
845 }
846
847 #[test]
848 fn heartbeat_interval_floor_at_one_ms_for_tiny_lease() {
849 assert_eq!(
850 heartbeat_interval(Duration::from_millis(1)),
851 Duration::from_millis(1)
852 );
853 }
854
855 #[test]
856 fn heartbeat_interval_floor_at_one_ms_for_zero_lease() {
857 assert_eq!(heartbeat_interval(Duration::ZERO), Duration::from_millis(1));
858 }
859
860 #[test]
861 fn worker_err_retry() {
862 let e = HandlerError::retry("network");
863 assert!(matches!(e, HandlerError::Retry(s) if s == "network"));
864 }
865
866 #[test]
867 fn worker_err_retry_after() {
868 let e = HandlerError::retry_after("rate limited", Duration::from_secs(5));
869 assert!(matches!(
870 e,
871 HandlerError::RetryAfter(s, d) if s == "rate limited" && d == Duration::from_secs(5)
872 ));
873 }
874
875 #[test]
876 fn worker_err_permanent() {
877 let e = HandlerError::permanent("bad input");
878 assert!(matches!(e, HandlerError::Permanent(s) if s == "bad input"));
879 }
880
881 #[test]
882 fn shutdown_handle_starts_unsignaled() {
883 let h = ShutdownHandle::new();
884 assert!(!h.is_shutdown());
885 }
886
887 #[test]
888 fn shutdown_handle_is_signaled_after_shutdown() {
889 let h = ShutdownHandle::new();
890 h.shutdown();
891 assert!(h.is_shutdown());
892 }
893
894 #[test]
895 fn shutdown_handle_clones_share_state() {
896 let h = ShutdownHandle::new();
897 let h2 = h.clone();
898 h.shutdown();
899 assert!(h2.is_shutdown());
900 }
901
902 #[tokio::test]
903 async fn shutdown_handle_cancelled_resolves_when_already_signaled() {
904 let h = ShutdownHandle::new();
905 h.shutdown();
906 tokio::time::timeout(Duration::from_secs(1), h.token.cancelled())
907 .await
908 .expect("cancelled() should resolve immediately when already signaled");
909 }
910
911 #[tokio::test]
912 async fn shutdown_handle_cancelled_resolves_on_late_signal() {
913 let h = ShutdownHandle::new();
914 let h2 = h.clone();
915 tokio::spawn(async move {
916 tokio::time::sleep(Duration::from_millis(10)).await;
917 h2.shutdown();
918 });
919 tokio::time::timeout(Duration::from_secs(1), h.token.cancelled())
920 .await
921 .expect("cancelled() should be woken by shutdown signal");
922 }
923
924 fn worker_test_client() -> SeppClient {
925 let chan = tonic::transport::Endpoint::from_static("http://[::1]:1").connect_lazy();
926 SeppClient::from_channel(chan)
927 }
928
929 async fn dummy_ok_handler(
930 _payload: Option<Payload>,
931 _ctx: Arc<JobCtx>,
932 ) -> Result<(), HandlerError> {
933 Ok(())
934 }
935
936 #[test]
937 fn default_worker_id_has_expected_format() {
938 let id = default_worker_id();
941 let parts: Vec<&str> = id.rsplitn(3, '-').collect();
942 assert_eq!(
943 parts.len(),
944 3,
945 "expected host-pid-rand dash-separated parts, got: {id}"
946 );
947 assert_eq!(parts[0].len(), 8, "expected 8-char hex suffix, got: {id}");
948 assert_eq!(
949 parts[1].parse::<u32>().ok(),
950 Some(std::process::id()),
951 "expected the middle part to be the PID, got: {id}"
952 );
953 assert!(!parts[2].is_empty(), "expected a hostname part, got: {id}");
954 }
955
956 #[test]
957 fn hostname_resolves_outside_containers() {
958 assert!(!hostname().is_empty());
961 }
962
963 #[test]
964 fn nack_backoff_grows_exponentially_and_caps_at_sixty_seconds() {
965 assert_eq!(nack_backoff(1), Duration::from_secs(2));
966 assert_eq!(nack_backoff(2), Duration::from_secs(4));
967 assert_eq!(nack_backoff(5), Duration::from_secs(32));
968 assert_eq!(nack_backoff(6), Duration::from_secs(60));
969 assert_eq!(nack_backoff(100), Duration::from_secs(60));
970 assert_eq!(nack_backoff(u32::MAX), Duration::from_secs(60));
971 }
972
973 #[test]
974 fn handler_error_display_retry() {
975 let e = HandlerError::retry("network timeout");
976 let s = e.to_string();
977 assert!(s.contains("retry"));
978 assert!(s.contains("network timeout"));
979 }
980
981 #[test]
982 fn handler_error_display_retry_after() {
983 let e = HandlerError::retry_after("rate limit", Duration::from_secs(30));
984 let s = e.to_string();
985 assert!(s.contains("retry after"));
986 assert!(s.contains("rate limit"));
987 }
988
989 #[test]
990 fn handler_error_display_permanent() {
991 let e = HandlerError::permanent("bad input");
992 let s = e.to_string();
993 assert!(s.contains("permanent"));
994 assert!(s.contains("bad input"));
995 }
996
997 #[test]
998 fn worker_builder_error_duplicate_handler_display() {
999 let e = WorkerBuilderError::DuplicateHandler("send_email".into());
1000 let s = e.to_string();
1001 assert!(s.contains("send_email"));
1002 assert!(s.contains("already registered"));
1003 }
1004
1005 #[test]
1006 fn worker_builder_error_from_reserve_options() {
1007 let e = WorkerBuilderError::from(ReserveOptionsError::EmptyWorkerId);
1008 let s = e.to_string();
1009 assert!(s.contains("worker_id"));
1010 }
1011
1012 #[tokio::test]
1013 async fn worker_new_rejects_empty_queues() {
1014 let client = worker_test_client();
1015 let result = Worker::new(client, Vec::<String>::new(), Duration::from_secs(1));
1016 assert!(matches!(
1017 result,
1018 Err(WorkerBuilderError::ReserveOptions(
1019 ReserveOptionsError::EmptyQueues
1020 ))
1021 ));
1022 }
1023
1024 #[tokio::test]
1025 async fn worker_new_rejects_zero_lease() {
1026 let client = worker_test_client();
1027 let result = Worker::new(client, ["q"], Duration::ZERO);
1028 assert!(matches!(
1029 result,
1030 Err(WorkerBuilderError::ReserveOptions(
1031 ReserveOptionsError::LeaseDurationTooShort
1032 ))
1033 ));
1034 }
1035
1036 #[tokio::test]
1037 async fn worker_new_succeeds_with_valid_args() {
1038 let client = worker_test_client();
1039 let _w = Worker::new(client, ["q"], Duration::from_secs(1)).unwrap();
1040 }
1041
1042 #[tokio::test]
1043 async fn worker_handle_rejects_duplicate_job_type() {
1044 let client = worker_test_client();
1045 let w = Worker::new(client, ["q"], Duration::from_secs(1))
1046 .unwrap()
1047 .handle("my_job", dummy_ok_handler)
1048 .unwrap();
1049 let result = w.handle("my_job", dummy_ok_handler);
1050 assert!(matches!(
1051 result,
1052 Err(WorkerBuilderError::DuplicateHandler(t)) if t == "my_job"
1053 ));
1054 }
1055
1056 #[tokio::test]
1057 async fn worker_replace_handler_overwrites() {
1058 let client = worker_test_client();
1059 let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1060 .unwrap()
1061 .handle("my_job", dummy_ok_handler)
1062 .unwrap()
1063 .replace_handler("my_job", dummy_ok_handler);
1064 }
1065
1066 #[tokio::test]
1067 async fn worker_remove_handler_allows_re_registration() {
1068 let client = worker_test_client();
1069 let w = Worker::new(client, ["q"], Duration::from_secs(1))
1070 .unwrap()
1071 .handle("my_job", dummy_ok_handler)
1072 .unwrap()
1073 .remove_handler("my_job");
1074 let _w = w.handle("my_job", dummy_ok_handler).unwrap();
1075 }
1076
1077 #[tokio::test]
1078 async fn worker_with_catch_all_handler_succeeds() {
1079 let client = worker_test_client();
1080 let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1081 .unwrap()
1082 .with_catch_all_handler(dummy_ok_handler);
1083 }
1084
1085 #[tokio::test]
1086 async fn worker_with_worker_id_rejects_empty() {
1087 let client = worker_test_client();
1088 let result = Worker::new(client, ["q"], Duration::from_secs(1))
1089 .unwrap()
1090 .with_worker_id("");
1091 assert!(matches!(
1092 result,
1093 Err(WorkerBuilderError::ReserveOptions(
1094 ReserveOptionsError::EmptyWorkerId
1095 ))
1096 ));
1097 }
1098
1099 #[tokio::test]
1100 #[should_panic(expected = "wait_timeout must be non-zero")]
1101 async fn worker_with_wait_timeout_zero_panics() {
1102 let client = worker_test_client();
1103 let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1104 .unwrap()
1105 .with_wait_timeout(Duration::ZERO);
1106 }
1107
1108 #[tokio::test]
1109 async fn worker_with_wait_timeout_accepts_non_zero() {
1110 let client = worker_test_client();
1111 let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1112 .unwrap()
1113 .with_wait_timeout(Duration::from_millis(1));
1114 }
1115
1116 #[tokio::test]
1117 #[should_panic(expected = "max_jobs must be at least 1")]
1118 async fn worker_with_max_jobs_zero_panics() {
1119 let client = worker_test_client();
1120 let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1121 .unwrap()
1122 .with_max_jobs(0);
1123 }
1124
1125 #[tokio::test]
1126 async fn worker_with_max_jobs_valid() {
1127 let client = worker_test_client();
1128 let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1129 .unwrap()
1130 .with_max_jobs(5)
1131 .handle("t", dummy_ok_handler)
1132 .unwrap();
1133 }
1134
1135 #[tokio::test]
1136 async fn worker_with_max_in_flight_lower_bound() {
1137 let client = worker_test_client();
1138 let _w = Worker::new(client, ["q"], Duration::from_secs(1))
1139 .unwrap()
1140 .with_max_in_flight(1)
1141 .handle("t", dummy_ok_handler)
1142 .unwrap();
1143 }
1144
1145 #[tokio::test]
1146 async fn worker_shutdown_handle_returns_untriggered_handle() {
1147 let client = worker_test_client();
1148 let w = Worker::new(client, ["q"], Duration::from_secs(1)).unwrap();
1149 let h = w.shutdown_handle();
1150 assert!(!h.is_shutdown());
1151 }
1152
1153 #[test]
1154 fn in_flight_guard_does_not_panic() {
1155 let metrics = Arc::new(Metrics::new());
1156 let _guard = InFlightGuard::new(metrics);
1157 }
1158}