1use std::{
83 collections::HashMap,
84 fmt,
85 time::{Duration, SystemTime},
86};
87
88mod pb;
89
90pub mod client;
91pub mod worker;
92
93#[derive(Debug, Clone, PartialEq)]
103pub enum Primitive {
104 String(String),
106 Int(i64),
108 Double(f64),
110 Bool(bool),
112}
113
114impl From<Primitive> for crate::pb::sepp::v1::PrimitiveValue {
115 fn from(p: Primitive) -> Self {
116 use crate::pb::sepp::v1::primitive_value::Value;
117
118 let value = match p {
119 Primitive::String(s) => Value::StringValue(s),
120 Primitive::Int(i) => Value::IntValue(i),
121 Primitive::Double(d) => Value::DoubleValue(d),
122 Primitive::Bool(b) => Value::BoolValue(b),
123 };
124 Self { value: Some(value) }
125 }
126}
127
128impl From<&str> for Primitive {
129 fn from(v: &str) -> Self {
130 Self::String(v.into())
131 }
132}
133impl From<String> for Primitive {
134 fn from(v: String) -> Self {
135 Self::String(v)
136 }
137}
138impl From<i64> for Primitive {
139 fn from(v: i64) -> Self {
140 Self::Int(v)
141 }
142}
143impl From<i32> for Primitive {
144 fn from(v: i32) -> Self {
145 Self::Int(v.into())
146 }
147}
148impl From<f64> for Primitive {
149 fn from(v: f64) -> Self {
150 Self::Double(v)
151 }
152}
153impl From<bool> for Primitive {
154 fn from(v: bool) -> Self {
155 Self::Bool(v)
156 }
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct Payload {
173 pub data: Vec<u8>,
175 pub encoding: String,
177}
178
179impl Payload {
180 pub fn new(data: Vec<u8>, encoding: impl Into<String>) -> Self {
183 Self {
184 data,
185 encoding: encoding.into(),
186 }
187 }
188}
189
190impl From<Payload> for crate::pb::sepp::v1::Payload {
191 fn from(p: Payload) -> Self {
192 Self {
193 data: p.data,
194 encoding: p.encoding,
195 }
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct Priority(u8);
213
214#[derive(Debug, thiserror::Error)]
216#[error("priority must be 0-9, got {0}")]
217pub struct PriorityOutOfRange(pub u8);
218
219impl Priority {
220 pub const MIN: Self = Self(0);
222 pub const MAX: Self = Self(9);
224
225 pub const P0: Self = Self(0);
227 pub const P1: Self = Self(1);
229 pub const P2: Self = Self(2);
231 pub const P3: Self = Self(3);
233 pub const P4: Self = Self(4);
235 pub const P5: Self = Self(5);
237 pub const P6: Self = Self(6);
239 pub const P7: Self = Self(7);
241 pub const P8: Self = Self(8);
243 pub const P9: Self = Self(9);
245
246 pub fn new(value: u8) -> Result<Self, PriorityOutOfRange> {
248 if value > Self::MAX.0 {
249 Err(PriorityOutOfRange(value))
250 } else {
251 Ok(Self(value))
252 }
253 }
254
255 pub fn get(&self) -> u8 {
257 self.0
258 }
259}
260
261impl TryFrom<u8> for Priority {
262 type Error = PriorityOutOfRange;
263 fn try_from(v: u8) -> Result<Self, Self::Error> {
264 Self::new(v)
265 }
266}
267
268#[derive(Debug, Clone, PartialEq)]
276pub struct TraceContext {
277 traceparent: String,
278 tracestate: Option<String>,
279}
280
281#[derive(Debug, thiserror::Error)]
284pub enum TraceContextError {
285 #[error("invalid traceparent: {0}")]
288 InvalidTraceparent(&'static str),
289}
290
291impl TraceContext {
292 pub fn new(traceparent: impl Into<String>) -> Result<Self, TraceContextError> {
297 let traceparent = traceparent.into();
298 validate_traceparent(&traceparent)?;
299 Ok(Self {
300 traceparent,
301 tracestate: None,
302 })
303 }
304
305 pub fn with_tracestate(mut self, ts: impl Into<String>) -> Self {
307 self.tracestate = Some(ts.into());
308 self
309 }
310
311 pub fn traceparent(&self) -> &str {
313 &self.traceparent
314 }
315 pub fn tracestate(&self) -> Option<&str> {
317 self.tracestate.as_deref()
318 }
319}
320
321#[cfg(feature = "opentelemetry")]
322impl TraceContext {
323 pub fn from_current_otel() -> Option<Self> {
328 use opentelemetry::propagation::TextMapPropagator;
329 use opentelemetry::trace::TraceContextExt;
330 use opentelemetry_sdk::propagation::TraceContextPropagator;
331
332 let cx = opentelemetry::Context::current();
333 if !cx.span().span_context().is_valid() {
334 return None;
335 }
336
337 let mut carrier = std::collections::HashMap::new();
338 TraceContextPropagator::new().inject_context(&cx, &mut HashMapInjector(&mut carrier));
339
340 let traceparent = carrier.remove("traceparent")?;
341 let tracestate = carrier.remove("tracestate");
342 Some(Self {
343 traceparent,
344 tracestate,
345 })
346 }
347
348 pub fn attach_to_otel(&self) -> opentelemetry::ContextGuard {
353 use opentelemetry::propagation::TextMapPropagator;
354 use opentelemetry_sdk::propagation::TraceContextPropagator;
355
356 let mut carrier = std::collections::HashMap::new();
357 carrier.insert("traceparent".to_string(), self.traceparent.clone());
358 if let Some(ts) = &self.tracestate {
359 carrier.insert("tracestate".to_string(), ts.clone());
360 }
361
362 let extracted = TraceContextPropagator::new().extract(&HashMapExtractor(&carrier));
363 extracted.attach()
364 }
365
366 pub fn otel_span_context(&self) -> Option<opentelemetry::trace::SpanContext> {
374 use opentelemetry::propagation::TextMapPropagator;
375 use opentelemetry::trace::TraceContextExt;
376 use opentelemetry_sdk::propagation::TraceContextPropagator;
377
378 let mut carrier = HashMap::new();
379 carrier.insert("traceparent".to_string(), self.traceparent.clone());
380 if let Some(ts) = &self.tracestate {
381 carrier.insert("tracestate".to_string(), ts.clone());
382 }
383 let cx = TraceContextPropagator::new().extract(&HashMapExtractor(&carrier));
384 let span_context = cx.span().span_context().clone();
385 span_context.is_valid().then_some(span_context)
386 }
387}
388
389#[cfg(feature = "opentelemetry")]
390pub(crate) fn inject_pb_trace_context(
391 cx: &opentelemetry::Context,
392) -> Option<crate::pb::sepp::v1::TraceContext> {
393 use opentelemetry::propagation::TextMapPropagator;
394 use opentelemetry::trace::TraceContextExt;
395 use opentelemetry_sdk::propagation::TraceContextPropagator;
396
397 if !cx.span().span_context().is_valid() {
398 return None;
399 }
400 let mut carrier = HashMap::new();
401 TraceContextPropagator::new().inject_context(cx, &mut HashMapInjector(&mut carrier));
402 Some(crate::pb::sepp::v1::TraceContext {
403 traceparent: carrier.remove("traceparent")?,
404 tracestate: carrier.remove("tracestate"),
405 })
406}
407
408#[cfg(feature = "opentelemetry")]
409struct HashMapInjector<'a>(&'a mut HashMap<String, String>);
410
411#[cfg(feature = "opentelemetry")]
412impl opentelemetry::propagation::Injector for HashMapInjector<'_> {
413 fn set(&mut self, key: &str, value: String) {
414 self.0.insert(key.to_string(), value);
415 }
416}
417
418#[cfg(feature = "opentelemetry")]
419struct HashMapExtractor<'a>(&'a HashMap<String, String>);
420
421#[cfg(feature = "opentelemetry")]
422impl opentelemetry::propagation::Extractor for HashMapExtractor<'_> {
423 fn get(&self, key: &str) -> Option<&str> {
424 self.0.get(key).map(String::as_str)
425 }
426
427 fn keys(&self) -> Vec<&str> {
428 self.0.keys().map(String::as_str).collect()
429 }
430}
431
432fn validate_traceparent(s: &str) -> Result<(), TraceContextError> {
433 let parts: Vec<&str> = s.split('-').collect();
435 if parts.len() != 4 {
436 return Err(TraceContextError::InvalidTraceparent(
437 "expected 4 hyphen-separated fields",
438 ));
439 }
440 let [ver, trace_id, span_id, flags] = [parts[0], parts[1], parts[2], parts[3]];
441 if ver.len() != 2 || !ver.chars().all(|c| c.is_ascii_hexdigit()) {
442 return Err(TraceContextError::InvalidTraceparent(
443 "version must be 2 hex chars",
444 ));
445 }
446 if trace_id.len() != 32 || !trace_id.chars().all(|c| c.is_ascii_hexdigit()) {
447 return Err(TraceContextError::InvalidTraceparent(
448 "trace_id must be 32 hex chars",
449 ));
450 }
451 if trace_id.bytes().all(|b| b == b'0') {
452 return Err(TraceContextError::InvalidTraceparent(
453 "trace_id must not be all zeros",
454 ));
455 }
456 if span_id.len() != 16 || !span_id.chars().all(|c| c.is_ascii_hexdigit()) {
457 return Err(TraceContextError::InvalidTraceparent(
458 "span_id must be 16 hex chars",
459 ));
460 }
461 if span_id.bytes().all(|b| b == b'0') {
462 return Err(TraceContextError::InvalidTraceparent(
463 "span_id must not be all zeros",
464 ));
465 }
466 if flags.len() != 2 || !flags.chars().all(|c| c.is_ascii_hexdigit()) {
467 return Err(TraceContextError::InvalidTraceparent(
468 "flags must be 2 hex chars",
469 ));
470 }
471 Ok(())
472}
473
474impl From<TraceContext> for crate::pb::sepp::v1::TraceContext {
475 fn from(tc: TraceContext) -> Self {
476 Self {
477 traceparent: tc.traceparent,
478 tracestate: tc.tracestate,
479 }
480 }
481}
482
483#[derive(Debug, Clone)]
507pub struct EnqueueRequest {
508 queue: String,
509 job_type: String,
510 payload: Option<Payload>,
511 idempotency_key: Option<String>,
512 priority: Option<Priority>,
513 max_attempts: Option<u32>,
514 custom: HashMap<String, Primitive>,
515 trace_context: Option<TraceContext>,
516 scheduled_at: Option<SystemTime>,
517}
518
519#[derive(Debug, thiserror::Error)]
525pub enum EnqueueRequestBuilderError {
526 #[error("queue name must not be empty")]
528 EmptyQueue,
529 #[error("job type must not be empty")]
531 EmptyJobType,
532}
533
534impl EnqueueRequest {
535 pub fn new(
539 queue: impl Into<String>,
540 job_type: impl Into<String>,
541 ) -> Result<Self, EnqueueRequestBuilderError> {
542 let queue = queue.into();
543 if queue.is_empty() {
544 return Err(EnqueueRequestBuilderError::EmptyQueue);
545 }
546 let job_type = job_type.into();
547 if job_type.is_empty() {
548 return Err(EnqueueRequestBuilderError::EmptyJobType);
549 }
550
551 Ok(Self {
552 queue,
553 job_type,
554 payload: None,
555 idempotency_key: None,
556 priority: None,
557 max_attempts: None,
558 custom: HashMap::new(),
559 trace_context: None,
560 scheduled_at: None,
561 })
562 }
563
564 pub fn with_payload(mut self, payload: Payload) -> Self {
567 self.payload = Some(payload);
568 self
569 }
570
571 pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
575 self.idempotency_key = Some(key.into());
576 self
577 }
578
579 pub fn with_priority(mut self, priority: Priority) -> Self {
581 self.priority = Some(priority);
582 self
583 }
584
585 pub fn with_max_attempts(mut self, attempts: u32) -> Self {
588 self.max_attempts = Some(attempts);
589 self
590 }
591
592 pub fn with_custom(mut self, custom: HashMap<String, Primitive>) -> Self {
595 self.custom = custom;
596 self
597 }
598
599 pub fn with_custom_entry(
602 mut self,
603 key: impl Into<String>,
604 value: impl Into<Primitive>,
605 ) -> Self {
606 self.custom.insert(key.into(), value.into());
607 self
608 }
609
610 pub fn with_trace_context(mut self, trace_context: TraceContext) -> Self {
616 self.trace_context = Some(trace_context);
617 self
618 }
619
620 pub fn with_scheduled_at(mut self, scheduled_at: SystemTime) -> Self {
625 self.scheduled_at = Some(scheduled_at);
626 self
627 }
628}
629
630impl From<EnqueueRequest> for crate::pb::sepp::v1::EnqueueRequest {
631 fn from(req: EnqueueRequest) -> Self {
632 Self {
633 queue: req.queue,
634 job_type: req.job_type,
635 payload: req.payload.map(Into::into),
636 idempotency_key: req.idempotency_key,
637 priority: req.priority.map(|p| p.get() as u32),
638 max_attempts: req.max_attempts,
639 custom: req.custom.into_iter().map(|(k, v)| (k, v.into())).collect(),
640 trace_context: req.trace_context.map(Into::into),
641
642 scheduled_at: req
643 .scheduled_at
644 .filter(|t| t.duration_since(SystemTime::UNIX_EPOCH).is_ok())
645 .map(system_time_to_timestamp),
646 }
647 }
648}
649
650#[derive(Debug, Clone, PartialEq, Eq)]
652pub struct EnqueueAck {
653 pub job_id: String,
656 pub deduplicated: bool,
659}
660
661impl From<crate::pb::sepp::v1::EnqueueResponse> for EnqueueAck {
662 fn from(r: crate::pb::sepp::v1::EnqueueResponse) -> Self {
663 Self {
664 job_id: r.job_id,
665 deduplicated: r.deduplicated,
666 }
667 }
668}
669
670#[derive(Debug, Clone, PartialEq, thiserror::Error)]
680#[non_exhaustive]
681pub enum JobRejection {
682 #[error("queue {queue:?} is not declared on the server (strict mode)")]
685 UnknownQueue { queue: String },
686 #[error("payload size {actual} bytes exceeds the queue limit of {limit}")]
689 PayloadTooLarge { limit: u64, actual: u64 },
690 #[error("payload encoding {encoding:?} is not allowed; accepted: {allowed:?}")]
693 EncodingNotAllowed {
694 encoding: String,
695 allowed: Vec<String>,
696 },
697 #[error("job_type {job_type:?} is not accepted by this queue; accepted: {allowed:?}")]
699 JobTypeNotAllowed {
700 job_type: String,
701 allowed: Vec<String>,
702 },
703 #[error("custom map has {actual} entries, exceeding the queue limit of {limit}")]
706 CustomEntriesTooMany { limit: u32, actual: u32 },
707 #[error("custom map's total size {actual} bytes exceeds the queue limit of {limit}")]
710 CustomMapTooLarge { limit: u64, actual: u64 },
711 #[error("custom key {key:?} is {actual} bytes, exceeding the limit of {limit}")]
714 CustomKeyTooLong {
715 key: String,
716 limit: u32,
717 actual: u64,
718 },
719 #[error("queue name is {actual} bytes, exceeding the limit of {limit}")]
722 QueueNameTooLong { limit: u32, actual: u64 },
723 #[error("job_type is {actual} bytes, exceeding the limit of {limit}")]
726 JobTypeNameTooLong { limit: u32, actual: u64 },
727 #[error("idempotency_key is {actual} bytes, exceeding the limit of {limit}")]
730 IdempotencyKeyTooLong { limit: u32, actual: u64 },
731 #[error("scheduled_at {actual:?} is beyond the max schedule horizon ({horizon:?})")]
734 ScheduledTooFar {
735 horizon: Duration,
736 actual: SystemTime,
737 },
738 #[error("structural validation failed: {message}")]
741 InvalidRequest { message: String },
742 #[error("queue {queue:?} is full (max depth {limit})")]
745 QueueFull { queue: String, limit: u64 },
746 #[error("queue {queue:?} is being deleted and is not accepting new jobs")]
749 QueueClosing { queue: String },
750 #[error("server returned an unrecognized rejection variant")]
753 Unknown,
754}
755
756impl From<crate::pb::sepp::v1::JobRejection> for JobRejection {
757 fn from(r: crate::pb::sepp::v1::JobRejection) -> Self {
758 use crate::pb::sepp::v1::job_rejection::Reason;
759 match r.reason {
760 Some(Reason::UnknownQueue(x)) => Self::UnknownQueue { queue: x.queue },
761 Some(Reason::PayloadTooLarge(x)) => Self::PayloadTooLarge {
762 limit: x.limit,
763 actual: x.actual,
764 },
765 Some(Reason::EncodingNotAllowed(x)) => Self::EncodingNotAllowed {
766 encoding: x.encoding,
767 allowed: x.allowed,
768 },
769 Some(Reason::JobTypeNotAllowed(x)) => Self::JobTypeNotAllowed {
770 job_type: x.job_type,
771 allowed: x.allowed,
772 },
773 Some(Reason::CustomEntriesTooMany(x)) => Self::CustomEntriesTooMany {
774 limit: x.limit,
775 actual: x.actual,
776 },
777 Some(Reason::CustomMapTooLarge(x)) => Self::CustomMapTooLarge {
778 limit: x.limit,
779 actual: x.actual,
780 },
781 Some(Reason::CustomKeyTooLong(x)) => Self::CustomKeyTooLong {
782 key: x.key,
783 limit: x.limit,
784 actual: x.actual,
785 },
786 Some(Reason::QueueNameTooLong(x)) => Self::QueueNameTooLong {
787 limit: x.limit,
788 actual: x.actual,
789 },
790 Some(Reason::JobTypeNameTooLong(x)) => Self::JobTypeNameTooLong {
791 limit: x.limit,
792 actual: x.actual,
793 },
794 Some(Reason::IdempotencyKeyTooLong(x)) => Self::IdempotencyKeyTooLong {
795 limit: x.limit,
796 actual: x.actual,
797 },
798 Some(Reason::ScheduledTooFar(x)) => Self::ScheduledTooFar {
799 horizon: proto_duration_to_std(x.horizon),
800 actual: timestamp_to_system_time(x.actual).unwrap_or(SystemTime::UNIX_EPOCH),
801 },
802 Some(Reason::InvalidRequest(x)) => Self::InvalidRequest { message: x.message },
803 Some(Reason::QueueFull(x)) => Self::QueueFull {
804 queue: x.queue,
805 limit: x.limit,
806 },
807 Some(Reason::QueueClosing(x)) => Self::QueueClosing { queue: x.queue },
808 None => Self::Unknown,
809 }
810 }
811}
812
813#[derive(Debug, Clone, PartialEq)]
819pub struct JobValidationError {
820 pub index: u32,
822 pub rejection: JobRejection,
824}
825
826impl From<crate::pb::sepp::v1::JobValidationError> for JobValidationError {
827 fn from(e: crate::pb::sepp::v1::JobValidationError) -> Self {
828 Self {
829 index: e.index,
830 rejection: e
831 .rejection
832 .map(JobRejection::from)
833 .unwrap_or(JobRejection::Unknown),
834 }
835 }
836}
837
838#[derive(Debug, thiserror::Error)]
845#[non_exhaustive]
846pub enum AtomicEnqueueError {
847 #[error(transparent)]
850 Client(#[from] crate::client::ClientError),
851 #[error("atomic batch rejected: {} job(s) failed validation", _0.len())]
853 Validation(Vec<JobValidationError>),
854}
855
856impl From<tonic::Status> for AtomicEnqueueError {
857 fn from(s: tonic::Status) -> Self {
858 Self::Client(crate::client::ClientError::from(s))
859 }
860}
861
862#[derive(Debug, Clone)]
869pub struct JobCtx {
870 pub id: String,
872 pub queue: String,
875 pub job_type: String,
877 pub priority: Priority,
879 pub attempt: u32,
882 pub max_attempts: u32,
884 pub enqueued_at: SystemTime,
886 pub scheduled_at: Option<SystemTime>,
890 pub custom: HashMap<String, Primitive>,
892 pub trace_context: Option<TraceContext>,
894 pub lease_expires_at: SystemTime,
897 pub(crate) lease: crate::client::Lease,
898}
899
900impl JobCtx {
901 pub async fn extend(
908 &self,
909 extension: Duration,
910 ) -> Result<SystemTime, crate::client::LeaseError> {
911 self.lease.extend(extension).await
912 }
913}
914
915impl fmt::Display for JobCtx {
916 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
917 write!(
918 f,
919 "JobCtx {{ id: {}, job_type: {}, attempt: {}/{}, priority: {} }}",
920 self.id,
921 self.job_type,
922 self.attempt,
923 self.max_attempts,
924 self.priority.get(),
925 )
926 }
927}
928
929#[derive(Debug, Clone)]
936pub struct Job {
937 pub payload: Option<Payload>,
939 pub ctx: JobCtx,
941}
942
943#[derive(Debug, thiserror::Error)]
950pub enum JobConversionError {
951 #[error("job is missing required field `{0}`")]
953 MissingField(&'static str),
954 #[error("job priority {0} is out of range (expected 0-9)")]
956 PriorityOutOfRange(u32),
957 #[error("job timestamp `{field}` is not a representable time ({value}ms)")]
960 InvalidTimestamp { field: &'static str, value: i64 },
961 #[error("custom value for key `{0}` has no value set")]
963 EmptyCustomValue(String),
964}
965
966impl From<crate::pb::sepp::v1::Payload> for Payload {
967 fn from(p: crate::pb::sepp::v1::Payload) -> Self {
968 Self {
969 data: p.data,
970 encoding: p.encoding,
971 }
972 }
973}
974
975impl TryFrom<crate::pb::sepp::v1::TraceContext> for TraceContext {
976 type Error = TraceContextError;
977
978 fn try_from(tc: crate::pb::sepp::v1::TraceContext) -> Result<Self, Self::Error> {
979 let mut ctx = TraceContext::new(tc.traceparent)?;
980 if let Some(ts) = tc.tracestate {
981 ctx = ctx.with_tracestate(ts);
982 }
983 Ok(ctx)
984 }
985}
986
987fn primitive_from_pb(v: crate::pb::sepp::v1::PrimitiveValue) -> Option<Primitive> {
988 use crate::pb::sepp::v1::primitive_value::Value;
989 Some(match v.value? {
990 Value::StringValue(s) => Primitive::String(s),
991 Value::DoubleValue(d) => Primitive::Double(d),
992 Value::IntValue(i) => Primitive::Int(i),
993 Value::BoolValue(b) => Primitive::Bool(b),
994 })
995}
996
997pub(crate) fn system_time_to_millis(t: SystemTime) -> i64 {
998 t.duration_since(SystemTime::UNIX_EPOCH)
999 .map(|d| d.as_millis() as i64)
1000 .unwrap_or(0)
1001}
1002
1003pub(crate) fn now_millis() -> i64 {
1004 system_time_to_millis(SystemTime::now())
1005}
1006
1007pub(crate) fn timestamp_to_system_time(ts: Option<prost_types::Timestamp>) -> Option<SystemTime> {
1008 let ts = ts?;
1009 if ts.seconds < 0 || (ts.seconds == 0 && ts.nanos < 0) {
1010 return None;
1011 }
1012 SystemTime::try_from(ts).ok()
1013}
1014
1015fn system_time_to_timestamp(t: SystemTime) -> prost_types::Timestamp {
1016 prost_types::Timestamp::from(t)
1017}
1018
1019fn proto_timestamp_to_millis(ts: Option<&prost_types::Timestamp>) -> i64 {
1020 match ts {
1021 Some(ts) => ts
1022 .seconds
1023 .saturating_mul(1_000)
1024 .saturating_add(i64::from(ts.nanos) / 1_000_000),
1025 None => 0,
1026 }
1027}
1028
1029pub(crate) fn duration_to_proto(d: Duration) -> prost_types::Duration {
1030 prost_types::Duration::try_from(d).unwrap_or(prost_types::Duration {
1031 seconds: i64::MAX,
1032 nanos: 999_999_999,
1033 })
1034}
1035
1036fn proto_duration_to_std(d: Option<prost_types::Duration>) -> Duration {
1037 d.and_then(|d| Duration::try_from(d).ok())
1038 .unwrap_or(Duration::ZERO)
1039}
1040
1041pub(crate) fn job_from_pb(
1042 client: &crate::client::SeppClient,
1043 j: crate::pb::sepp::v1::Job,
1044 worker_id: Option<&str>,
1045) -> Result<Job, JobConversionError> {
1046 use JobConversionError as E;
1047
1048 if j.id.is_empty() {
1049 return Err(E::MissingField("id"));
1050 }
1051 if j.job_type.is_empty() {
1052 return Err(E::MissingField("job_type"));
1053 }
1054
1055 let priority = u8::try_from(j.priority)
1056 .ok()
1057 .and_then(|p| Priority::new(p).ok())
1058 .ok_or(E::PriorityOutOfRange(j.priority))?;
1059
1060 let enqueued_at_ms = proto_timestamp_to_millis(j.enqueued_at.as_ref());
1061 let enqueued_at = timestamp_to_system_time(j.enqueued_at).ok_or(E::InvalidTimestamp {
1062 field: "enqueued_at",
1063 value: enqueued_at_ms,
1064 })?;
1065 let lease_expires_at_ms = proto_timestamp_to_millis(j.lease_expires_at.as_ref());
1066 let lease_expires_at =
1067 timestamp_to_system_time(j.lease_expires_at).ok_or(E::InvalidTimestamp {
1068 field: "lease_expires_at",
1069 value: lease_expires_at_ms,
1070 })?;
1071
1072 let mut custom = HashMap::with_capacity(j.custom.len());
1073 for (k, v) in j.custom {
1074 let value = primitive_from_pb(v).ok_or_else(|| E::EmptyCustomValue(k.clone()))?;
1075 custom.insert(k, value);
1076 }
1077
1078 let trace_context = j
1081 .trace_context
1082 .and_then(|tc| TraceContext::try_from(tc).ok());
1083
1084 let lease = crate::client::Lease::new(
1085 client.clone(),
1086 j.id.clone(),
1087 j.attempt,
1088 lease_expires_at,
1089 worker_id.map(String::from),
1090 );
1091
1092 Ok(Job {
1093 payload: j.payload.map(Into::into),
1094 ctx: JobCtx {
1095 id: j.id,
1096 queue: j.queue,
1097 job_type: j.job_type,
1098 priority,
1099 attempt: j.attempt,
1100 max_attempts: j.max_attempts,
1101 enqueued_at,
1102 scheduled_at: timestamp_to_system_time(j.scheduled_at),
1105 custom,
1106 trace_context,
1107 lease_expires_at,
1108 lease,
1109 },
1110 })
1111}
1112
1113#[derive(Debug, Clone, PartialEq)]
1119pub struct ReserveOptions {
1120 queues: Vec<String>,
1121 wait_timeout: Duration,
1122 lease_duration: Duration,
1123 worker_id: Option<String>,
1124 max_jobs: Option<u32>,
1125}
1126
1127#[derive(Debug, thiserror::Error)]
1129pub enum ReserveOptionsError {
1130 #[error("at least one queue must be specified")]
1132 EmptyQueues,
1133 #[error("queue name at index {0} must not be empty")]
1135 EmptyQueueName(usize),
1136 #[error("lease_duration must be at least 1ms")]
1138 LeaseDurationTooShort,
1139 #[error("worker_id must not be empty when set")]
1141 EmptyWorkerId,
1142}
1143
1144impl ReserveOptions {
1145 pub fn new(
1154 queues: impl IntoIterator<Item = impl Into<String>>,
1155 lease_duration: Duration,
1156 ) -> Result<Self, ReserveOptionsError> {
1157 let queues: Vec<String> = queues.into_iter().map(Into::into).collect();
1158 if queues.is_empty() {
1159 return Err(ReserveOptionsError::EmptyQueues);
1160 }
1161 for (i, q) in queues.iter().enumerate() {
1162 if q.is_empty() {
1163 return Err(ReserveOptionsError::EmptyQueueName(i));
1164 }
1165 }
1166 if lease_duration.is_zero() {
1167 return Err(ReserveOptionsError::LeaseDurationTooShort);
1168 }
1169 Ok(Self {
1170 queues,
1171 wait_timeout: Duration::from_secs(30),
1172 lease_duration,
1173 worker_id: None,
1174 max_jobs: None,
1175 })
1176 }
1177
1178 pub fn with_wait_timeout(mut self, wait: Duration) -> Self {
1187 self.wait_timeout = wait;
1188 self
1189 }
1190
1191 pub fn wait_timeout(&self) -> Duration {
1193 self.wait_timeout
1194 }
1195
1196 pub fn with_worker_id(mut self, id: impl Into<String>) -> Result<Self, ReserveOptionsError> {
1199 let id = id.into();
1200 if id.is_empty() {
1201 return Err(ReserveOptionsError::EmptyWorkerId);
1202 }
1203 self.worker_id = Some(id);
1204 Ok(self)
1205 }
1206
1207 pub fn with_max_jobs(mut self, max: u32) -> Self {
1212 self.max_jobs = Some(max);
1213 self
1214 }
1215}
1216
1217impl From<ReserveOptions> for crate::pb::sepp::v1::ReserveRequest {
1218 fn from(o: ReserveOptions) -> Self {
1219 Self::from(&o)
1220 }
1221}
1222
1223impl From<&ReserveOptions> for crate::pb::sepp::v1::ReserveRequest {
1224 fn from(o: &ReserveOptions) -> Self {
1225 Self {
1226 queues: o.queues.clone(),
1227 wait_timeout: Some(duration_to_proto(o.wait_timeout)),
1228 lease_duration: Some(duration_to_proto(o.lease_duration)),
1229 worker_id: o.worker_id.clone(),
1230 max_jobs: o.max_jobs,
1231 }
1232 }
1233}
1234
1235#[derive(Debug, Clone, PartialEq)]
1244pub struct ServerInfo {
1245 pub version: String,
1247 pub supported_protocol_versions: Vec<String>,
1249 pub server_time: SystemTime,
1251 pub restricts_encodings: bool,
1254 pub allowed_encodings: Vec<String>,
1257 pub max_payload_bytes: u64,
1259 pub max_custom_entries: u32,
1261 pub max_custom_total_bytes: u64,
1263 pub max_custom_key_bytes: u32,
1265 pub max_queue_name_bytes: u32,
1267 pub max_job_type_bytes: u32,
1269 pub max_idempotency_key_bytes: u32,
1271 pub max_schedule_horizon: Duration,
1273 pub max_enqueue_batch: u32,
1275 pub max_reserve_batch: u32,
1277 pub max_reserve_queues: u32,
1279 pub max_wait_timeout: Duration,
1281 pub max_lease_duration: Duration,
1283 pub strict_queues: bool,
1286 pub dead_letter_retention_enabled: bool,
1290}
1291
1292#[derive(Debug, thiserror::Error)]
1295pub enum ServerInfoError {
1296 #[error("server info is missing required field `{0}`")]
1298 MissingField(&'static str),
1299 #[error("server_time is not a representable time ({0}ms)")]
1301 InvalidServerTime(i64),
1302}
1303
1304impl TryFrom<crate::pb::sepp::v1::GetServerInfoResponse> for ServerInfo {
1305 type Error = ServerInfoError;
1306
1307 fn try_from(r: crate::pb::sepp::v1::GetServerInfoResponse) -> Result<Self, Self::Error> {
1308 if r.server_version.is_empty() {
1309 return Err(ServerInfoError::MissingField("server_version"));
1310 }
1311 let server_time_ms = proto_timestamp_to_millis(r.server_time.as_ref());
1312 let server_time = timestamp_to_system_time(r.server_time)
1313 .ok_or(ServerInfoError::InvalidServerTime(server_time_ms))?;
1314
1315 Ok(Self {
1316 version: r.server_version,
1317 supported_protocol_versions: r.supported_protocol_versions,
1318 server_time,
1319 restricts_encodings: r.restricts_encodings,
1320 allowed_encodings: r.allowed_encodings,
1321 max_payload_bytes: r.max_payload_bytes,
1322 max_custom_entries: r.max_custom_entries,
1323 max_custom_total_bytes: r.max_custom_total_bytes,
1324 max_custom_key_bytes: r.max_custom_key_bytes,
1325 max_queue_name_bytes: r.max_queue_name_bytes,
1326 max_job_type_bytes: r.max_job_type_bytes,
1327 max_idempotency_key_bytes: r.max_idempotency_key_bytes,
1328 max_schedule_horizon: proto_duration_to_std(r.max_schedule_horizon),
1329 max_enqueue_batch: r.max_enqueue_batch,
1330 max_reserve_batch: r.max_reserve_batch,
1331 max_reserve_queues: r.max_reserve_queues,
1332 max_wait_timeout: proto_duration_to_std(r.max_wait_timeout),
1333 max_lease_duration: proto_duration_to_std(r.max_lease_duration),
1334 strict_queues: r.strict_queues,
1335 dead_letter_retention_enabled: r.dead_letter_retention_enabled,
1336 })
1337 }
1338}
1339
1340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1342#[non_exhaustive]
1343pub enum DeadLetterCause {
1344 Unspecified,
1346 AttemptsExhausted,
1348 Rejected,
1352 LeaseExpired,
1354 Admin,
1356}
1357
1358impl From<crate::pb::sepp::v1::DeadLetterCause> for DeadLetterCause {
1359 fn from(c: crate::pb::sepp::v1::DeadLetterCause) -> Self {
1360 use crate::pb::sepp::v1::DeadLetterCause as Pb;
1361 match c {
1362 Pb::Unspecified => Self::Unspecified,
1363 Pb::AttemptsExhausted => Self::AttemptsExhausted,
1364 Pb::Rejected => Self::Rejected,
1365 Pb::LeaseExpired => Self::LeaseExpired,
1366 Pb::Admin => Self::Admin,
1367 }
1368 }
1369}
1370
1371#[derive(Debug, Clone)]
1379pub struct DeadLetterRecord {
1380 pub queue: String,
1382 pub job_id: String,
1384 pub job_type: String,
1386 pub payload: Option<Payload>,
1388 pub priority: Priority,
1390 pub max_attempts: u32,
1393 pub custom: HashMap<String, Primitive>,
1395 pub trace_context: Option<TraceContext>,
1397 pub enqueued_at: SystemTime,
1399 pub scheduled_at: Option<SystemTime>,
1402 pub cause: DeadLetterCause,
1404 pub failed_at: SystemTime,
1406 pub final_attempt: u32,
1408 pub last_reason: Option<String>,
1411}
1412
1413impl DeadLetterRecord {
1414 pub fn to_enqueue_request(&self) -> EnqueueRequest {
1425 self.into()
1426 }
1427}
1428
1429impl From<DeadLetterRecord> for EnqueueRequest {
1430 fn from(r: DeadLetterRecord) -> Self {
1432 EnqueueRequest {
1433 queue: r.queue,
1434 job_type: r.job_type,
1435 payload: r.payload,
1436 idempotency_key: None,
1437 priority: Some(r.priority),
1438 max_attempts: Some(r.max_attempts),
1439 custom: r.custom,
1440 trace_context: r.trace_context,
1441 scheduled_at: None,
1444 }
1445 }
1446}
1447
1448impl From<&DeadLetterRecord> for EnqueueRequest {
1449 fn from(r: &DeadLetterRecord) -> Self {
1452 EnqueueRequest {
1453 queue: r.queue.clone(),
1454 job_type: r.job_type.clone(),
1455 payload: r.payload.clone(),
1456 idempotency_key: None,
1457 priority: Some(r.priority),
1458 max_attempts: Some(r.max_attempts),
1459 custom: r.custom.clone(),
1460 trace_context: r.trace_context.clone(),
1461 scheduled_at: None,
1464 }
1465 }
1466}
1467
1468pub(crate) fn dead_letter_record_from_pb(
1469 r: crate::pb::sepp::v1::DeadLetterRecord,
1470) -> Result<DeadLetterRecord, JobConversionError> {
1471 use JobConversionError as E;
1472 let j = r.job.ok_or(E::MissingField("job"))?;
1473
1474 if j.id.is_empty() {
1475 return Err(E::MissingField("id"));
1476 }
1477 if j.job_type.is_empty() {
1478 return Err(E::MissingField("job_type"));
1479 }
1480
1481 let priority = u8::try_from(j.priority)
1482 .ok()
1483 .and_then(|p| Priority::new(p).ok())
1484 .ok_or(E::PriorityOutOfRange(j.priority))?;
1485
1486 let enqueued_at_ms = proto_timestamp_to_millis(j.enqueued_at.as_ref());
1487 let enqueued_at = timestamp_to_system_time(j.enqueued_at).ok_or(E::InvalidTimestamp {
1488 field: "enqueued_at",
1489 value: enqueued_at_ms,
1490 })?;
1491 let failed_at_ms = proto_timestamp_to_millis(r.failed_at.as_ref());
1492 let failed_at = timestamp_to_system_time(r.failed_at).ok_or(E::InvalidTimestamp {
1493 field: "failed_at",
1494 value: failed_at_ms,
1495 })?;
1496
1497 let mut custom = HashMap::with_capacity(j.custom.len());
1498 for (k, v) in j.custom {
1499 let value = primitive_from_pb(v).ok_or_else(|| E::EmptyCustomValue(k.clone()))?;
1500 custom.insert(k, value);
1501 }
1502
1503 let trace_context = j
1504 .trace_context
1505 .and_then(|tc| TraceContext::try_from(tc).ok());
1506
1507 let cause = crate::pb::sepp::v1::DeadLetterCause::try_from(r.cause)
1508 .unwrap_or(crate::pb::sepp::v1::DeadLetterCause::Unspecified)
1509 .into();
1510
1511 Ok(DeadLetterRecord {
1512 queue: j.queue,
1513 job_id: j.id,
1514 job_type: j.job_type,
1515 payload: j.payload.map(Into::into),
1516 priority,
1517 max_attempts: j.max_attempts,
1518 custom,
1519 trace_context,
1520 enqueued_at,
1521 scheduled_at: timestamp_to_system_time(j.scheduled_at),
1524 cause,
1525 failed_at,
1526 final_attempt: r.final_attempt,
1527 last_reason: r.last_reason,
1528 })
1529}
1530
1531#[cfg(test)]
1532mod tests {
1533 use super::*;
1534 use crate::pb::sepp::v1 as pb;
1535
1536 const VALID_TP: &str = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01";
1537
1538 fn test_client() -> crate::client::SeppClient {
1539 let chan = tonic::transport::Endpoint::from_static("http://[::1]:1").connect_lazy();
1540 crate::client::SeppClient::from_channel(chan)
1541 }
1542
1543 #[test]
1544 fn primitive_from_str() {
1545 assert_eq!(Primitive::from("hi"), Primitive::String("hi".into()));
1546 }
1547
1548 #[test]
1549 fn primitive_from_string() {
1550 assert_eq!(
1551 Primitive::from(String::from("hi")),
1552 Primitive::String("hi".into())
1553 );
1554 }
1555
1556 #[test]
1557 fn primitive_from_i64() {
1558 assert_eq!(Primitive::from(42_i64), Primitive::Int(42));
1559 }
1560
1561 #[test]
1562 fn primitive_from_i32() {
1563 assert_eq!(Primitive::from(42_i32), Primitive::Int(42));
1564 }
1565
1566 #[test]
1567 fn primitive_from_f64() {
1568 assert_eq!(Primitive::from(1.5_f64), Primitive::Double(1.5));
1569 }
1570
1571 #[test]
1572 fn primitive_from_bool() {
1573 assert_eq!(Primitive::from(true), Primitive::Bool(true));
1574 }
1575
1576 #[test]
1577 fn primitive_to_pb_string() {
1578 let pb: pb::PrimitiveValue = Primitive::String("x".into()).into();
1579 assert!(
1580 matches!(pb.value, Some(pb::primitive_value::Value::StringValue(ref s)) if s == "x")
1581 );
1582 }
1583
1584 #[test]
1585 fn primitive_to_pb_int() {
1586 let pb: pb::PrimitiveValue = Primitive::Int(7).into();
1587 assert!(matches!(
1588 pb.value,
1589 Some(pb::primitive_value::Value::IntValue(7))
1590 ));
1591 }
1592
1593 #[test]
1594 fn primitive_to_pb_double() {
1595 let pb: pb::PrimitiveValue = Primitive::Double(2.5).into();
1596 assert!(matches!(
1597 pb.value,
1598 Some(pb::primitive_value::Value::DoubleValue(d)) if d == 2.5
1599 ));
1600 }
1601
1602 #[test]
1603 fn primitive_to_pb_bool() {
1604 let pb: pb::PrimitiveValue = Primitive::Bool(false).into();
1605 assert!(matches!(
1606 pb.value,
1607 Some(pb::primitive_value::Value::BoolValue(false))
1608 ));
1609 }
1610
1611 #[test]
1612 fn payload_to_pb() {
1613 let p = Payload {
1614 data: vec![1, 2, 3],
1615 encoding: "json".into(),
1616 };
1617 let pb: pb::Payload = p.into();
1618 assert_eq!(pb.data, vec![1, 2, 3]);
1619 assert_eq!(pb.encoding, "json");
1620 }
1621
1622 #[test]
1623 fn payload_from_pb() {
1624 let pb = pb::Payload {
1625 data: vec![9],
1626 encoding: "raw".into(),
1627 };
1628 let p: Payload = pb.into();
1629 assert_eq!(p.data, vec![9]);
1630 assert_eq!(p.encoding, "raw");
1631 }
1632
1633 #[test]
1634 fn priority_zero_valid() {
1635 assert_eq!(Priority::new(0).unwrap().get(), 0);
1636 }
1637
1638 #[test]
1639 fn priority_mid_valid() {
1640 assert_eq!(Priority::new(5).unwrap().get(), 5);
1641 }
1642
1643 #[test]
1644 fn priority_max_valid() {
1645 assert_eq!(Priority::new(9).unwrap().get(), 9);
1646 }
1647
1648 #[test]
1649 fn priority_above_max_rejected() {
1650 assert!(matches!(Priority::new(10), Err(PriorityOutOfRange(10))));
1651 }
1652
1653 #[test]
1654 fn priority_u8_max_rejected() {
1655 assert!(matches!(
1656 Priority::new(u8::MAX),
1657 Err(PriorityOutOfRange(255))
1658 ));
1659 }
1660
1661 #[test]
1662 fn priority_try_from_ok() {
1663 let p: Priority = 5u8.try_into().unwrap();
1664 assert_eq!(p.get(), 5);
1665 }
1666
1667 #[test]
1668 fn priority_try_from_err() {
1669 assert!(<Priority as TryFrom<u8>>::try_from(11).is_err());
1670 }
1671
1672 #[test]
1673 fn priority_constants() {
1674 assert_eq!(Priority::MIN.get(), 0);
1675 assert_eq!(Priority::MAX.get(), 9);
1676 }
1677
1678 #[test]
1679 fn traceparent_valid() {
1680 assert!(TraceContext::new(VALID_TP).is_ok());
1681 }
1682
1683 #[test]
1684 fn traceparent_wrong_field_count() {
1685 assert!(matches!(
1686 TraceContext::new("00-deadbeef-0123"),
1687 Err(TraceContextError::InvalidTraceparent(_))
1688 ));
1689 }
1690
1691 #[test]
1692 fn traceparent_bad_version_length() {
1693 let tp = "0-0123456789abcdef0123456789abcdef-0123456789abcdef-01";
1694 assert!(TraceContext::new(tp).is_err());
1695 }
1696
1697 #[test]
1698 fn traceparent_non_hex_version() {
1699 let tp = "0g-0123456789abcdef0123456789abcdef-0123456789abcdef-01";
1700 assert!(TraceContext::new(tp).is_err());
1701 }
1702
1703 #[test]
1704 fn traceparent_bad_trace_id_length() {
1705 let tp = "00-deadbeef-0123456789abcdef-01";
1706 assert!(TraceContext::new(tp).is_err());
1707 }
1708
1709 #[test]
1710 fn traceparent_non_hex_trace_id() {
1711 let tp = "00-0123456789abcdeg0123456789abcdef-0123456789abcdef-01";
1712 assert!(TraceContext::new(tp).is_err());
1713 }
1714
1715 #[test]
1716 fn traceparent_all_zero_trace_id_rejected() {
1717 let tp = "00-00000000000000000000000000000000-0123456789abcdef-01";
1718 assert!(TraceContext::new(tp).is_err());
1719 }
1720
1721 #[test]
1722 fn traceparent_bad_span_id_length() {
1723 let tp = "00-0123456789abcdef0123456789abcdef-deadbeef-01";
1724 assert!(TraceContext::new(tp).is_err());
1725 }
1726
1727 #[test]
1728 fn traceparent_non_hex_span_id() {
1729 let tp = "00-0123456789abcdef0123456789abcdef-0123456789abcdeg-01";
1730 assert!(TraceContext::new(tp).is_err());
1731 }
1732
1733 #[test]
1734 fn traceparent_all_zero_span_id_rejected() {
1735 let tp = "00-0123456789abcdef0123456789abcdef-0000000000000000-01";
1736 assert!(TraceContext::new(tp).is_err());
1737 }
1738
1739 #[test]
1740 fn traceparent_bad_flags_length() {
1741 let tp = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-0";
1742 assert!(TraceContext::new(tp).is_err());
1743 }
1744
1745 #[test]
1746 fn traceparent_non_hex_flags() {
1747 let tp = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-0g";
1748 assert!(TraceContext::new(tp).is_err());
1749 }
1750
1751 #[test]
1752 fn trace_context_with_tracestate() {
1753 let tc = TraceContext::new(VALID_TP)
1754 .unwrap()
1755 .with_tracestate("vendor=abc");
1756 assert_eq!(tc.traceparent(), VALID_TP);
1757 assert_eq!(tc.tracestate(), Some("vendor=abc"));
1758 }
1759
1760 #[test]
1761 fn trace_context_without_tracestate() {
1762 let tc = TraceContext::new(VALID_TP).unwrap();
1763 assert!(tc.tracestate().is_none());
1764 }
1765
1766 #[test]
1767 fn trace_context_to_pb() {
1768 let tc = TraceContext::new(VALID_TP).unwrap().with_tracestate("v=1");
1769 let pb: pb::TraceContext = tc.into();
1770 assert_eq!(pb.traceparent, VALID_TP);
1771 assert_eq!(pb.tracestate.as_deref(), Some("v=1"));
1772 }
1773
1774 #[test]
1775 fn trace_context_try_from_pb_ok() {
1776 let pb = pb::TraceContext {
1777 traceparent: VALID_TP.into(),
1778 tracestate: Some("v=1".into()),
1779 };
1780 let tc = TraceContext::try_from(pb).unwrap();
1781 assert_eq!(tc.traceparent(), VALID_TP);
1782 assert_eq!(tc.tracestate(), Some("v=1"));
1783 }
1784
1785 #[test]
1786 fn trace_context_try_from_pb_propagates_validation_error() {
1787 let pb = pb::TraceContext {
1788 traceparent: "garbage".into(),
1789 tracestate: None,
1790 };
1791 assert!(TraceContext::try_from(pb).is_err());
1792 }
1793
1794 #[test]
1795 fn enqueue_request_empty_queue_rejected() {
1796 assert!(matches!(
1797 EnqueueRequest::new("", "type"),
1798 Err(EnqueueRequestBuilderError::EmptyQueue)
1799 ));
1800 }
1801
1802 #[test]
1803 fn enqueue_request_empty_job_type_rejected() {
1804 assert!(matches!(
1805 EnqueueRequest::new("q", ""),
1806 Err(EnqueueRequestBuilderError::EmptyJobType)
1807 ));
1808 }
1809
1810 #[test]
1811 fn enqueue_request_to_pb_minimal() {
1812 let req = EnqueueRequest::new("q", "t").unwrap();
1813 let pb: pb::EnqueueRequest = req.into();
1814 assert_eq!(pb.queue, "q");
1815 assert_eq!(pb.job_type, "t");
1816 assert!(pb.payload.is_none());
1817 assert!(pb.idempotency_key.is_none());
1818 assert!(pb.priority.is_none());
1819 assert!(pb.max_attempts.is_none());
1820 assert!(pb.custom.is_empty());
1821 assert!(pb.trace_context.is_none());
1822 assert!(pb.scheduled_at.is_none());
1823 }
1824
1825 #[test]
1826 fn enqueue_request_to_pb_all_fields() {
1827 let mut custom = HashMap::new();
1828 custom.insert("k".into(), Primitive::Int(1));
1829 let req = EnqueueRequest::new("q", "t")
1830 .unwrap()
1831 .with_payload(Payload {
1832 data: vec![1],
1833 encoding: "raw".into(),
1834 })
1835 .with_idempotency_key("idem")
1836 .with_priority(Priority::new(7).unwrap())
1837 .with_max_attempts(5)
1838 .with_custom(custom)
1839 .with_trace_context(TraceContext::new(VALID_TP).unwrap())
1840 .with_scheduled_at(SystemTime::UNIX_EPOCH + Duration::from_millis(1234));
1841 let pb: pb::EnqueueRequest = req.into();
1842 assert_eq!(pb.queue, "q");
1843 assert_eq!(pb.job_type, "t");
1844 assert_eq!(pb.payload.as_ref().unwrap().encoding, "raw");
1845 assert_eq!(pb.payload.as_ref().unwrap().data, vec![1]);
1846 assert_eq!(pb.idempotency_key.as_deref(), Some("idem"));
1847 assert_eq!(pb.priority, Some(7));
1848 assert_eq!(pb.max_attempts, Some(5));
1849 assert_eq!(pb.custom.len(), 1);
1850 assert!(pb.trace_context.is_some());
1851 assert_eq!(pb.scheduled_at, Some(ts(1, 234_000_000)));
1852 }
1853
1854 #[test]
1855 fn enqueue_request_scheduled_at_pre_epoch_becomes_none() {
1856 let req = EnqueueRequest::new("q", "t")
1857 .unwrap()
1858 .with_scheduled_at(SystemTime::UNIX_EPOCH - Duration::from_secs(1));
1859 let pb: pb::EnqueueRequest = req.into();
1860 assert!(pb.scheduled_at.is_none());
1861 }
1862
1863 #[test]
1864 fn enqueue_ack_from_pb() {
1865 let pb = pb::EnqueueResponse {
1866 job_id: "abc".into(),
1867 deduplicated: true,
1868 };
1869 let ack = EnqueueAck::from(pb);
1870 assert_eq!(ack.job_id, "abc");
1871 assert!(ack.deduplicated);
1872 }
1873
1874 fn rej(reason: pb::job_rejection::Reason) -> pb::JobRejection {
1875 pb::JobRejection {
1876 reason: Some(reason),
1877 }
1878 }
1879
1880 #[test]
1881 fn job_rejection_unknown_queue() {
1882 let pb = rej(pb::job_rejection::Reason::UnknownQueue(pb::UnknownQueue {
1883 queue: "q".into(),
1884 }));
1885 assert!(
1886 matches!(JobRejection::from(pb), JobRejection::UnknownQueue { queue } if queue == "q")
1887 );
1888 }
1889
1890 #[test]
1891 fn job_rejection_payload_too_large() {
1892 let pb = rej(pb::job_rejection::Reason::PayloadTooLarge(
1893 pb::PayloadTooLarge {
1894 limit: 10,
1895 actual: 20,
1896 },
1897 ));
1898 assert!(matches!(
1899 JobRejection::from(pb),
1900 JobRejection::PayloadTooLarge {
1901 limit: 10,
1902 actual: 20
1903 }
1904 ));
1905 }
1906
1907 #[test]
1908 fn job_rejection_encoding_not_allowed() {
1909 let pb = rej(pb::job_rejection::Reason::EncodingNotAllowed(
1910 pb::EncodingNotAllowed {
1911 encoding: "gzip".into(),
1912 allowed: vec!["json".into()],
1913 },
1914 ));
1915 match JobRejection::from(pb) {
1916 JobRejection::EncodingNotAllowed { encoding, allowed } => {
1917 assert_eq!(encoding, "gzip");
1918 assert_eq!(allowed, vec!["json".to_string()]);
1919 }
1920 _ => panic!("wrong variant"),
1921 }
1922 }
1923
1924 #[test]
1925 fn job_rejection_job_type_not_allowed() {
1926 let pb = rej(pb::job_rejection::Reason::JobTypeNotAllowed(
1927 pb::JobTypeNotAllowed {
1928 job_type: "x".into(),
1929 allowed: vec!["y".into()],
1930 },
1931 ));
1932 match JobRejection::from(pb) {
1933 JobRejection::JobTypeNotAllowed { job_type, allowed } => {
1934 assert_eq!(job_type, "x");
1935 assert_eq!(allowed, vec!["y".to_string()]);
1936 }
1937 _ => panic!("wrong variant"),
1938 }
1939 }
1940
1941 #[test]
1942 fn job_rejection_custom_entries_too_many() {
1943 let pb = rej(pb::job_rejection::Reason::CustomEntriesTooMany(
1944 pb::CustomEntriesTooMany {
1945 limit: 5,
1946 actual: 6,
1947 },
1948 ));
1949 assert!(matches!(
1950 JobRejection::from(pb),
1951 JobRejection::CustomEntriesTooMany {
1952 limit: 5,
1953 actual: 6
1954 }
1955 ));
1956 }
1957
1958 #[test]
1959 fn job_rejection_custom_map_too_large() {
1960 let pb = rej(pb::job_rejection::Reason::CustomMapTooLarge(
1961 pb::CustomMapTooLarge {
1962 limit: 100,
1963 actual: 200,
1964 },
1965 ));
1966 assert!(matches!(
1967 JobRejection::from(pb),
1968 JobRejection::CustomMapTooLarge {
1969 limit: 100,
1970 actual: 200
1971 }
1972 ));
1973 }
1974
1975 #[test]
1976 fn job_rejection_custom_key_too_long() {
1977 let pb = rej(pb::job_rejection::Reason::CustomKeyTooLong(
1978 pb::CustomKeyTooLong {
1979 key: "k".into(),
1980 limit: 1,
1981 actual: 2,
1982 },
1983 ));
1984 match JobRejection::from(pb) {
1985 JobRejection::CustomKeyTooLong { key, limit, actual } => {
1986 assert_eq!(key, "k");
1987 assert_eq!(limit, 1);
1988 assert_eq!(actual, 2);
1989 }
1990 _ => panic!("wrong variant"),
1991 }
1992 }
1993
1994 #[test]
1995 fn job_rejection_queue_name_too_long() {
1996 let pb = rej(pb::job_rejection::Reason::QueueNameTooLong(
1997 pb::QueueNameTooLong {
1998 limit: 1,
1999 actual: 2,
2000 },
2001 ));
2002 assert!(matches!(
2003 JobRejection::from(pb),
2004 JobRejection::QueueNameTooLong {
2005 limit: 1,
2006 actual: 2
2007 }
2008 ));
2009 }
2010
2011 #[test]
2012 fn job_rejection_job_type_name_too_long() {
2013 let pb = rej(pb::job_rejection::Reason::JobTypeNameTooLong(
2014 pb::JobTypeNameTooLong {
2015 limit: 3,
2016 actual: 4,
2017 },
2018 ));
2019 assert!(matches!(
2020 JobRejection::from(pb),
2021 JobRejection::JobTypeNameTooLong {
2022 limit: 3,
2023 actual: 4
2024 }
2025 ));
2026 }
2027
2028 #[test]
2029 fn job_rejection_idempotency_key_too_long() {
2030 let pb = rej(pb::job_rejection::Reason::IdempotencyKeyTooLong(
2031 pb::IdempotencyKeyTooLong {
2032 limit: 8,
2033 actual: 9,
2034 },
2035 ));
2036 assert!(matches!(
2037 JobRejection::from(pb),
2038 JobRejection::IdempotencyKeyTooLong {
2039 limit: 8,
2040 actual: 9
2041 }
2042 ));
2043 }
2044
2045 #[test]
2046 fn job_rejection_scheduled_too_far() {
2047 let pb = rej(pb::job_rejection::Reason::ScheduledTooFar(
2048 pb::ScheduledTooFar {
2049 horizon: Some(prost_types::Duration {
2050 seconds: 60,
2051 nanos: 0,
2052 }),
2053 actual: Some(ts(120, 0)),
2054 },
2055 ));
2056 let JobRejection::ScheduledTooFar { horizon, actual } = JobRejection::from(pb) else {
2057 panic!("expected ScheduledTooFar");
2058 };
2059 assert_eq!(horizon, Duration::from_secs(60));
2060 assert_eq!(actual, SystemTime::UNIX_EPOCH + Duration::from_secs(120));
2061 }
2062
2063 #[test]
2064 fn job_rejection_invalid_request() {
2065 let pb = rej(pb::job_rejection::Reason::InvalidRequest(
2066 pb::InvalidRequest {
2067 message: "oops".into(),
2068 },
2069 ));
2070 match JobRejection::from(pb) {
2071 JobRejection::InvalidRequest { message } => assert_eq!(message, "oops"),
2072 _ => panic!("wrong variant"),
2073 }
2074 }
2075
2076 #[test]
2077 fn job_rejection_queue_full() {
2078 let pb = rej(pb::job_rejection::Reason::QueueFull(pb::QueueFull {
2079 queue: "q".into(),
2080 limit: 1000,
2081 }));
2082 match JobRejection::from(pb) {
2083 JobRejection::QueueFull { queue, limit } => {
2084 assert_eq!(queue, "q");
2085 assert_eq!(limit, 1000);
2086 }
2087 _ => panic!("wrong variant"),
2088 }
2089 }
2090
2091 #[test]
2092 fn job_rejection_queue_closing() {
2093 let pb = rej(pb::job_rejection::Reason::QueueClosing(pb::QueueClosing {
2094 queue: "q".into(),
2095 }));
2096 match JobRejection::from(pb) {
2097 JobRejection::QueueClosing { queue } => assert_eq!(queue, "q"),
2098 _ => panic!("wrong variant"),
2099 }
2100 }
2101
2102 #[test]
2103 fn job_rejection_none_is_unknown() {
2104 let pb = pb::JobRejection { reason: None };
2105 assert!(matches!(JobRejection::from(pb), JobRejection::Unknown));
2106 }
2107
2108 #[test]
2109 fn job_validation_error_from_pb() {
2110 let pb = pb::JobValidationError {
2111 index: 3,
2112 rejection: Some(rej(pb::job_rejection::Reason::UnknownQueue(
2113 pb::UnknownQueue { queue: "q".into() },
2114 ))),
2115 };
2116 let e = JobValidationError::from(pb);
2117 assert_eq!(e.index, 3);
2118 assert!(matches!(e.rejection, JobRejection::UnknownQueue { queue } if queue == "q"));
2119 }
2120
2121 #[test]
2122 fn job_validation_error_missing_rejection_is_unknown() {
2123 let pb = pb::JobValidationError {
2124 index: 1,
2125 rejection: None,
2126 };
2127 let e = JobValidationError::from(pb);
2128 assert!(matches!(e.rejection, JobRejection::Unknown));
2129 }
2130
2131 fn valid_job_pb() -> pb::Job {
2132 pb::Job {
2133 id: "550e8400-e29b-41d4-a716-446655440000".into(),
2134 job_type: "send_email".into(),
2135 payload: None,
2136 priority: 3,
2137 trace_context: None,
2138 enqueued_at: Some(ts(1_700_000_000, 0)),
2139 attempt: 1,
2140 max_attempts: 5,
2141 lease_expires_at: Some(ts(1_700_000_060, 0)),
2142 custom: HashMap::new(),
2143 scheduled_at: None,
2144 queue: "emails".into(),
2145 }
2146 }
2147
2148 #[tokio::test]
2149 async fn job_from_pb_happy_path() {
2150 let client = test_client();
2151 let mut p = valid_job_pb();
2152 p.payload = Some(pb::Payload {
2153 data: vec![1, 2],
2154 encoding: "json".into(),
2155 });
2156 p.custom.insert(
2157 "k".into(),
2158 pb::PrimitiveValue {
2159 value: Some(pb::primitive_value::Value::StringValue("v".into())),
2160 },
2161 );
2162 let job = job_from_pb(&client, p, None).unwrap();
2163 assert_eq!(job.ctx.id, "550e8400-e29b-41d4-a716-446655440000");
2164 assert_eq!(job.ctx.job_type, "send_email");
2165 assert_eq!(job.ctx.priority.get(), 3);
2166 assert_eq!(job.ctx.attempt, 1);
2167 assert_eq!(job.ctx.max_attempts, 5);
2168 assert_eq!(job.payload.as_ref().unwrap().encoding, "json");
2169 assert_eq!(
2170 job.ctx.custom.get("k"),
2171 Some(&Primitive::String("v".into()))
2172 );
2173 }
2174
2175 #[tokio::test]
2176 async fn job_from_pb_missing_id() {
2177 let client = test_client();
2178 let mut p = valid_job_pb();
2179 p.id.clear();
2180 assert!(matches!(
2181 job_from_pb(&client, p, None),
2182 Err(JobConversionError::MissingField("id"))
2183 ));
2184 }
2185
2186 #[tokio::test]
2187 async fn job_from_pb_missing_job_type() {
2188 let client = test_client();
2189 let mut p = valid_job_pb();
2190 p.job_type.clear();
2191 assert!(matches!(
2192 job_from_pb(&client, p, None),
2193 Err(JobConversionError::MissingField("job_type"))
2194 ));
2195 }
2196
2197 #[tokio::test]
2198 async fn job_from_pb_priority_out_of_range() {
2199 let client = test_client();
2200 let mut p = valid_job_pb();
2201 p.priority = 10;
2202 assert!(matches!(
2203 job_from_pb(&client, p, None),
2204 Err(JobConversionError::PriorityOutOfRange(10))
2205 ));
2206 }
2207
2208 #[tokio::test]
2209 async fn job_from_pb_priority_above_u8() {
2210 let client = test_client();
2211 let mut p = valid_job_pb();
2212 p.priority = 300;
2213 assert!(matches!(
2214 job_from_pb(&client, p, None),
2215 Err(JobConversionError::PriorityOutOfRange(300))
2216 ));
2217 }
2218
2219 #[tokio::test]
2220 async fn job_from_pb_invalid_enqueued_at() {
2221 let client = test_client();
2222 let mut p = valid_job_pb();
2223 p.enqueued_at = Some(ts(-1, 0));
2224 assert!(matches!(
2225 job_from_pb(&client, p, None),
2226 Err(JobConversionError::InvalidTimestamp {
2227 field: "enqueued_at",
2228 value: -1_000
2229 })
2230 ));
2231 }
2232
2233 #[tokio::test]
2234 async fn job_from_pb_invalid_lease_expires_at() {
2235 let client = test_client();
2236 let mut p = valid_job_pb();
2237 p.lease_expires_at = Some(ts(-5, 0));
2238 assert!(matches!(
2239 job_from_pb(&client, p, None),
2240 Err(JobConversionError::InvalidTimestamp {
2241 field: "lease_expires_at",
2242 value: -5_000
2243 })
2244 ));
2245 }
2246
2247 #[tokio::test]
2248 async fn job_from_pb_empty_custom_value() {
2249 let client = test_client();
2250 let mut p = valid_job_pb();
2251 p.custom
2252 .insert("k".into(), pb::PrimitiveValue { value: None });
2253 match job_from_pb(&client, p, None) {
2254 Err(JobConversionError::EmptyCustomValue(k)) => assert_eq!(k, "k"),
2255 _ => panic!("expected EmptyCustomValue"),
2256 }
2257 }
2258
2259 #[tokio::test]
2260 async fn job_from_pb_carries_scheduled_at() {
2261 let client = test_client();
2262 let mut p = valid_job_pb();
2263 p.scheduled_at = Some(ts(1_700_000_030, 0));
2264 let job = job_from_pb(&client, p, None).unwrap();
2265 assert_eq!(
2266 job.ctx.scheduled_at,
2267 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_030))
2268 );
2269 }
2270
2271 #[tokio::test]
2272 async fn job_from_pb_unscheduled_job_has_no_scheduled_at() {
2273 let client = test_client();
2274 let job = job_from_pb(&client, valid_job_pb(), None).unwrap();
2275 assert_eq!(job.ctx.scheduled_at, None);
2276 }
2277
2278 #[tokio::test]
2279 async fn job_from_pb_invalid_scheduled_at_degrades_to_none() {
2280 let client = test_client();
2281 let mut p = valid_job_pb();
2282 p.scheduled_at = Some(ts(-1, 0));
2283 let job = job_from_pb(&client, p, None).unwrap();
2284 assert_eq!(job.ctx.scheduled_at, None);
2285 }
2286
2287 #[tokio::test]
2288 async fn job_from_pb_drops_invalid_trace_context() {
2289 let client = test_client();
2290 let mut p = valid_job_pb();
2291 p.trace_context = Some(pb::TraceContext {
2292 traceparent: "garbage".into(),
2293 tracestate: None,
2294 });
2295 let job = job_from_pb(&client, p, None).unwrap();
2296 assert!(job.ctx.trace_context.is_none());
2297 }
2298
2299 #[tokio::test]
2300 async fn job_from_pb_preserves_valid_trace_context() {
2301 let client = test_client();
2302 let mut p = valid_job_pb();
2303 p.trace_context = Some(pb::TraceContext {
2304 traceparent: VALID_TP.into(),
2305 tracestate: Some("v=1".into()),
2306 });
2307 let job = job_from_pb(&client, p, None).unwrap();
2308 let tc = job.ctx.trace_context.as_ref().unwrap();
2309 assert_eq!(tc.traceparent(), VALID_TP);
2310 assert_eq!(tc.tracestate(), Some("v=1"));
2311 }
2312
2313 #[test]
2314 fn reserve_opts_empty_queues() {
2315 let r = ReserveOptions::new(Vec::<String>::new(), Duration::from_secs(1));
2316 assert!(matches!(r, Err(ReserveOptionsError::EmptyQueues)));
2317 }
2318
2319 #[test]
2320 fn reserve_opts_empty_queue_name_at_index() {
2321 let r = ReserveOptions::new(["ok", ""], Duration::from_secs(1));
2322 assert!(matches!(r, Err(ReserveOptionsError::EmptyQueueName(1))));
2323 }
2324
2325 #[test]
2326 fn reserve_opts_zero_lease() {
2327 let r = ReserveOptions::new(["q"], Duration::ZERO);
2328 assert!(matches!(r, Err(ReserveOptionsError::LeaseDurationTooShort)));
2329 }
2330
2331 #[test]
2332 fn reserve_opts_with_worker_id_empty_rejected() {
2333 let opts = ReserveOptions::new(["q"], Duration::from_secs(1)).unwrap();
2334 assert!(matches!(
2335 opts.with_worker_id(""),
2336 Err(ReserveOptionsError::EmptyWorkerId)
2337 ));
2338 }
2339
2340 #[test]
2341 fn reserve_opts_default_wait_timeout_30s() {
2342 let opts = ReserveOptions::new(["q"], Duration::from_secs(1)).unwrap();
2343 assert_eq!(opts.wait_timeout(), Duration::from_secs(30));
2344 }
2345
2346 #[test]
2347 fn reserve_opts_with_wait_timeout_overrides() {
2348 let opts = ReserveOptions::new(["q"], Duration::from_secs(1))
2349 .unwrap()
2350 .with_wait_timeout(Duration::from_millis(500));
2351 assert_eq!(opts.wait_timeout(), Duration::from_millis(500));
2352 }
2353
2354 #[test]
2355 fn reserve_opts_to_pb_all_fields() {
2356 let opts = ReserveOptions::new(["q1", "q2"], Duration::from_millis(5_000))
2357 .unwrap()
2358 .with_wait_timeout(Duration::from_millis(2_000))
2359 .with_worker_id("w")
2360 .unwrap()
2361 .with_max_jobs(7);
2362 let pb: pb::ReserveRequest = opts.into();
2363 assert_eq!(pb.queues, vec!["q1".to_string(), "q2".to_string()]);
2364 assert_eq!(
2365 pb.wait_timeout,
2366 Some(prost_types::Duration {
2367 seconds: 2,
2368 nanos: 0
2369 })
2370 );
2371 assert_eq!(
2372 pb.lease_duration,
2373 Some(prost_types::Duration {
2374 seconds: 5,
2375 nanos: 0
2376 })
2377 );
2378 assert_eq!(pb.worker_id.as_deref(), Some("w"));
2379 assert_eq!(pb.max_jobs, Some(7));
2380 }
2381
2382 #[test]
2383 fn reserve_opts_to_pb_by_ref_matches_by_value() {
2384 let opts = ReserveOptions::new(["q"], Duration::from_millis(1_000)).unwrap();
2385 let by_ref: pb::ReserveRequest = (&opts).into();
2386 let by_val: pb::ReserveRequest = opts.into();
2387 assert_eq!(by_ref, by_val);
2388 }
2389
2390 fn ts(seconds: i64, nanos: i32) -> prost_types::Timestamp {
2391 prost_types::Timestamp { seconds, nanos }
2392 }
2393
2394 fn dur(seconds: i64) -> prost_types::Duration {
2395 prost_types::Duration { seconds, nanos: 0 }
2396 }
2397
2398 #[test]
2399 fn timestamp_to_system_time_epoch() {
2400 assert_eq!(
2401 timestamp_to_system_time(Some(ts(0, 0))),
2402 Some(SystemTime::UNIX_EPOCH)
2403 );
2404 }
2405
2406 #[test]
2407 fn timestamp_to_system_time_none_is_none() {
2408 assert!(timestamp_to_system_time(None).is_none());
2409 }
2410
2411 #[test]
2412 fn timestamp_to_system_time_negative_is_none() {
2413 assert!(timestamp_to_system_time(Some(ts(-1, 0))).is_none());
2414 }
2415
2416 #[test]
2417 fn timestamp_to_system_time_positive() {
2418 let t = timestamp_to_system_time(Some(ts(1_700_000_000, 0))).unwrap();
2419 assert_eq!(
2420 t,
2421 SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
2422 );
2423 }
2424
2425 #[test]
2426 fn system_time_to_timestamp_round_trip() {
2427 let t = SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
2428 let pb = system_time_to_timestamp(t);
2429 assert_eq!(pb.seconds, 1_700_000_000);
2430 assert_eq!(pb.nanos, 123_000_000);
2431 }
2432
2433 #[test]
2434 fn proto_timestamp_to_millis_handles_none_and_value() {
2435 assert_eq!(proto_timestamp_to_millis(None), 0);
2436 assert_eq!(
2437 proto_timestamp_to_millis(Some(&ts(120, 500_000_000))),
2438 120_500
2439 );
2440 assert_eq!(proto_timestamp_to_millis(Some(&ts(-5, 0))), -5_000);
2441 }
2442
2443 #[test]
2444 fn duration_to_proto_round_trip() {
2445 let pb = duration_to_proto(Duration::from_millis(5_000));
2446 assert_eq!(pb.seconds, 5);
2447 assert_eq!(pb.nanos, 0);
2448 }
2449
2450 #[test]
2451 fn duration_to_proto_saturates_on_overflow() {
2452 let pb = duration_to_proto(Duration::new(u64::MAX, 0));
2453 assert_eq!(pb.seconds, i64::MAX);
2454 assert_eq!(pb.nanos, 999_999_999);
2455 }
2456
2457 #[test]
2458 fn system_time_to_millis_at_epoch_is_zero() {
2459 assert_eq!(system_time_to_millis(SystemTime::UNIX_EPOCH), 0);
2460 }
2461
2462 #[test]
2463 fn system_time_to_millis_pre_epoch_returns_zero() {
2464 let t = SystemTime::UNIX_EPOCH - Duration::from_secs(1);
2465 assert_eq!(system_time_to_millis(t), 0);
2466 }
2467
2468 #[test]
2469 fn system_time_to_millis_round_trip() {
2470 let t = SystemTime::UNIX_EPOCH + Duration::from_millis(42);
2471 assert_eq!(system_time_to_millis(t), 42);
2472 }
2473
2474 fn valid_server_info_pb() -> pb::GetServerInfoResponse {
2475 pb::GetServerInfoResponse {
2476 server_version: "1.2.3".into(),
2477 supported_protocol_versions: vec!["v1".into()],
2478 server_time: Some(ts(1_700_000_000, 0)),
2479 restricts_encodings: false,
2480 allowed_encodings: vec!["json".into()],
2481 max_payload_bytes: 1024,
2482 max_custom_entries: 10,
2483 max_custom_total_bytes: 2048,
2484 max_custom_key_bytes: 64,
2485 max_queue_name_bytes: 512,
2486 max_job_type_bytes: 256,
2487 max_idempotency_key_bytes: 128,
2488 max_schedule_horizon: Some(dur(86_400)),
2489 max_enqueue_batch: 100,
2490 max_reserve_batch: 50,
2491 max_reserve_queues: 8,
2492 max_wait_timeout: Some(dur(30)),
2493 max_lease_duration: Some(dur(60)),
2494 strict_queues: true,
2495 dead_letter_retention_enabled: false,
2496 }
2497 }
2498
2499 #[test]
2500 fn server_info_missing_version() {
2501 let mut p = valid_server_info_pb();
2502 p.server_version.clear();
2503 assert!(matches!(
2504 ServerInfo::try_from(p),
2505 Err(ServerInfoError::MissingField("server_version"))
2506 ));
2507 }
2508
2509 #[test]
2510 fn server_info_invalid_server_time() {
2511 let mut p = valid_server_info_pb();
2512 p.server_time = Some(ts(-1, 0));
2513 assert!(matches!(
2514 ServerInfo::try_from(p),
2515 Err(ServerInfoError::InvalidServerTime(-1_000))
2516 ));
2517 }
2518
2519 #[test]
2520 fn server_info_happy_path() {
2521 let info = ServerInfo::try_from(valid_server_info_pb()).unwrap();
2522 assert_eq!(info.version, "1.2.3");
2523 assert_eq!(info.supported_protocol_versions, vec!["v1".to_string()]);
2524 assert_eq!(info.allowed_encodings, vec!["json".to_string()]);
2525 assert!(!info.restricts_encodings);
2526 assert_eq!(info.max_payload_bytes, 1024);
2527 assert_eq!(info.max_custom_entries, 10);
2528 assert_eq!(info.max_custom_total_bytes, 2048);
2529 assert_eq!(info.max_custom_key_bytes, 64);
2530 assert_eq!(info.max_queue_name_bytes, 512);
2531 assert_eq!(info.max_job_type_bytes, 256);
2532 assert_eq!(info.max_idempotency_key_bytes, 128);
2533 assert_eq!(info.max_schedule_horizon, Duration::from_secs(86_400));
2534 assert_eq!(info.max_enqueue_batch, 100);
2535 assert_eq!(info.max_reserve_batch, 50);
2536 assert_eq!(info.max_reserve_queues, 8);
2537 assert_eq!(info.max_wait_timeout, Duration::from_secs(30));
2538 assert_eq!(info.max_lease_duration, Duration::from_secs(60));
2539 assert!(info.strict_queues);
2540 assert!(!info.dead_letter_retention_enabled);
2541 assert_eq!(
2542 info.server_time,
2543 SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)
2544 );
2545 }
2546
2547 fn valid_dead_letter_pb() -> pb::DeadLetterRecord {
2548 pb::DeadLetterRecord {
2549 job: Some(valid_job_pb()),
2550 cause: pb::DeadLetterCause::AttemptsExhausted as i32,
2551 failed_at: Some(ts(1_700_000_100, 0)),
2552 final_attempt: 5,
2553 last_reason: Some("boom".into()),
2554 }
2555 }
2556
2557 #[tokio::test]
2558 async fn job_ctx_carries_its_queue() {
2559 let client = test_client();
2560 let job = job_from_pb(&client, valid_job_pb(), None).unwrap();
2561 assert_eq!(job.ctx.queue, "emails");
2562 }
2563
2564 #[test]
2565 fn dead_letter_cause_maps_from_pb() {
2566 assert_eq!(
2567 DeadLetterCause::from(pb::DeadLetterCause::AttemptsExhausted),
2568 DeadLetterCause::AttemptsExhausted
2569 );
2570 assert_eq!(
2571 DeadLetterCause::from(pb::DeadLetterCause::Rejected),
2572 DeadLetterCause::Rejected
2573 );
2574 assert_eq!(
2575 DeadLetterCause::from(pb::DeadLetterCause::LeaseExpired),
2576 DeadLetterCause::LeaseExpired
2577 );
2578 assert_eq!(
2579 DeadLetterCause::from(pb::DeadLetterCause::Admin),
2580 DeadLetterCause::Admin
2581 );
2582 assert_eq!(
2583 DeadLetterCause::from(pb::DeadLetterCause::Unspecified),
2584 DeadLetterCause::Unspecified
2585 );
2586 }
2587
2588 #[test]
2589 fn dead_letter_cause_raw_values_via_try_from() {
2590 let mut record = valid_dead_letter_pb();
2591 record.cause = 4;
2592 let r = dead_letter_record_from_pb(record).unwrap();
2593 assert_eq!(r.cause, DeadLetterCause::Admin);
2594
2595 let mut record = valid_dead_letter_pb();
2596 record.cause = 99; let r = dead_letter_record_from_pb(record).unwrap();
2598 assert_eq!(r.cause, DeadLetterCause::Unspecified);
2599 }
2600
2601 #[test]
2602 fn dead_letter_record_converts_from_pb() {
2603 let r = dead_letter_record_from_pb(valid_dead_letter_pb()).unwrap();
2604 assert_eq!(r.job_id, "550e8400-e29b-41d4-a716-446655440000");
2605 assert_eq!(r.queue, "emails");
2606 assert_eq!(r.job_type, "send_email");
2607 assert_eq!(r.cause, DeadLetterCause::AttemptsExhausted);
2608 assert_eq!(r.final_attempt, 5);
2609 assert_eq!(r.last_reason.as_deref(), Some("boom"));
2610 assert_eq!(r.priority.get(), 3);
2611 assert_eq!(r.max_attempts, 5);
2612 assert_eq!(r.scheduled_at, None);
2613 assert_eq!(
2614 r.enqueued_at,
2615 SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)
2616 );
2617 assert_eq!(
2618 r.failed_at,
2619 SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_100_000)
2620 );
2621 }
2622
2623 #[test]
2624 fn dead_letter_record_carries_scheduled_at() {
2625 let mut job = valid_job_pb();
2626 job.scheduled_at = Some(ts(1_700_000_050, 0));
2627 let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
2628 job: Some(job),
2629 ..valid_dead_letter_pb()
2630 })
2631 .unwrap();
2632 assert_eq!(
2633 r.scheduled_at,
2634 Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_050))
2635 );
2636 }
2637
2638 #[test]
2639 fn dead_letter_record_missing_job_is_an_error() {
2640 let mut record = valid_dead_letter_pb();
2641 record.job = None;
2642 assert!(matches!(
2643 dead_letter_record_from_pb(record),
2644 Err(JobConversionError::MissingField("job"))
2645 ));
2646 }
2647
2648 #[test]
2649 fn dead_letter_record_unknown_cause_maps_to_unspecified() {
2650 let mut record = valid_dead_letter_pb();
2651 record.cause = 9999; let r = dead_letter_record_from_pb(record).unwrap();
2653 assert_eq!(r.cause, DeadLetterCause::Unspecified);
2654 }
2655
2656 #[test]
2657 fn dead_letter_record_replays_into_its_queue() {
2658 let mut job = valid_job_pb();
2659 job.payload = Some(pb::Payload {
2660 data: vec![1, 2, 3],
2661 encoding: "json".into(),
2662 });
2663 let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
2664 job: Some(job),
2665 ..valid_dead_letter_pb()
2666 })
2667 .unwrap();
2668
2669 let req: pb::EnqueueRequest = r.to_enqueue_request().into();
2670 assert_eq!(req.queue, "emails");
2671 assert_eq!(req.job_type, "send_email");
2672 assert_eq!(req.priority, Some(3));
2673 assert_eq!(req.payload.map(|p| p.data), Some(vec![1, 2, 3]));
2674 }
2675
2676 #[test]
2677 fn dead_letter_record_replay_preserves_max_attempts() {
2678 let mut job = valid_job_pb();
2679 job.max_attempts = 1; let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
2681 job: Some(job),
2682 ..valid_dead_letter_pb()
2683 })
2684 .unwrap();
2685
2686 let req: pb::EnqueueRequest = r.to_enqueue_request().into();
2687 assert_eq!(req.max_attempts, Some(1));
2688 }
2689
2690 #[test]
2691 fn dead_letter_record_replay_does_not_reschedule() {
2692 let mut job = valid_job_pb();
2695 job.scheduled_at = Some(ts(1_700_000_050, 0));
2696 let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
2697 job: Some(job),
2698 ..valid_dead_letter_pb()
2699 })
2700 .unwrap();
2701
2702 let req: pb::EnqueueRequest = r.to_enqueue_request().into();
2703 assert_eq!(req.scheduled_at, None);
2704 }
2705
2706 #[test]
2707 fn dead_letter_record_converts_into_enqueue_request() {
2708 let r = dead_letter_record_from_pb(valid_dead_letter_pb()).unwrap();
2711 let req: pb::EnqueueRequest = EnqueueRequest::from(r).into();
2712 assert_eq!(req.queue, "emails");
2713 assert_eq!(req.job_type, "send_email");
2714 assert_eq!(req.priority, Some(3));
2715 assert_eq!(req.max_attempts, Some(5));
2716 }
2717
2718 #[test]
2719 fn primitive_from_pb_string_value() {
2720 let pb_value = pb::PrimitiveValue {
2721 value: Some(pb::primitive_value::Value::StringValue("hi".into())),
2722 };
2723 assert_eq!(
2724 primitive_from_pb(pb_value),
2725 Some(Primitive::String("hi".into()))
2726 );
2727 }
2728
2729 #[test]
2730 fn primitive_from_pb_int_value() {
2731 let pb_value = pb::PrimitiveValue {
2732 value: Some(pb::primitive_value::Value::IntValue(42)),
2733 };
2734 assert_eq!(primitive_from_pb(pb_value), Some(Primitive::Int(42)));
2735 }
2736
2737 #[test]
2738 fn primitive_from_pb_double_value() {
2739 let pb_value = pb::PrimitiveValue {
2740 value: Some(pb::primitive_value::Value::DoubleValue(2.5)),
2741 };
2742 assert_eq!(primitive_from_pb(pb_value), Some(Primitive::Double(2.5)));
2743 }
2744
2745 #[test]
2746 fn primitive_from_pb_bool_value() {
2747 let pb_value = pb::PrimitiveValue {
2748 value: Some(pb::primitive_value::Value::BoolValue(true)),
2749 };
2750 assert_eq!(primitive_from_pb(pb_value), Some(Primitive::Bool(true)));
2751 }
2752
2753 #[test]
2754 fn primitive_from_pb_none_value() {
2755 let pb_value = pb::PrimitiveValue { value: None };
2756 assert_eq!(primitive_from_pb(pb_value), None);
2757 }
2758
2759 #[test]
2760 fn proto_duration_to_std_none_is_zero() {
2761 assert_eq!(proto_duration_to_std(None), Duration::ZERO);
2762 }
2763
2764 #[test]
2765 fn proto_duration_to_std_positive() {
2766 let d = proto_duration_to_std(Some(prost_types::Duration {
2767 seconds: 3,
2768 nanos: 500_000_000,
2769 }));
2770 assert_eq!(d, Duration::from_millis(3_500));
2771 }
2772
2773 #[test]
2774 fn proto_duration_to_std_negative_seconds_is_zero() {
2775 let d = proto_duration_to_std(Some(prost_types::Duration {
2776 seconds: -1,
2777 nanos: 0,
2778 }));
2779 assert_eq!(d, Duration::ZERO);
2780 }
2781
2782 #[test]
2783 fn timestamp_to_system_time_zero_seconds_negative_nanos() {
2784 assert!(timestamp_to_system_time(Some(ts(0, -1))).is_none());
2785 }
2786
2787 #[test]
2788 fn now_millis_is_reasonable() {
2789 let now = now_millis();
2790 assert!(now > 1_577_836_800_000, "now_millis too small: {now}");
2791 }
2792
2793 #[tokio::test]
2794 async fn job_ctx_display_contains_key_info() {
2795 let client = test_client();
2796 let job = job_from_pb(&client, valid_job_pb(), None).unwrap();
2797 let display = format!("{}", job.ctx);
2798 assert!(display.contains("send_email"));
2799 assert!(display.contains("1/5"));
2800 assert!(display.contains("priority: 3"));
2801 }
2802
2803 #[test]
2804 fn enqueue_request_with_custom_entry_multiple_types() {
2805 let req = EnqueueRequest::new("q", "t")
2806 .unwrap()
2807 .with_custom_entry("str_key", "hello")
2808 .with_custom_entry("int_key", 42_i64)
2809 .with_custom_entry("bool_key", true);
2810 let p: pb::EnqueueRequest = req.into();
2811 assert_eq!(p.custom.len(), 3);
2812 assert_eq!(
2813 p.custom.get("str_key").and_then(|v| v.value.as_ref()),
2814 Some(&pb::primitive_value::Value::StringValue("hello".into()))
2815 );
2816 assert_eq!(
2817 p.custom.get("int_key").and_then(|v| v.value.as_ref()),
2818 Some(&pb::primitive_value::Value::IntValue(42))
2819 );
2820 assert_eq!(
2821 p.custom.get("bool_key").and_then(|v| v.value.as_ref()),
2822 Some(&pb::primitive_value::Value::BoolValue(true))
2823 );
2824 }
2825
2826 #[test]
2827 fn reserve_opts_to_pb_minimal() {
2828 let opts = ReserveOptions::new(["q"], Duration::from_secs(1)).unwrap();
2829 let p: pb::ReserveRequest = opts.into();
2830 assert_eq!(p.queues, vec!["q".to_string()]);
2831 assert!(p.wait_timeout.is_some());
2832 assert!(p.lease_duration.is_some());
2833 assert!(p.worker_id.is_none());
2834 assert!(p.max_jobs.is_none());
2835 }
2836
2837 #[cfg(feature = "opentelemetry")]
2838 mod opentelemetry_tests {
2839 use super::*;
2840
2841 #[test]
2842 fn otel_span_context_from_valid_traceparent() {
2843 let tc = TraceContext::new(VALID_TP).unwrap();
2844 assert!(tc.otel_span_context().is_some());
2845 }
2846
2847 #[test]
2848 fn otel_span_context_with_tracestate() {
2849 let tc = TraceContext::new(VALID_TP)
2850 .unwrap()
2851 .with_tracestate("vendor=abc");
2852 assert!(tc.otel_span_context().is_some());
2853 }
2854 }
2855}