use std::{
collections::HashMap,
fmt,
time::{Duration, SystemTime},
};
mod pb;
pub mod client;
pub mod worker;
#[derive(Debug, Clone, PartialEq)]
pub enum Primitive {
String(String),
Int(i64),
Double(f64),
Bool(bool),
}
impl From<Primitive> for crate::pb::sepp::v1::PrimitiveValue {
fn from(p: Primitive) -> Self {
use crate::pb::sepp::v1::primitive_value::Value;
let value = match p {
Primitive::String(s) => Value::StringValue(s),
Primitive::Int(i) => Value::IntValue(i),
Primitive::Double(d) => Value::DoubleValue(d),
Primitive::Bool(b) => Value::BoolValue(b),
};
Self { value: Some(value) }
}
}
impl From<&str> for Primitive {
fn from(v: &str) -> Self {
Self::String(v.into())
}
}
impl From<String> for Primitive {
fn from(v: String) -> Self {
Self::String(v)
}
}
impl From<i64> for Primitive {
fn from(v: i64) -> Self {
Self::Int(v)
}
}
impl From<i32> for Primitive {
fn from(v: i32) -> Self {
Self::Int(v.into())
}
}
impl From<f64> for Primitive {
fn from(v: f64) -> Self {
Self::Double(v)
}
}
impl From<bool> for Primitive {
fn from(v: bool) -> Self {
Self::Bool(v)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Payload {
pub data: Vec<u8>,
pub encoding: String,
}
impl Payload {
pub fn new(data: Vec<u8>, encoding: impl Into<String>) -> Self {
Self {
data,
encoding: encoding.into(),
}
}
}
impl From<Payload> for crate::pb::sepp::v1::Payload {
fn from(p: Payload) -> Self {
Self {
data: p.data,
encoding: p.encoding,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Priority(u8);
#[derive(Debug, thiserror::Error)]
#[error("priority must be 0-9, got {0}")]
pub struct PriorityOutOfRange(pub u8);
impl Priority {
pub const MIN: Self = Self(0);
pub const MAX: Self = Self(9);
pub const P0: Self = Self(0);
pub const P1: Self = Self(1);
pub const P2: Self = Self(2);
pub const P3: Self = Self(3);
pub const P4: Self = Self(4);
pub const P5: Self = Self(5);
pub const P6: Self = Self(6);
pub const P7: Self = Self(7);
pub const P8: Self = Self(8);
pub const P9: Self = Self(9);
pub fn new(value: u8) -> Result<Self, PriorityOutOfRange> {
if value > Self::MAX.0 {
Err(PriorityOutOfRange(value))
} else {
Ok(Self(value))
}
}
pub fn get(&self) -> u8 {
self.0
}
}
impl TryFrom<u8> for Priority {
type Error = PriorityOutOfRange;
fn try_from(v: u8) -> Result<Self, Self::Error> {
Self::new(v)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TraceContext {
traceparent: String,
tracestate: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum TraceContextError {
#[error("invalid traceparent: {0}")]
InvalidTraceparent(&'static str),
}
impl TraceContext {
pub fn new(traceparent: impl Into<String>) -> Result<Self, TraceContextError> {
let traceparent = traceparent.into();
validate_traceparent(&traceparent)?;
Ok(Self {
traceparent,
tracestate: None,
})
}
pub fn with_tracestate(mut self, ts: impl Into<String>) -> Self {
self.tracestate = Some(ts.into());
self
}
pub fn traceparent(&self) -> &str {
&self.traceparent
}
pub fn tracestate(&self) -> Option<&str> {
self.tracestate.as_deref()
}
}
#[cfg(feature = "opentelemetry")]
impl TraceContext {
pub fn from_current_otel() -> Option<Self> {
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::TraceContextExt;
use opentelemetry_sdk::propagation::TraceContextPropagator;
let cx = opentelemetry::Context::current();
if !cx.span().span_context().is_valid() {
return None;
}
let mut carrier = std::collections::HashMap::new();
TraceContextPropagator::new().inject_context(&cx, &mut HashMapInjector(&mut carrier));
let traceparent = carrier.remove("traceparent")?;
let tracestate = carrier.remove("tracestate");
Some(Self {
traceparent,
tracestate,
})
}
pub fn attach_to_otel(&self) -> opentelemetry::ContextGuard {
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry_sdk::propagation::TraceContextPropagator;
let mut carrier = std::collections::HashMap::new();
carrier.insert("traceparent".to_string(), self.traceparent.clone());
if let Some(ts) = &self.tracestate {
carrier.insert("tracestate".to_string(), ts.clone());
}
let extracted = TraceContextPropagator::new().extract(&HashMapExtractor(&carrier));
extracted.attach()
}
pub fn otel_span_context(&self) -> Option<opentelemetry::trace::SpanContext> {
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::TraceContextExt;
use opentelemetry_sdk::propagation::TraceContextPropagator;
let mut carrier = HashMap::new();
carrier.insert("traceparent".to_string(), self.traceparent.clone());
if let Some(ts) = &self.tracestate {
carrier.insert("tracestate".to_string(), ts.clone());
}
let cx = TraceContextPropagator::new().extract(&HashMapExtractor(&carrier));
let span_context = cx.span().span_context().clone();
span_context.is_valid().then_some(span_context)
}
}
#[cfg(feature = "opentelemetry")]
pub(crate) fn inject_pb_trace_context(
cx: &opentelemetry::Context,
) -> Option<crate::pb::sepp::v1::TraceContext> {
use opentelemetry::propagation::TextMapPropagator;
use opentelemetry::trace::TraceContextExt;
use opentelemetry_sdk::propagation::TraceContextPropagator;
if !cx.span().span_context().is_valid() {
return None;
}
let mut carrier = HashMap::new();
TraceContextPropagator::new().inject_context(cx, &mut HashMapInjector(&mut carrier));
Some(crate::pb::sepp::v1::TraceContext {
traceparent: carrier.remove("traceparent")?,
tracestate: carrier.remove("tracestate"),
})
}
#[cfg(feature = "opentelemetry")]
struct HashMapInjector<'a>(&'a mut HashMap<String, String>);
#[cfg(feature = "opentelemetry")]
impl opentelemetry::propagation::Injector for HashMapInjector<'_> {
fn set(&mut self, key: &str, value: String) {
self.0.insert(key.to_string(), value);
}
}
#[cfg(feature = "opentelemetry")]
struct HashMapExtractor<'a>(&'a HashMap<String, String>);
#[cfg(feature = "opentelemetry")]
impl opentelemetry::propagation::Extractor for HashMapExtractor<'_> {
fn get(&self, key: &str) -> Option<&str> {
self.0.get(key).map(String::as_str)
}
fn keys(&self) -> Vec<&str> {
self.0.keys().map(String::as_str).collect()
}
}
fn validate_traceparent(s: &str) -> Result<(), TraceContextError> {
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 4 {
return Err(TraceContextError::InvalidTraceparent(
"expected 4 hyphen-separated fields",
));
}
let [ver, trace_id, span_id, flags] = [parts[0], parts[1], parts[2], parts[3]];
if ver.len() != 2 || !ver.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(TraceContextError::InvalidTraceparent(
"version must be 2 hex chars",
));
}
if trace_id.len() != 32 || !trace_id.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(TraceContextError::InvalidTraceparent(
"trace_id must be 32 hex chars",
));
}
if trace_id.bytes().all(|b| b == b'0') {
return Err(TraceContextError::InvalidTraceparent(
"trace_id must not be all zeros",
));
}
if span_id.len() != 16 || !span_id.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(TraceContextError::InvalidTraceparent(
"span_id must be 16 hex chars",
));
}
if span_id.bytes().all(|b| b == b'0') {
return Err(TraceContextError::InvalidTraceparent(
"span_id must not be all zeros",
));
}
if flags.len() != 2 || !flags.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(TraceContextError::InvalidTraceparent(
"flags must be 2 hex chars",
));
}
Ok(())
}
impl From<TraceContext> for crate::pb::sepp::v1::TraceContext {
fn from(tc: TraceContext) -> Self {
Self {
traceparent: tc.traceparent,
tracestate: tc.tracestate,
}
}
}
#[derive(Debug, Clone)]
pub struct EnqueueRequest {
queue: String,
job_type: String,
payload: Option<Payload>,
idempotency_key: Option<String>,
priority: Option<Priority>,
max_attempts: Option<u32>,
custom: HashMap<String, Primitive>,
trace_context: Option<TraceContext>,
scheduled_at: Option<SystemTime>,
}
#[derive(Debug, thiserror::Error)]
pub enum EnqueueRequestBuilderError {
#[error("queue name must not be empty")]
EmptyQueue,
#[error("job type must not be empty")]
EmptyJobType,
}
impl EnqueueRequest {
pub fn new(
queue: impl Into<String>,
job_type: impl Into<String>,
) -> Result<Self, EnqueueRequestBuilderError> {
let queue = queue.into();
if queue.is_empty() {
return Err(EnqueueRequestBuilderError::EmptyQueue);
}
let job_type = job_type.into();
if job_type.is_empty() {
return Err(EnqueueRequestBuilderError::EmptyJobType);
}
Ok(Self {
queue,
job_type,
payload: None,
idempotency_key: None,
priority: None,
max_attempts: None,
custom: HashMap::new(),
trace_context: None,
scheduled_at: None,
})
}
pub fn with_payload(mut self, payload: Payload) -> Self {
self.payload = Some(payload);
self
}
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
pub fn with_priority(mut self, priority: Priority) -> Self {
self.priority = Some(priority);
self
}
pub fn with_max_attempts(mut self, attempts: u32) -> Self {
self.max_attempts = Some(attempts);
self
}
pub fn with_custom(mut self, custom: HashMap<String, Primitive>) -> Self {
self.custom = custom;
self
}
pub fn with_custom_entry(
mut self,
key: impl Into<String>,
value: impl Into<Primitive>,
) -> Self {
self.custom.insert(key.into(), value.into());
self
}
pub fn with_trace_context(mut self, trace_context: TraceContext) -> Self {
self.trace_context = Some(trace_context);
self
}
pub fn with_scheduled_at(mut self, scheduled_at: SystemTime) -> Self {
self.scheduled_at = Some(scheduled_at);
self
}
}
impl From<EnqueueRequest> for crate::pb::sepp::v1::EnqueueRequest {
fn from(req: EnqueueRequest) -> Self {
Self {
queue: req.queue,
job_type: req.job_type,
payload: req.payload.map(Into::into),
idempotency_key: req.idempotency_key,
priority: req.priority.map(|p| p.get() as u32),
max_attempts: req.max_attempts,
custom: req.custom.into_iter().map(|(k, v)| (k, v.into())).collect(),
trace_context: req.trace_context.map(Into::into),
scheduled_at: req
.scheduled_at
.filter(|t| t.duration_since(SystemTime::UNIX_EPOCH).is_ok())
.map(system_time_to_timestamp),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnqueueAck {
pub job_id: String,
pub deduplicated: bool,
}
impl From<crate::pb::sepp::v1::EnqueueResponse> for EnqueueAck {
fn from(r: crate::pb::sepp::v1::EnqueueResponse) -> Self {
Self {
job_id: r.job_id,
deduplicated: r.deduplicated,
}
}
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum JobRejection {
#[error("queue {queue:?} is not declared on the server (strict mode)")]
UnknownQueue { queue: String },
#[error("payload size {actual} bytes exceeds the queue limit of {limit}")]
PayloadTooLarge { limit: u64, actual: u64 },
#[error("payload encoding {encoding:?} is not allowed; accepted: {allowed:?}")]
EncodingNotAllowed {
encoding: String,
allowed: Vec<String>,
},
#[error("job_type {job_type:?} is not accepted by this queue; accepted: {allowed:?}")]
JobTypeNotAllowed {
job_type: String,
allowed: Vec<String>,
},
#[error("custom map has {actual} entries, exceeding the queue limit of {limit}")]
CustomEntriesTooMany { limit: u32, actual: u32 },
#[error("custom map's total size {actual} bytes exceeds the queue limit of {limit}")]
CustomMapTooLarge { limit: u64, actual: u64 },
#[error("custom key {key:?} is {actual} bytes, exceeding the limit of {limit}")]
CustomKeyTooLong {
key: String,
limit: u32,
actual: u64,
},
#[error("queue name is {actual} bytes, exceeding the limit of {limit}")]
QueueNameTooLong { limit: u32, actual: u64 },
#[error("job_type is {actual} bytes, exceeding the limit of {limit}")]
JobTypeNameTooLong { limit: u32, actual: u64 },
#[error("idempotency_key is {actual} bytes, exceeding the limit of {limit}")]
IdempotencyKeyTooLong { limit: u32, actual: u64 },
#[error("scheduled_at {actual:?} is beyond the max schedule horizon ({horizon:?})")]
ScheduledTooFar {
horizon: Duration,
actual: SystemTime,
},
#[error("structural validation failed: {message}")]
InvalidRequest { message: String },
#[error("queue {queue:?} is full (max depth {limit})")]
QueueFull { queue: String, limit: u64 },
#[error("queue {queue:?} is being deleted and is not accepting new jobs")]
QueueClosing { queue: String },
#[error("server returned an unrecognized rejection variant")]
Unknown,
}
impl From<crate::pb::sepp::v1::JobRejection> for JobRejection {
fn from(r: crate::pb::sepp::v1::JobRejection) -> Self {
use crate::pb::sepp::v1::job_rejection::Reason;
match r.reason {
Some(Reason::UnknownQueue(x)) => Self::UnknownQueue { queue: x.queue },
Some(Reason::PayloadTooLarge(x)) => Self::PayloadTooLarge {
limit: x.limit,
actual: x.actual,
},
Some(Reason::EncodingNotAllowed(x)) => Self::EncodingNotAllowed {
encoding: x.encoding,
allowed: x.allowed,
},
Some(Reason::JobTypeNotAllowed(x)) => Self::JobTypeNotAllowed {
job_type: x.job_type,
allowed: x.allowed,
},
Some(Reason::CustomEntriesTooMany(x)) => Self::CustomEntriesTooMany {
limit: x.limit,
actual: x.actual,
},
Some(Reason::CustomMapTooLarge(x)) => Self::CustomMapTooLarge {
limit: x.limit,
actual: x.actual,
},
Some(Reason::CustomKeyTooLong(x)) => Self::CustomKeyTooLong {
key: x.key,
limit: x.limit,
actual: x.actual,
},
Some(Reason::QueueNameTooLong(x)) => Self::QueueNameTooLong {
limit: x.limit,
actual: x.actual,
},
Some(Reason::JobTypeNameTooLong(x)) => Self::JobTypeNameTooLong {
limit: x.limit,
actual: x.actual,
},
Some(Reason::IdempotencyKeyTooLong(x)) => Self::IdempotencyKeyTooLong {
limit: x.limit,
actual: x.actual,
},
Some(Reason::ScheduledTooFar(x)) => Self::ScheduledTooFar {
horizon: proto_duration_to_std(x.horizon),
actual: timestamp_to_system_time(x.actual).unwrap_or(SystemTime::UNIX_EPOCH),
},
Some(Reason::InvalidRequest(x)) => Self::InvalidRequest { message: x.message },
Some(Reason::QueueFull(x)) => Self::QueueFull {
queue: x.queue,
limit: x.limit,
},
Some(Reason::QueueClosing(x)) => Self::QueueClosing { queue: x.queue },
None => Self::Unknown,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct JobValidationError {
pub index: u32,
pub rejection: JobRejection,
}
impl From<crate::pb::sepp::v1::JobValidationError> for JobValidationError {
fn from(e: crate::pb::sepp::v1::JobValidationError) -> Self {
Self {
index: e.index,
rejection: e
.rejection
.map(JobRejection::from)
.unwrap_or(JobRejection::Unknown),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum AtomicEnqueueError {
#[error(transparent)]
Client(#[from] crate::client::ClientError),
#[error("atomic batch rejected: {} job(s) failed validation", _0.len())]
Validation(Vec<JobValidationError>),
}
impl From<tonic::Status> for AtomicEnqueueError {
fn from(s: tonic::Status) -> Self {
Self::Client(crate::client::ClientError::from(s))
}
}
#[derive(Debug, Clone)]
pub struct JobCtx {
pub id: String,
pub queue: String,
pub job_type: String,
pub priority: Priority,
pub attempt: u32,
pub max_attempts: u32,
pub enqueued_at: SystemTime,
pub scheduled_at: Option<SystemTime>,
pub custom: HashMap<String, Primitive>,
pub trace_context: Option<TraceContext>,
pub lease_expires_at: SystemTime,
pub(crate) lease: crate::client::Lease,
}
impl JobCtx {
pub async fn extend(
&self,
extension: Duration,
) -> Result<SystemTime, crate::client::LeaseError> {
self.lease.extend(extension).await
}
}
impl fmt::Display for JobCtx {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"JobCtx {{ id: {}, job_type: {}, attempt: {}/{}, priority: {} }}",
self.id,
self.job_type,
self.attempt,
self.max_attempts,
self.priority.get(),
)
}
}
#[derive(Debug, Clone)]
pub struct Job {
pub payload: Option<Payload>,
pub ctx: JobCtx,
}
#[derive(Debug, thiserror::Error)]
pub enum JobConversionError {
#[error("job is missing required field `{0}`")]
MissingField(&'static str),
#[error("job priority {0} is out of range (expected 0-9)")]
PriorityOutOfRange(u32),
#[error("job timestamp `{field}` is not a representable time ({value}ms)")]
InvalidTimestamp { field: &'static str, value: i64 },
#[error("custom value for key `{0}` has no value set")]
EmptyCustomValue(String),
}
impl From<crate::pb::sepp::v1::Payload> for Payload {
fn from(p: crate::pb::sepp::v1::Payload) -> Self {
Self {
data: p.data,
encoding: p.encoding,
}
}
}
impl TryFrom<crate::pb::sepp::v1::TraceContext> for TraceContext {
type Error = TraceContextError;
fn try_from(tc: crate::pb::sepp::v1::TraceContext) -> Result<Self, Self::Error> {
let mut ctx = TraceContext::new(tc.traceparent)?;
if let Some(ts) = tc.tracestate {
ctx = ctx.with_tracestate(ts);
}
Ok(ctx)
}
}
fn primitive_from_pb(v: crate::pb::sepp::v1::PrimitiveValue) -> Option<Primitive> {
use crate::pb::sepp::v1::primitive_value::Value;
Some(match v.value? {
Value::StringValue(s) => Primitive::String(s),
Value::DoubleValue(d) => Primitive::Double(d),
Value::IntValue(i) => Primitive::Int(i),
Value::BoolValue(b) => Primitive::Bool(b),
})
}
pub(crate) fn system_time_to_millis(t: SystemTime) -> i64 {
t.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub(crate) fn now_millis() -> i64 {
system_time_to_millis(SystemTime::now())
}
pub(crate) fn timestamp_to_system_time(ts: Option<prost_types::Timestamp>) -> Option<SystemTime> {
let ts = ts?;
if ts.seconds < 0 || (ts.seconds == 0 && ts.nanos < 0) {
return None;
}
SystemTime::try_from(ts).ok()
}
fn system_time_to_timestamp(t: SystemTime) -> prost_types::Timestamp {
prost_types::Timestamp::from(t)
}
fn proto_timestamp_to_millis(ts: Option<&prost_types::Timestamp>) -> i64 {
match ts {
Some(ts) => ts
.seconds
.saturating_mul(1_000)
.saturating_add(i64::from(ts.nanos) / 1_000_000),
None => 0,
}
}
pub(crate) fn duration_to_proto(d: Duration) -> prost_types::Duration {
prost_types::Duration::try_from(d).unwrap_or(prost_types::Duration {
seconds: i64::MAX,
nanos: 999_999_999,
})
}
fn proto_duration_to_std(d: Option<prost_types::Duration>) -> Duration {
d.and_then(|d| Duration::try_from(d).ok())
.unwrap_or(Duration::ZERO)
}
pub(crate) fn job_from_pb(
client: &crate::client::SeppClient,
j: crate::pb::sepp::v1::Job,
worker_id: Option<&str>,
) -> Result<Job, JobConversionError> {
use JobConversionError as E;
if j.id.is_empty() {
return Err(E::MissingField("id"));
}
if j.job_type.is_empty() {
return Err(E::MissingField("job_type"));
}
let priority = u8::try_from(j.priority)
.ok()
.and_then(|p| Priority::new(p).ok())
.ok_or(E::PriorityOutOfRange(j.priority))?;
let enqueued_at_ms = proto_timestamp_to_millis(j.enqueued_at.as_ref());
let enqueued_at = timestamp_to_system_time(j.enqueued_at).ok_or(E::InvalidTimestamp {
field: "enqueued_at",
value: enqueued_at_ms,
})?;
let lease_expires_at_ms = proto_timestamp_to_millis(j.lease_expires_at.as_ref());
let lease_expires_at =
timestamp_to_system_time(j.lease_expires_at).ok_or(E::InvalidTimestamp {
field: "lease_expires_at",
value: lease_expires_at_ms,
})?;
let mut custom = HashMap::with_capacity(j.custom.len());
for (k, v) in j.custom {
let value = primitive_from_pb(v).ok_or_else(|| E::EmptyCustomValue(k.clone()))?;
custom.insert(k, value);
}
let trace_context = j
.trace_context
.and_then(|tc| TraceContext::try_from(tc).ok());
let lease = crate::client::Lease::new(
client.clone(),
j.id.clone(),
j.attempt,
lease_expires_at,
worker_id.map(String::from),
);
Ok(Job {
payload: j.payload.map(Into::into),
ctx: JobCtx {
id: j.id,
queue: j.queue,
job_type: j.job_type,
priority,
attempt: j.attempt,
max_attempts: j.max_attempts,
enqueued_at,
scheduled_at: timestamp_to_system_time(j.scheduled_at),
custom,
trace_context,
lease_expires_at,
lease,
},
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReserveOptions {
queues: Vec<String>,
wait_timeout: Duration,
lease_duration: Duration,
worker_id: Option<String>,
max_jobs: Option<u32>,
}
#[derive(Debug, thiserror::Error)]
pub enum ReserveOptionsError {
#[error("at least one queue must be specified")]
EmptyQueues,
#[error("queue name at index {0} must not be empty")]
EmptyQueueName(usize),
#[error("lease_duration must be at least 1ms")]
LeaseDurationTooShort,
#[error("worker_id must not be empty when set")]
EmptyWorkerId,
}
impl ReserveOptions {
pub fn new(
queues: impl IntoIterator<Item = impl Into<String>>,
lease_duration: Duration,
) -> Result<Self, ReserveOptionsError> {
let queues: Vec<String> = queues.into_iter().map(Into::into).collect();
if queues.is_empty() {
return Err(ReserveOptionsError::EmptyQueues);
}
for (i, q) in queues.iter().enumerate() {
if q.is_empty() {
return Err(ReserveOptionsError::EmptyQueueName(i));
}
}
if lease_duration.is_zero() {
return Err(ReserveOptionsError::LeaseDurationTooShort);
}
Ok(Self {
queues,
wait_timeout: Duration::from_secs(30),
lease_duration,
worker_id: None,
max_jobs: None,
})
}
pub fn with_wait_timeout(mut self, wait: Duration) -> Self {
self.wait_timeout = wait;
self
}
pub fn wait_timeout(&self) -> Duration {
self.wait_timeout
}
pub fn with_worker_id(mut self, id: impl Into<String>) -> Result<Self, ReserveOptionsError> {
let id = id.into();
if id.is_empty() {
return Err(ReserveOptionsError::EmptyWorkerId);
}
self.worker_id = Some(id);
Ok(self)
}
pub fn with_max_jobs(mut self, max: u32) -> Self {
self.max_jobs = Some(max);
self
}
}
impl From<ReserveOptions> for crate::pb::sepp::v1::ReserveRequest {
fn from(o: ReserveOptions) -> Self {
Self::from(&o)
}
}
impl From<&ReserveOptions> for crate::pb::sepp::v1::ReserveRequest {
fn from(o: &ReserveOptions) -> Self {
Self {
queues: o.queues.clone(),
wait_timeout: Some(duration_to_proto(o.wait_timeout)),
lease_duration: Some(duration_to_proto(o.lease_duration)),
worker_id: o.worker_id.clone(),
max_jobs: o.max_jobs,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ServerInfo {
pub version: String,
pub supported_protocol_versions: Vec<String>,
pub server_time: SystemTime,
pub restricts_encodings: bool,
pub allowed_encodings: Vec<String>,
pub max_payload_bytes: u64,
pub max_custom_entries: u32,
pub max_custom_total_bytes: u64,
pub max_custom_key_bytes: u32,
pub max_queue_name_bytes: u32,
pub max_job_type_bytes: u32,
pub max_idempotency_key_bytes: u32,
pub max_schedule_horizon: Duration,
pub max_enqueue_batch: u32,
pub max_reserve_batch: u32,
pub max_reserve_queues: u32,
pub max_wait_timeout: Duration,
pub max_lease_duration: Duration,
pub strict_queues: bool,
pub dead_letter_retention_enabled: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ServerInfoError {
#[error("server info is missing required field `{0}`")]
MissingField(&'static str),
#[error("server_time is not a representable time ({0}ms)")]
InvalidServerTime(i64),
}
impl TryFrom<crate::pb::sepp::v1::GetServerInfoResponse> for ServerInfo {
type Error = ServerInfoError;
fn try_from(r: crate::pb::sepp::v1::GetServerInfoResponse) -> Result<Self, Self::Error> {
if r.server_version.is_empty() {
return Err(ServerInfoError::MissingField("server_version"));
}
let server_time_ms = proto_timestamp_to_millis(r.server_time.as_ref());
let server_time = timestamp_to_system_time(r.server_time)
.ok_or(ServerInfoError::InvalidServerTime(server_time_ms))?;
Ok(Self {
version: r.server_version,
supported_protocol_versions: r.supported_protocol_versions,
server_time,
restricts_encodings: r.restricts_encodings,
allowed_encodings: r.allowed_encodings,
max_payload_bytes: r.max_payload_bytes,
max_custom_entries: r.max_custom_entries,
max_custom_total_bytes: r.max_custom_total_bytes,
max_custom_key_bytes: r.max_custom_key_bytes,
max_queue_name_bytes: r.max_queue_name_bytes,
max_job_type_bytes: r.max_job_type_bytes,
max_idempotency_key_bytes: r.max_idempotency_key_bytes,
max_schedule_horizon: proto_duration_to_std(r.max_schedule_horizon),
max_enqueue_batch: r.max_enqueue_batch,
max_reserve_batch: r.max_reserve_batch,
max_reserve_queues: r.max_reserve_queues,
max_wait_timeout: proto_duration_to_std(r.max_wait_timeout),
max_lease_duration: proto_duration_to_std(r.max_lease_duration),
strict_queues: r.strict_queues,
dead_letter_retention_enabled: r.dead_letter_retention_enabled,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DeadLetterCause {
Unspecified,
AttemptsExhausted,
Rejected,
LeaseExpired,
Admin,
}
impl From<crate::pb::sepp::v1::DeadLetterCause> for DeadLetterCause {
fn from(c: crate::pb::sepp::v1::DeadLetterCause) -> Self {
use crate::pb::sepp::v1::DeadLetterCause as Pb;
match c {
Pb::Unspecified => Self::Unspecified,
Pb::AttemptsExhausted => Self::AttemptsExhausted,
Pb::Rejected => Self::Rejected,
Pb::LeaseExpired => Self::LeaseExpired,
Pb::Admin => Self::Admin,
}
}
}
#[derive(Debug, Clone)]
pub struct DeadLetterRecord {
pub queue: String,
pub job_id: String,
pub job_type: String,
pub payload: Option<Payload>,
pub priority: Priority,
pub max_attempts: u32,
pub custom: HashMap<String, Primitive>,
pub trace_context: Option<TraceContext>,
pub enqueued_at: SystemTime,
pub scheduled_at: Option<SystemTime>,
pub cause: DeadLetterCause,
pub failed_at: SystemTime,
pub final_attempt: u32,
pub last_reason: Option<String>,
}
impl DeadLetterRecord {
pub fn to_enqueue_request(&self) -> EnqueueRequest {
self.into()
}
}
impl From<DeadLetterRecord> for EnqueueRequest {
fn from(r: DeadLetterRecord) -> Self {
EnqueueRequest {
queue: r.queue,
job_type: r.job_type,
payload: r.payload,
idempotency_key: None,
priority: Some(r.priority),
max_attempts: Some(r.max_attempts),
custom: r.custom,
trace_context: r.trace_context,
scheduled_at: None,
}
}
}
impl From<&DeadLetterRecord> for EnqueueRequest {
fn from(r: &DeadLetterRecord) -> Self {
EnqueueRequest {
queue: r.queue.clone(),
job_type: r.job_type.clone(),
payload: r.payload.clone(),
idempotency_key: None,
priority: Some(r.priority),
max_attempts: Some(r.max_attempts),
custom: r.custom.clone(),
trace_context: r.trace_context.clone(),
scheduled_at: None,
}
}
}
pub(crate) fn dead_letter_record_from_pb(
r: crate::pb::sepp::v1::DeadLetterRecord,
) -> Result<DeadLetterRecord, JobConversionError> {
use JobConversionError as E;
let j = r.job.ok_or(E::MissingField("job"))?;
if j.id.is_empty() {
return Err(E::MissingField("id"));
}
if j.job_type.is_empty() {
return Err(E::MissingField("job_type"));
}
let priority = u8::try_from(j.priority)
.ok()
.and_then(|p| Priority::new(p).ok())
.ok_or(E::PriorityOutOfRange(j.priority))?;
let enqueued_at_ms = proto_timestamp_to_millis(j.enqueued_at.as_ref());
let enqueued_at = timestamp_to_system_time(j.enqueued_at).ok_or(E::InvalidTimestamp {
field: "enqueued_at",
value: enqueued_at_ms,
})?;
let failed_at_ms = proto_timestamp_to_millis(r.failed_at.as_ref());
let failed_at = timestamp_to_system_time(r.failed_at).ok_or(E::InvalidTimestamp {
field: "failed_at",
value: failed_at_ms,
})?;
let mut custom = HashMap::with_capacity(j.custom.len());
for (k, v) in j.custom {
let value = primitive_from_pb(v).ok_or_else(|| E::EmptyCustomValue(k.clone()))?;
custom.insert(k, value);
}
let trace_context = j
.trace_context
.and_then(|tc| TraceContext::try_from(tc).ok());
let cause = crate::pb::sepp::v1::DeadLetterCause::try_from(r.cause)
.unwrap_or(crate::pb::sepp::v1::DeadLetterCause::Unspecified)
.into();
Ok(DeadLetterRecord {
queue: j.queue,
job_id: j.id,
job_type: j.job_type,
payload: j.payload.map(Into::into),
priority,
max_attempts: j.max_attempts,
custom,
trace_context,
enqueued_at,
scheduled_at: timestamp_to_system_time(j.scheduled_at),
cause,
failed_at,
final_attempt: r.final_attempt,
last_reason: r.last_reason,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pb::sepp::v1 as pb;
const VALID_TP: &str = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01";
fn test_client() -> crate::client::SeppClient {
let chan = tonic::transport::Endpoint::from_static("http://[::1]:1").connect_lazy();
crate::client::SeppClient::from_channel(chan)
}
#[test]
fn primitive_from_str() {
assert_eq!(Primitive::from("hi"), Primitive::String("hi".into()));
}
#[test]
fn primitive_from_string() {
assert_eq!(
Primitive::from(String::from("hi")),
Primitive::String("hi".into())
);
}
#[test]
fn primitive_from_i64() {
assert_eq!(Primitive::from(42_i64), Primitive::Int(42));
}
#[test]
fn primitive_from_i32() {
assert_eq!(Primitive::from(42_i32), Primitive::Int(42));
}
#[test]
fn primitive_from_f64() {
assert_eq!(Primitive::from(1.5_f64), Primitive::Double(1.5));
}
#[test]
fn primitive_from_bool() {
assert_eq!(Primitive::from(true), Primitive::Bool(true));
}
#[test]
fn primitive_to_pb_string() {
let pb: pb::PrimitiveValue = Primitive::String("x".into()).into();
assert!(
matches!(pb.value, Some(pb::primitive_value::Value::StringValue(ref s)) if s == "x")
);
}
#[test]
fn primitive_to_pb_int() {
let pb: pb::PrimitiveValue = Primitive::Int(7).into();
assert!(matches!(
pb.value,
Some(pb::primitive_value::Value::IntValue(7))
));
}
#[test]
fn primitive_to_pb_double() {
let pb: pb::PrimitiveValue = Primitive::Double(2.5).into();
assert!(matches!(
pb.value,
Some(pb::primitive_value::Value::DoubleValue(d)) if d == 2.5
));
}
#[test]
fn primitive_to_pb_bool() {
let pb: pb::PrimitiveValue = Primitive::Bool(false).into();
assert!(matches!(
pb.value,
Some(pb::primitive_value::Value::BoolValue(false))
));
}
#[test]
fn payload_to_pb() {
let p = Payload {
data: vec![1, 2, 3],
encoding: "json".into(),
};
let pb: pb::Payload = p.into();
assert_eq!(pb.data, vec![1, 2, 3]);
assert_eq!(pb.encoding, "json");
}
#[test]
fn payload_from_pb() {
let pb = pb::Payload {
data: vec![9],
encoding: "raw".into(),
};
let p: Payload = pb.into();
assert_eq!(p.data, vec![9]);
assert_eq!(p.encoding, "raw");
}
#[test]
fn priority_zero_valid() {
assert_eq!(Priority::new(0).unwrap().get(), 0);
}
#[test]
fn priority_mid_valid() {
assert_eq!(Priority::new(5).unwrap().get(), 5);
}
#[test]
fn priority_max_valid() {
assert_eq!(Priority::new(9).unwrap().get(), 9);
}
#[test]
fn priority_above_max_rejected() {
assert!(matches!(Priority::new(10), Err(PriorityOutOfRange(10))));
}
#[test]
fn priority_u8_max_rejected() {
assert!(matches!(
Priority::new(u8::MAX),
Err(PriorityOutOfRange(255))
));
}
#[test]
fn priority_try_from_ok() {
let p: Priority = 5u8.try_into().unwrap();
assert_eq!(p.get(), 5);
}
#[test]
fn priority_try_from_err() {
assert!(<Priority as TryFrom<u8>>::try_from(11).is_err());
}
#[test]
fn priority_constants() {
assert_eq!(Priority::MIN.get(), 0);
assert_eq!(Priority::MAX.get(), 9);
}
#[test]
fn traceparent_valid() {
assert!(TraceContext::new(VALID_TP).is_ok());
}
#[test]
fn traceparent_wrong_field_count() {
assert!(matches!(
TraceContext::new("00-deadbeef-0123"),
Err(TraceContextError::InvalidTraceparent(_))
));
}
#[test]
fn traceparent_bad_version_length() {
let tp = "0-0123456789abcdef0123456789abcdef-0123456789abcdef-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_non_hex_version() {
let tp = "0g-0123456789abcdef0123456789abcdef-0123456789abcdef-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_bad_trace_id_length() {
let tp = "00-deadbeef-0123456789abcdef-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_non_hex_trace_id() {
let tp = "00-0123456789abcdeg0123456789abcdef-0123456789abcdef-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_all_zero_trace_id_rejected() {
let tp = "00-00000000000000000000000000000000-0123456789abcdef-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_bad_span_id_length() {
let tp = "00-0123456789abcdef0123456789abcdef-deadbeef-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_non_hex_span_id() {
let tp = "00-0123456789abcdef0123456789abcdef-0123456789abcdeg-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_all_zero_span_id_rejected() {
let tp = "00-0123456789abcdef0123456789abcdef-0000000000000000-01";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_bad_flags_length() {
let tp = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-0";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn traceparent_non_hex_flags() {
let tp = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-0g";
assert!(TraceContext::new(tp).is_err());
}
#[test]
fn trace_context_with_tracestate() {
let tc = TraceContext::new(VALID_TP)
.unwrap()
.with_tracestate("vendor=abc");
assert_eq!(tc.traceparent(), VALID_TP);
assert_eq!(tc.tracestate(), Some("vendor=abc"));
}
#[test]
fn trace_context_without_tracestate() {
let tc = TraceContext::new(VALID_TP).unwrap();
assert!(tc.tracestate().is_none());
}
#[test]
fn trace_context_to_pb() {
let tc = TraceContext::new(VALID_TP).unwrap().with_tracestate("v=1");
let pb: pb::TraceContext = tc.into();
assert_eq!(pb.traceparent, VALID_TP);
assert_eq!(pb.tracestate.as_deref(), Some("v=1"));
}
#[test]
fn trace_context_try_from_pb_ok() {
let pb = pb::TraceContext {
traceparent: VALID_TP.into(),
tracestate: Some("v=1".into()),
};
let tc = TraceContext::try_from(pb).unwrap();
assert_eq!(tc.traceparent(), VALID_TP);
assert_eq!(tc.tracestate(), Some("v=1"));
}
#[test]
fn trace_context_try_from_pb_propagates_validation_error() {
let pb = pb::TraceContext {
traceparent: "garbage".into(),
tracestate: None,
};
assert!(TraceContext::try_from(pb).is_err());
}
#[test]
fn enqueue_request_empty_queue_rejected() {
assert!(matches!(
EnqueueRequest::new("", "type"),
Err(EnqueueRequestBuilderError::EmptyQueue)
));
}
#[test]
fn enqueue_request_empty_job_type_rejected() {
assert!(matches!(
EnqueueRequest::new("q", ""),
Err(EnqueueRequestBuilderError::EmptyJobType)
));
}
#[test]
fn enqueue_request_to_pb_minimal() {
let req = EnqueueRequest::new("q", "t").unwrap();
let pb: pb::EnqueueRequest = req.into();
assert_eq!(pb.queue, "q");
assert_eq!(pb.job_type, "t");
assert!(pb.payload.is_none());
assert!(pb.idempotency_key.is_none());
assert!(pb.priority.is_none());
assert!(pb.max_attempts.is_none());
assert!(pb.custom.is_empty());
assert!(pb.trace_context.is_none());
assert!(pb.scheduled_at.is_none());
}
#[test]
fn enqueue_request_to_pb_all_fields() {
let mut custom = HashMap::new();
custom.insert("k".into(), Primitive::Int(1));
let req = EnqueueRequest::new("q", "t")
.unwrap()
.with_payload(Payload {
data: vec![1],
encoding: "raw".into(),
})
.with_idempotency_key("idem")
.with_priority(Priority::new(7).unwrap())
.with_max_attempts(5)
.with_custom(custom)
.with_trace_context(TraceContext::new(VALID_TP).unwrap())
.with_scheduled_at(SystemTime::UNIX_EPOCH + Duration::from_millis(1234));
let pb: pb::EnqueueRequest = req.into();
assert_eq!(pb.queue, "q");
assert_eq!(pb.job_type, "t");
assert_eq!(pb.payload.as_ref().unwrap().encoding, "raw");
assert_eq!(pb.payload.as_ref().unwrap().data, vec![1]);
assert_eq!(pb.idempotency_key.as_deref(), Some("idem"));
assert_eq!(pb.priority, Some(7));
assert_eq!(pb.max_attempts, Some(5));
assert_eq!(pb.custom.len(), 1);
assert!(pb.trace_context.is_some());
assert_eq!(pb.scheduled_at, Some(ts(1, 234_000_000)));
}
#[test]
fn enqueue_request_scheduled_at_pre_epoch_becomes_none() {
let req = EnqueueRequest::new("q", "t")
.unwrap()
.with_scheduled_at(SystemTime::UNIX_EPOCH - Duration::from_secs(1));
let pb: pb::EnqueueRequest = req.into();
assert!(pb.scheduled_at.is_none());
}
#[test]
fn enqueue_ack_from_pb() {
let pb = pb::EnqueueResponse {
job_id: "abc".into(),
deduplicated: true,
};
let ack = EnqueueAck::from(pb);
assert_eq!(ack.job_id, "abc");
assert!(ack.deduplicated);
}
fn rej(reason: pb::job_rejection::Reason) -> pb::JobRejection {
pb::JobRejection {
reason: Some(reason),
}
}
#[test]
fn job_rejection_unknown_queue() {
let pb = rej(pb::job_rejection::Reason::UnknownQueue(pb::UnknownQueue {
queue: "q".into(),
}));
assert!(
matches!(JobRejection::from(pb), JobRejection::UnknownQueue { queue } if queue == "q")
);
}
#[test]
fn job_rejection_payload_too_large() {
let pb = rej(pb::job_rejection::Reason::PayloadTooLarge(
pb::PayloadTooLarge {
limit: 10,
actual: 20,
},
));
assert!(matches!(
JobRejection::from(pb),
JobRejection::PayloadTooLarge {
limit: 10,
actual: 20
}
));
}
#[test]
fn job_rejection_encoding_not_allowed() {
let pb = rej(pb::job_rejection::Reason::EncodingNotAllowed(
pb::EncodingNotAllowed {
encoding: "gzip".into(),
allowed: vec!["json".into()],
},
));
match JobRejection::from(pb) {
JobRejection::EncodingNotAllowed { encoding, allowed } => {
assert_eq!(encoding, "gzip");
assert_eq!(allowed, vec!["json".to_string()]);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn job_rejection_job_type_not_allowed() {
let pb = rej(pb::job_rejection::Reason::JobTypeNotAllowed(
pb::JobTypeNotAllowed {
job_type: "x".into(),
allowed: vec!["y".into()],
},
));
match JobRejection::from(pb) {
JobRejection::JobTypeNotAllowed { job_type, allowed } => {
assert_eq!(job_type, "x");
assert_eq!(allowed, vec!["y".to_string()]);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn job_rejection_custom_entries_too_many() {
let pb = rej(pb::job_rejection::Reason::CustomEntriesTooMany(
pb::CustomEntriesTooMany {
limit: 5,
actual: 6,
},
));
assert!(matches!(
JobRejection::from(pb),
JobRejection::CustomEntriesTooMany {
limit: 5,
actual: 6
}
));
}
#[test]
fn job_rejection_custom_map_too_large() {
let pb = rej(pb::job_rejection::Reason::CustomMapTooLarge(
pb::CustomMapTooLarge {
limit: 100,
actual: 200,
},
));
assert!(matches!(
JobRejection::from(pb),
JobRejection::CustomMapTooLarge {
limit: 100,
actual: 200
}
));
}
#[test]
fn job_rejection_custom_key_too_long() {
let pb = rej(pb::job_rejection::Reason::CustomKeyTooLong(
pb::CustomKeyTooLong {
key: "k".into(),
limit: 1,
actual: 2,
},
));
match JobRejection::from(pb) {
JobRejection::CustomKeyTooLong { key, limit, actual } => {
assert_eq!(key, "k");
assert_eq!(limit, 1);
assert_eq!(actual, 2);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn job_rejection_queue_name_too_long() {
let pb = rej(pb::job_rejection::Reason::QueueNameTooLong(
pb::QueueNameTooLong {
limit: 1,
actual: 2,
},
));
assert!(matches!(
JobRejection::from(pb),
JobRejection::QueueNameTooLong {
limit: 1,
actual: 2
}
));
}
#[test]
fn job_rejection_job_type_name_too_long() {
let pb = rej(pb::job_rejection::Reason::JobTypeNameTooLong(
pb::JobTypeNameTooLong {
limit: 3,
actual: 4,
},
));
assert!(matches!(
JobRejection::from(pb),
JobRejection::JobTypeNameTooLong {
limit: 3,
actual: 4
}
));
}
#[test]
fn job_rejection_idempotency_key_too_long() {
let pb = rej(pb::job_rejection::Reason::IdempotencyKeyTooLong(
pb::IdempotencyKeyTooLong {
limit: 8,
actual: 9,
},
));
assert!(matches!(
JobRejection::from(pb),
JobRejection::IdempotencyKeyTooLong {
limit: 8,
actual: 9
}
));
}
#[test]
fn job_rejection_scheduled_too_far() {
let pb = rej(pb::job_rejection::Reason::ScheduledTooFar(
pb::ScheduledTooFar {
horizon: Some(prost_types::Duration {
seconds: 60,
nanos: 0,
}),
actual: Some(ts(120, 0)),
},
));
let JobRejection::ScheduledTooFar { horizon, actual } = JobRejection::from(pb) else {
panic!("expected ScheduledTooFar");
};
assert_eq!(horizon, Duration::from_secs(60));
assert_eq!(actual, SystemTime::UNIX_EPOCH + Duration::from_secs(120));
}
#[test]
fn job_rejection_invalid_request() {
let pb = rej(pb::job_rejection::Reason::InvalidRequest(
pb::InvalidRequest {
message: "oops".into(),
},
));
match JobRejection::from(pb) {
JobRejection::InvalidRequest { message } => assert_eq!(message, "oops"),
_ => panic!("wrong variant"),
}
}
#[test]
fn job_rejection_queue_full() {
let pb = rej(pb::job_rejection::Reason::QueueFull(pb::QueueFull {
queue: "q".into(),
limit: 1000,
}));
match JobRejection::from(pb) {
JobRejection::QueueFull { queue, limit } => {
assert_eq!(queue, "q");
assert_eq!(limit, 1000);
}
_ => panic!("wrong variant"),
}
}
#[test]
fn job_rejection_queue_closing() {
let pb = rej(pb::job_rejection::Reason::QueueClosing(pb::QueueClosing {
queue: "q".into(),
}));
match JobRejection::from(pb) {
JobRejection::QueueClosing { queue } => assert_eq!(queue, "q"),
_ => panic!("wrong variant"),
}
}
#[test]
fn job_rejection_none_is_unknown() {
let pb = pb::JobRejection { reason: None };
assert!(matches!(JobRejection::from(pb), JobRejection::Unknown));
}
#[test]
fn job_validation_error_from_pb() {
let pb = pb::JobValidationError {
index: 3,
rejection: Some(rej(pb::job_rejection::Reason::UnknownQueue(
pb::UnknownQueue { queue: "q".into() },
))),
};
let e = JobValidationError::from(pb);
assert_eq!(e.index, 3);
assert!(matches!(e.rejection, JobRejection::UnknownQueue { queue } if queue == "q"));
}
#[test]
fn job_validation_error_missing_rejection_is_unknown() {
let pb = pb::JobValidationError {
index: 1,
rejection: None,
};
let e = JobValidationError::from(pb);
assert!(matches!(e.rejection, JobRejection::Unknown));
}
fn valid_job_pb() -> pb::Job {
pb::Job {
id: "550e8400-e29b-41d4-a716-446655440000".into(),
job_type: "send_email".into(),
payload: None,
priority: 3,
trace_context: None,
enqueued_at: Some(ts(1_700_000_000, 0)),
attempt: 1,
max_attempts: 5,
lease_expires_at: Some(ts(1_700_000_060, 0)),
custom: HashMap::new(),
scheduled_at: None,
queue: "emails".into(),
}
}
#[tokio::test]
async fn job_from_pb_happy_path() {
let client = test_client();
let mut p = valid_job_pb();
p.payload = Some(pb::Payload {
data: vec![1, 2],
encoding: "json".into(),
});
p.custom.insert(
"k".into(),
pb::PrimitiveValue {
value: Some(pb::primitive_value::Value::StringValue("v".into())),
},
);
let job = job_from_pb(&client, p, None).unwrap();
assert_eq!(job.ctx.id, "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(job.ctx.job_type, "send_email");
assert_eq!(job.ctx.priority.get(), 3);
assert_eq!(job.ctx.attempt, 1);
assert_eq!(job.ctx.max_attempts, 5);
assert_eq!(job.payload.as_ref().unwrap().encoding, "json");
assert_eq!(
job.ctx.custom.get("k"),
Some(&Primitive::String("v".into()))
);
}
#[tokio::test]
async fn job_from_pb_missing_id() {
let client = test_client();
let mut p = valid_job_pb();
p.id.clear();
assert!(matches!(
job_from_pb(&client, p, None),
Err(JobConversionError::MissingField("id"))
));
}
#[tokio::test]
async fn job_from_pb_missing_job_type() {
let client = test_client();
let mut p = valid_job_pb();
p.job_type.clear();
assert!(matches!(
job_from_pb(&client, p, None),
Err(JobConversionError::MissingField("job_type"))
));
}
#[tokio::test]
async fn job_from_pb_priority_out_of_range() {
let client = test_client();
let mut p = valid_job_pb();
p.priority = 10;
assert!(matches!(
job_from_pb(&client, p, None),
Err(JobConversionError::PriorityOutOfRange(10))
));
}
#[tokio::test]
async fn job_from_pb_priority_above_u8() {
let client = test_client();
let mut p = valid_job_pb();
p.priority = 300;
assert!(matches!(
job_from_pb(&client, p, None),
Err(JobConversionError::PriorityOutOfRange(300))
));
}
#[tokio::test]
async fn job_from_pb_invalid_enqueued_at() {
let client = test_client();
let mut p = valid_job_pb();
p.enqueued_at = Some(ts(-1, 0));
assert!(matches!(
job_from_pb(&client, p, None),
Err(JobConversionError::InvalidTimestamp {
field: "enqueued_at",
value: -1_000
})
));
}
#[tokio::test]
async fn job_from_pb_invalid_lease_expires_at() {
let client = test_client();
let mut p = valid_job_pb();
p.lease_expires_at = Some(ts(-5, 0));
assert!(matches!(
job_from_pb(&client, p, None),
Err(JobConversionError::InvalidTimestamp {
field: "lease_expires_at",
value: -5_000
})
));
}
#[tokio::test]
async fn job_from_pb_empty_custom_value() {
let client = test_client();
let mut p = valid_job_pb();
p.custom
.insert("k".into(), pb::PrimitiveValue { value: None });
match job_from_pb(&client, p, None) {
Err(JobConversionError::EmptyCustomValue(k)) => assert_eq!(k, "k"),
_ => panic!("expected EmptyCustomValue"),
}
}
#[tokio::test]
async fn job_from_pb_carries_scheduled_at() {
let client = test_client();
let mut p = valid_job_pb();
p.scheduled_at = Some(ts(1_700_000_030, 0));
let job = job_from_pb(&client, p, None).unwrap();
assert_eq!(
job.ctx.scheduled_at,
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_030))
);
}
#[tokio::test]
async fn job_from_pb_unscheduled_job_has_no_scheduled_at() {
let client = test_client();
let job = job_from_pb(&client, valid_job_pb(), None).unwrap();
assert_eq!(job.ctx.scheduled_at, None);
}
#[tokio::test]
async fn job_from_pb_invalid_scheduled_at_degrades_to_none() {
let client = test_client();
let mut p = valid_job_pb();
p.scheduled_at = Some(ts(-1, 0));
let job = job_from_pb(&client, p, None).unwrap();
assert_eq!(job.ctx.scheduled_at, None);
}
#[tokio::test]
async fn job_from_pb_drops_invalid_trace_context() {
let client = test_client();
let mut p = valid_job_pb();
p.trace_context = Some(pb::TraceContext {
traceparent: "garbage".into(),
tracestate: None,
});
let job = job_from_pb(&client, p, None).unwrap();
assert!(job.ctx.trace_context.is_none());
}
#[tokio::test]
async fn job_from_pb_preserves_valid_trace_context() {
let client = test_client();
let mut p = valid_job_pb();
p.trace_context = Some(pb::TraceContext {
traceparent: VALID_TP.into(),
tracestate: Some("v=1".into()),
});
let job = job_from_pb(&client, p, None).unwrap();
let tc = job.ctx.trace_context.as_ref().unwrap();
assert_eq!(tc.traceparent(), VALID_TP);
assert_eq!(tc.tracestate(), Some("v=1"));
}
#[test]
fn reserve_opts_empty_queues() {
let r = ReserveOptions::new(Vec::<String>::new(), Duration::from_secs(1));
assert!(matches!(r, Err(ReserveOptionsError::EmptyQueues)));
}
#[test]
fn reserve_opts_empty_queue_name_at_index() {
let r = ReserveOptions::new(["ok", ""], Duration::from_secs(1));
assert!(matches!(r, Err(ReserveOptionsError::EmptyQueueName(1))));
}
#[test]
fn reserve_opts_zero_lease() {
let r = ReserveOptions::new(["q"], Duration::ZERO);
assert!(matches!(r, Err(ReserveOptionsError::LeaseDurationTooShort)));
}
#[test]
fn reserve_opts_with_worker_id_empty_rejected() {
let opts = ReserveOptions::new(["q"], Duration::from_secs(1)).unwrap();
assert!(matches!(
opts.with_worker_id(""),
Err(ReserveOptionsError::EmptyWorkerId)
));
}
#[test]
fn reserve_opts_default_wait_timeout_30s() {
let opts = ReserveOptions::new(["q"], Duration::from_secs(1)).unwrap();
assert_eq!(opts.wait_timeout(), Duration::from_secs(30));
}
#[test]
fn reserve_opts_with_wait_timeout_overrides() {
let opts = ReserveOptions::new(["q"], Duration::from_secs(1))
.unwrap()
.with_wait_timeout(Duration::from_millis(500));
assert_eq!(opts.wait_timeout(), Duration::from_millis(500));
}
#[test]
fn reserve_opts_to_pb_all_fields() {
let opts = ReserveOptions::new(["q1", "q2"], Duration::from_millis(5_000))
.unwrap()
.with_wait_timeout(Duration::from_millis(2_000))
.with_worker_id("w")
.unwrap()
.with_max_jobs(7);
let pb: pb::ReserveRequest = opts.into();
assert_eq!(pb.queues, vec!["q1".to_string(), "q2".to_string()]);
assert_eq!(
pb.wait_timeout,
Some(prost_types::Duration {
seconds: 2,
nanos: 0
})
);
assert_eq!(
pb.lease_duration,
Some(prost_types::Duration {
seconds: 5,
nanos: 0
})
);
assert_eq!(pb.worker_id.as_deref(), Some("w"));
assert_eq!(pb.max_jobs, Some(7));
}
#[test]
fn reserve_opts_to_pb_by_ref_matches_by_value() {
let opts = ReserveOptions::new(["q"], Duration::from_millis(1_000)).unwrap();
let by_ref: pb::ReserveRequest = (&opts).into();
let by_val: pb::ReserveRequest = opts.into();
assert_eq!(by_ref, by_val);
}
fn ts(seconds: i64, nanos: i32) -> prost_types::Timestamp {
prost_types::Timestamp { seconds, nanos }
}
fn dur(seconds: i64) -> prost_types::Duration {
prost_types::Duration { seconds, nanos: 0 }
}
#[test]
fn timestamp_to_system_time_epoch() {
assert_eq!(
timestamp_to_system_time(Some(ts(0, 0))),
Some(SystemTime::UNIX_EPOCH)
);
}
#[test]
fn timestamp_to_system_time_none_is_none() {
assert!(timestamp_to_system_time(None).is_none());
}
#[test]
fn timestamp_to_system_time_negative_is_none() {
assert!(timestamp_to_system_time(Some(ts(-1, 0))).is_none());
}
#[test]
fn timestamp_to_system_time_positive() {
let t = timestamp_to_system_time(Some(ts(1_700_000_000, 0))).unwrap();
assert_eq!(
t,
SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
);
}
#[test]
fn system_time_to_timestamp_round_trip() {
let t = SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_123);
let pb = system_time_to_timestamp(t);
assert_eq!(pb.seconds, 1_700_000_000);
assert_eq!(pb.nanos, 123_000_000);
}
#[test]
fn proto_timestamp_to_millis_handles_none_and_value() {
assert_eq!(proto_timestamp_to_millis(None), 0);
assert_eq!(
proto_timestamp_to_millis(Some(&ts(120, 500_000_000))),
120_500
);
assert_eq!(proto_timestamp_to_millis(Some(&ts(-5, 0))), -5_000);
}
#[test]
fn duration_to_proto_round_trip() {
let pb = duration_to_proto(Duration::from_millis(5_000));
assert_eq!(pb.seconds, 5);
assert_eq!(pb.nanos, 0);
}
#[test]
fn duration_to_proto_saturates_on_overflow() {
let pb = duration_to_proto(Duration::new(u64::MAX, 0));
assert_eq!(pb.seconds, i64::MAX);
assert_eq!(pb.nanos, 999_999_999);
}
#[test]
fn system_time_to_millis_at_epoch_is_zero() {
assert_eq!(system_time_to_millis(SystemTime::UNIX_EPOCH), 0);
}
#[test]
fn system_time_to_millis_pre_epoch_returns_zero() {
let t = SystemTime::UNIX_EPOCH - Duration::from_secs(1);
assert_eq!(system_time_to_millis(t), 0);
}
#[test]
fn system_time_to_millis_round_trip() {
let t = SystemTime::UNIX_EPOCH + Duration::from_millis(42);
assert_eq!(system_time_to_millis(t), 42);
}
fn valid_server_info_pb() -> pb::GetServerInfoResponse {
pb::GetServerInfoResponse {
server_version: "1.2.3".into(),
supported_protocol_versions: vec!["v1".into()],
server_time: Some(ts(1_700_000_000, 0)),
restricts_encodings: false,
allowed_encodings: vec!["json".into()],
max_payload_bytes: 1024,
max_custom_entries: 10,
max_custom_total_bytes: 2048,
max_custom_key_bytes: 64,
max_queue_name_bytes: 512,
max_job_type_bytes: 256,
max_idempotency_key_bytes: 128,
max_schedule_horizon: Some(dur(86_400)),
max_enqueue_batch: 100,
max_reserve_batch: 50,
max_reserve_queues: 8,
max_wait_timeout: Some(dur(30)),
max_lease_duration: Some(dur(60)),
strict_queues: true,
dead_letter_retention_enabled: false,
}
}
#[test]
fn server_info_missing_version() {
let mut p = valid_server_info_pb();
p.server_version.clear();
assert!(matches!(
ServerInfo::try_from(p),
Err(ServerInfoError::MissingField("server_version"))
));
}
#[test]
fn server_info_invalid_server_time() {
let mut p = valid_server_info_pb();
p.server_time = Some(ts(-1, 0));
assert!(matches!(
ServerInfo::try_from(p),
Err(ServerInfoError::InvalidServerTime(-1_000))
));
}
#[test]
fn server_info_happy_path() {
let info = ServerInfo::try_from(valid_server_info_pb()).unwrap();
assert_eq!(info.version, "1.2.3");
assert_eq!(info.supported_protocol_versions, vec!["v1".to_string()]);
assert_eq!(info.allowed_encodings, vec!["json".to_string()]);
assert!(!info.restricts_encodings);
assert_eq!(info.max_payload_bytes, 1024);
assert_eq!(info.max_custom_entries, 10);
assert_eq!(info.max_custom_total_bytes, 2048);
assert_eq!(info.max_custom_key_bytes, 64);
assert_eq!(info.max_queue_name_bytes, 512);
assert_eq!(info.max_job_type_bytes, 256);
assert_eq!(info.max_idempotency_key_bytes, 128);
assert_eq!(info.max_schedule_horizon, Duration::from_secs(86_400));
assert_eq!(info.max_enqueue_batch, 100);
assert_eq!(info.max_reserve_batch, 50);
assert_eq!(info.max_reserve_queues, 8);
assert_eq!(info.max_wait_timeout, Duration::from_secs(30));
assert_eq!(info.max_lease_duration, Duration::from_secs(60));
assert!(info.strict_queues);
assert!(!info.dead_letter_retention_enabled);
assert_eq!(
info.server_time,
SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)
);
}
fn valid_dead_letter_pb() -> pb::DeadLetterRecord {
pb::DeadLetterRecord {
job: Some(valid_job_pb()),
cause: pb::DeadLetterCause::AttemptsExhausted as i32,
failed_at: Some(ts(1_700_000_100, 0)),
final_attempt: 5,
last_reason: Some("boom".into()),
}
}
#[tokio::test]
async fn job_ctx_carries_its_queue() {
let client = test_client();
let job = job_from_pb(&client, valid_job_pb(), None).unwrap();
assert_eq!(job.ctx.queue, "emails");
}
#[test]
fn dead_letter_cause_maps_from_pb() {
assert_eq!(
DeadLetterCause::from(pb::DeadLetterCause::AttemptsExhausted),
DeadLetterCause::AttemptsExhausted
);
assert_eq!(
DeadLetterCause::from(pb::DeadLetterCause::Rejected),
DeadLetterCause::Rejected
);
assert_eq!(
DeadLetterCause::from(pb::DeadLetterCause::LeaseExpired),
DeadLetterCause::LeaseExpired
);
assert_eq!(
DeadLetterCause::from(pb::DeadLetterCause::Admin),
DeadLetterCause::Admin
);
assert_eq!(
DeadLetterCause::from(pb::DeadLetterCause::Unspecified),
DeadLetterCause::Unspecified
);
}
#[test]
fn dead_letter_cause_raw_values_via_try_from() {
let mut record = valid_dead_letter_pb();
record.cause = 4;
let r = dead_letter_record_from_pb(record).unwrap();
assert_eq!(r.cause, DeadLetterCause::Admin);
let mut record = valid_dead_letter_pb();
record.cause = 99; let r = dead_letter_record_from_pb(record).unwrap();
assert_eq!(r.cause, DeadLetterCause::Unspecified);
}
#[test]
fn dead_letter_record_converts_from_pb() {
let r = dead_letter_record_from_pb(valid_dead_letter_pb()).unwrap();
assert_eq!(r.job_id, "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(r.queue, "emails");
assert_eq!(r.job_type, "send_email");
assert_eq!(r.cause, DeadLetterCause::AttemptsExhausted);
assert_eq!(r.final_attempt, 5);
assert_eq!(r.last_reason.as_deref(), Some("boom"));
assert_eq!(r.priority.get(), 3);
assert_eq!(r.max_attempts, 5);
assert_eq!(r.scheduled_at, None);
assert_eq!(
r.enqueued_at,
SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_000_000)
);
assert_eq!(
r.failed_at,
SystemTime::UNIX_EPOCH + Duration::from_millis(1_700_000_100_000)
);
}
#[test]
fn dead_letter_record_carries_scheduled_at() {
let mut job = valid_job_pb();
job.scheduled_at = Some(ts(1_700_000_050, 0));
let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
job: Some(job),
..valid_dead_letter_pb()
})
.unwrap();
assert_eq!(
r.scheduled_at,
Some(SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_050))
);
}
#[test]
fn dead_letter_record_missing_job_is_an_error() {
let mut record = valid_dead_letter_pb();
record.job = None;
assert!(matches!(
dead_letter_record_from_pb(record),
Err(JobConversionError::MissingField("job"))
));
}
#[test]
fn dead_letter_record_unknown_cause_maps_to_unspecified() {
let mut record = valid_dead_letter_pb();
record.cause = 9999; let r = dead_letter_record_from_pb(record).unwrap();
assert_eq!(r.cause, DeadLetterCause::Unspecified);
}
#[test]
fn dead_letter_record_replays_into_its_queue() {
let mut job = valid_job_pb();
job.payload = Some(pb::Payload {
data: vec![1, 2, 3],
encoding: "json".into(),
});
let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
job: Some(job),
..valid_dead_letter_pb()
})
.unwrap();
let req: pb::EnqueueRequest = r.to_enqueue_request().into();
assert_eq!(req.queue, "emails");
assert_eq!(req.job_type, "send_email");
assert_eq!(req.priority, Some(3));
assert_eq!(req.payload.map(|p| p.data), Some(vec![1, 2, 3]));
}
#[test]
fn dead_letter_record_replay_preserves_max_attempts() {
let mut job = valid_job_pb();
job.max_attempts = 1; let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
job: Some(job),
..valid_dead_letter_pb()
})
.unwrap();
let req: pb::EnqueueRequest = r.to_enqueue_request().into();
assert_eq!(req.max_attempts, Some(1));
}
#[test]
fn dead_letter_record_replay_does_not_reschedule() {
let mut job = valid_job_pb();
job.scheduled_at = Some(ts(1_700_000_050, 0));
let r = dead_letter_record_from_pb(pb::DeadLetterRecord {
job: Some(job),
..valid_dead_letter_pb()
})
.unwrap();
let req: pb::EnqueueRequest = r.to_enqueue_request().into();
assert_eq!(req.scheduled_at, None);
}
#[test]
fn dead_letter_record_converts_into_enqueue_request() {
let r = dead_letter_record_from_pb(valid_dead_letter_pb()).unwrap();
let req: pb::EnqueueRequest = EnqueueRequest::from(r).into();
assert_eq!(req.queue, "emails");
assert_eq!(req.job_type, "send_email");
assert_eq!(req.priority, Some(3));
assert_eq!(req.max_attempts, Some(5));
}
#[test]
fn primitive_from_pb_string_value() {
let pb_value = pb::PrimitiveValue {
value: Some(pb::primitive_value::Value::StringValue("hi".into())),
};
assert_eq!(
primitive_from_pb(pb_value),
Some(Primitive::String("hi".into()))
);
}
#[test]
fn primitive_from_pb_int_value() {
let pb_value = pb::PrimitiveValue {
value: Some(pb::primitive_value::Value::IntValue(42)),
};
assert_eq!(primitive_from_pb(pb_value), Some(Primitive::Int(42)));
}
#[test]
fn primitive_from_pb_double_value() {
let pb_value = pb::PrimitiveValue {
value: Some(pb::primitive_value::Value::DoubleValue(2.5)),
};
assert_eq!(primitive_from_pb(pb_value), Some(Primitive::Double(2.5)));
}
#[test]
fn primitive_from_pb_bool_value() {
let pb_value = pb::PrimitiveValue {
value: Some(pb::primitive_value::Value::BoolValue(true)),
};
assert_eq!(primitive_from_pb(pb_value), Some(Primitive::Bool(true)));
}
#[test]
fn primitive_from_pb_none_value() {
let pb_value = pb::PrimitiveValue { value: None };
assert_eq!(primitive_from_pb(pb_value), None);
}
#[test]
fn proto_duration_to_std_none_is_zero() {
assert_eq!(proto_duration_to_std(None), Duration::ZERO);
}
#[test]
fn proto_duration_to_std_positive() {
let d = proto_duration_to_std(Some(prost_types::Duration {
seconds: 3,
nanos: 500_000_000,
}));
assert_eq!(d, Duration::from_millis(3_500));
}
#[test]
fn proto_duration_to_std_negative_seconds_is_zero() {
let d = proto_duration_to_std(Some(prost_types::Duration {
seconds: -1,
nanos: 0,
}));
assert_eq!(d, Duration::ZERO);
}
#[test]
fn timestamp_to_system_time_zero_seconds_negative_nanos() {
assert!(timestamp_to_system_time(Some(ts(0, -1))).is_none());
}
#[test]
fn now_millis_is_reasonable() {
let now = now_millis();
assert!(now > 1_577_836_800_000, "now_millis too small: {now}");
}
#[tokio::test]
async fn job_ctx_display_contains_key_info() {
let client = test_client();
let job = job_from_pb(&client, valid_job_pb(), None).unwrap();
let display = format!("{}", job.ctx);
assert!(display.contains("send_email"));
assert!(display.contains("1/5"));
assert!(display.contains("priority: 3"));
}
#[test]
fn enqueue_request_with_custom_entry_multiple_types() {
let req = EnqueueRequest::new("q", "t")
.unwrap()
.with_custom_entry("str_key", "hello")
.with_custom_entry("int_key", 42_i64)
.with_custom_entry("bool_key", true);
let p: pb::EnqueueRequest = req.into();
assert_eq!(p.custom.len(), 3);
assert_eq!(
p.custom.get("str_key").and_then(|v| v.value.as_ref()),
Some(&pb::primitive_value::Value::StringValue("hello".into()))
);
assert_eq!(
p.custom.get("int_key").and_then(|v| v.value.as_ref()),
Some(&pb::primitive_value::Value::IntValue(42))
);
assert_eq!(
p.custom.get("bool_key").and_then(|v| v.value.as_ref()),
Some(&pb::primitive_value::Value::BoolValue(true))
);
}
#[test]
fn reserve_opts_to_pb_minimal() {
let opts = ReserveOptions::new(["q"], Duration::from_secs(1)).unwrap();
let p: pb::ReserveRequest = opts.into();
assert_eq!(p.queues, vec!["q".to_string()]);
assert!(p.wait_timeout.is_some());
assert!(p.lease_duration.is_some());
assert!(p.worker_id.is_none());
assert!(p.max_jobs.is_none());
}
#[cfg(feature = "opentelemetry")]
mod opentelemetry_tests {
use super::*;
#[test]
fn otel_span_context_from_valid_traceparent() {
let tc = TraceContext::new(VALID_TP).unwrap();
assert!(tc.otel_span_context().is_some());
}
#[test]
fn otel_span_context_with_tracestate() {
let tc = TraceContext::new(VALID_TP)
.unwrap()
.with_tracestate("vendor=abc");
assert!(tc.otel_span_context().is_some());
}
}
}