Skip to main content

sepp_rs/
lib.rs

1//! A Rust client for the [sepp](https://github.com/sepp-org/sepp) job queue.
2//!
3//! This client provides both halves of the job queue API:
4//!
5//! - **Producers** enqueue jobs with a [`SeppClient`](client::SeppClient) —
6//!   one at a time, in best-effort batches or atomic batches.
7//! - **Consumers** reserve jobs and report their outcome. The low-level
8//!   [`reserve`](client::SeppClient::reserve) /
9//!   [`ack`](client::SeppClient::ack) / [`nack`](client::SeppClient::nack)
10//!   calls give you full manual control, while the high-level
11//!   [`Worker`](worker::Worker) runs the whole reserve → process → ack loop for
12//!   you with bounded concurrency, optional lease auto-extension, graceful
13//!   shutdown and metrics.
14//!
15//! # Quickstart
16//!
17//! As this crate uses [tonic](https://docs.rs/tonic/latest/tonic/), the client is async only and requires a [tokio runtime](https://docs.rs/tokio/latest/tokio/).
18//!
19//! Enqueue a job, then run a worker that processes it:
20//!
21//! ```no_run
22//! use std::time::Duration;
23//! use sepp_rs::client::SeppClient;
24//! use sepp_rs::worker::Worker;
25//! use sepp_rs::{EnqueueRequest, Payload};
26//!
27//! #[tokio::main]
28//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
29//!     let client = SeppClient::connect("http://127.0.0.1:50051").await?;
30//!
31//!     // Producer: enqueue a job onto the `emails` queue.
32//!     let ack = client
33//!         .enqueue(
34//!             EnqueueRequest::new("emails", "send_welcome")?
35//!                 .with_payload(Payload::new(b"{\"user\":42}".to_vec(), "application/json")),
36//!         )
37//!         .await?;
38//!     println!("enqueued job {}", ack.job_id);
39//!
40//!     // Consumer: process `send_welcome` jobs from the `emails` queue. A handler
41//!     // returns `Ok(())` to ack the job, or a `HandlerError` to nack it.
42//!     Worker::new(client, ["emails"], Duration::from_secs(30))?
43//!         .handle("send_welcome", |payload, ctx| async move {
44//!             println!("processing job {}", ctx.id);
45//!             Ok(())
46//!         })?
47//!         .run()
48//!         .await;
49//!
50//!     Ok(())
51//! }
52//! ```
53//!
54//! # Concepts
55//!
56//! **Queues and job types.** Every job is enqueued onto a named queue and
57//! tagged with a `job_type`. Workers reserve from one or more queues and
58//! dispatch each job to the handler registered for its `job_type`.
59//!
60//! **[`Payload`].** Every job can carry an opaque blob of bytes plus an encoding hint.
61//! You can use this to transport any data you want as long as producers and workers agree on the encoding.
62//! For a primitive key-value map, use [`custom`](EnqueueRequest::with_custom_entry) instead.
63//!
64//! **Leases and redelivery.** A reserved job is leased to the worker for a
65//! bounded duration. The worker must [`ack`](client::SeppClient::ack),
66//! [`nack`](client::SeppClient::nack), or [`extend`](client::SeppClient::extend)
67//! the lease before it expires; otherwise the server redelivers the job to
68//! another worker (with [`attempt`](JobCtx::attempt) incremented) until
69//! `max_attempts` is reached and it is dead-lettered. [`Worker`](worker::Worker)
70//! can extend leases automatically — see
71//! [`with_auto_extend`](worker::Worker::with_auto_extend).
72//!
73//! # Feature flags
74//!
75//! - **`opentelemetry`** *(enabled by default)* — emit OpenTelemetry-compatible
76//!   `tracing` spans and metrics, and propagate W3C trace context from the
77//!   producer's enqueue span to the worker's process span. The host application
78//!   still owns the exporter; see the `traced` example.
79//! - **`tls`** — enable TLS for the transport via the `tls_*` methods on
80//!   [`SeppClientBuilder`](client::SeppClientBuilder).
81
82use std::{
83    collections::HashMap,
84    fmt,
85    time::{Duration, SystemTime},
86};
87
88mod pb;
89
90pub mod client;
91pub mod worker;
92
93/// A JSON-primitive value stored in a job's [custom](EnqueueRequest::with_custom)
94/// metadata map.
95///
96/// ```
97/// use sepp_rs::Primitive;
98///
99/// let p: Primitive = "hello".into();
100/// assert_eq!(p, Primitive::String("hello".to_string()));
101/// ```
102#[derive(Debug, Clone, PartialEq)]
103pub enum Primitive {
104    /// A UTF-8 string.
105    String(String),
106    /// A 64-bit signed integer.
107    Int(i64),
108    /// A 64-bit float.
109    Double(f64),
110    /// A boolean.
111    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/// The opaque body of a job, plus an encoding hint.
160///
161/// The queue never interprets `data`; it only carries the bytes. `encoding`
162/// (for example `"application/json"` or `"text/plain"`) is a hint the producer
163/// sets and the worker reads to decide how to deserialize the bytes. A queue
164/// may restrict which encodings it accepts — see
165/// [`JobRejection::EncodingNotAllowed`].
166///
167/// The wire contract requires `data` (and `encoding`) to be non-empty: omit
168/// the payload entirely for jobs that carry no data, by never calling
169/// [`with_payload`](EnqueueRequest::with_payload). The server rejects an empty
170/// payload with [`JobRejection::InvalidRequest`].
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct Payload {
173    /// The raw payload bytes.
174    pub data: Vec<u8>,
175    /// Encoding hint for the worker (e.g. a MIME type).
176    pub encoding: String,
177}
178
179impl Payload {
180    /// Creates a payload from its bytes and an encoding hint. Both must be
181    /// non-empty on the wire; jobs without data should carry no payload at all.
182    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/// A job priority in the range `0..=9`, where higher values are dequeued first.
200///
201/// The type makes the valid range unrepresentable-if-invalid: construct one
202/// with [`Priority::new`], the `TryFrom<u8>` impl, or one of the
203/// [`P0`](Priority::P0)–[`P9`](Priority::P9) constants.
204///
205/// ```
206/// use sepp_rs::Priority;
207///
208/// assert_eq!(Priority::new(7).unwrap(), Priority::P7);
209/// assert!(Priority::new(10).is_err());
210/// ```
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
212pub struct Priority(u8);
213
214/// Returned by [`Priority::new`] when the value is greater than 9.
215#[derive(Debug, thiserror::Error)]
216#[error("priority must be 0-9, got {0}")]
217pub struct PriorityOutOfRange(pub u8);
218
219impl Priority {
220    /// The lowest priority (`0`).
221    pub const MIN: Self = Self(0);
222    /// The highest priority (`9`).
223    pub const MAX: Self = Self(9);
224
225    /// Priority `0` (lowest).
226    pub const P0: Self = Self(0);
227    /// Priority `1`.
228    pub const P1: Self = Self(1);
229    /// Priority `2`.
230    pub const P2: Self = Self(2);
231    /// Priority `3`.
232    pub const P3: Self = Self(3);
233    /// Priority `4`.
234    pub const P4: Self = Self(4);
235    /// Priority `5`.
236    pub const P5: Self = Self(5);
237    /// Priority `6`.
238    pub const P6: Self = Self(6);
239    /// Priority `7`.
240    pub const P7: Self = Self(7);
241    /// Priority `8`.
242    pub const P8: Self = Self(8);
243    /// Priority `9` (highest).
244    pub const P9: Self = Self(9);
245
246    /// Creates a priority, returning [`PriorityOutOfRange`] if `value > 9`.
247    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    /// Returns the priority as a `u8` in `0..=9`.
256    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/// A [W3C Trace Context](https://www.w3.org/TR/trace-context/) attached to a
269/// job, used to link a producer's trace to the worker that processes the job.
270///
271/// The `traceparent` is validated on construction. With the `opentelemetry`
272/// feature enabled, [`Worker`](worker::Worker) and the client wire this up
273/// automatically — you only need this type to bridge to or from an external
274/// trace propagation system by hand.
275#[derive(Debug, Clone, PartialEq)]
276pub struct TraceContext {
277    traceparent: String,
278    tracestate: Option<String>,
279}
280
281/// Returned by [`TraceContext::new`] when the `traceparent` is not a
282/// well-formed W3C value.
283#[derive(Debug, thiserror::Error)]
284pub enum TraceContextError {
285    /// The `traceparent` string did not match the `version-trace_id-span_id-flags`
286    /// shape; the payload explains which field was wrong.
287    #[error("invalid traceparent: {0}")]
288    InvalidTraceparent(&'static str),
289}
290
291impl TraceContext {
292    /// Creates a trace context from a W3C `traceparent`, validating its format.
293    ///
294    /// The expected shape is `version-trace_id-span_id-flags`, e.g.
295    /// `00-<32 hex>-<16 hex>-<2 hex>`.
296    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    /// Attaches a W3C `tracestate` (vendor-specific trace data).
306    pub fn with_tracestate(mut self, ts: impl Into<String>) -> Self {
307        self.tracestate = Some(ts.into());
308        self
309    }
310
311    /// Returns the W3C `traceparent`.
312    pub fn traceparent(&self) -> &str {
313        &self.traceparent
314    }
315    /// Returns the W3C `tracestate`, if one was set.
316    pub fn tracestate(&self) -> Option<&str> {
317        self.tracestate.as_deref()
318    }
319}
320
321#[cfg(feature = "opentelemetry")]
322impl TraceContext {
323    /// Captures the current OpenTelemetry context as a `TraceContext`, or
324    /// `None` if there is no valid active span.
325    ///
326    /// *Requires the `opentelemetry` feature.*
327    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    /// Installs this trace context as the current OpenTelemetry context for as
349    /// long as the returned guard is held.
350    ///
351    /// *Requires the `opentelemetry` feature.*
352    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    /// Decodes this trace context into an OpenTelemetry [`SpanContext`], or
367    /// `None` if it does not represent a valid span. Used to add a span *link*
368    /// from the worker's process span back to the producer.
369    ///
370    /// [`SpanContext`]: opentelemetry::trace::SpanContext
371    ///
372    /// *Requires the `opentelemetry` feature.*
373    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    // W3C: version-trace_id-span_id-flags  ->  "00-<32hex>-<16hex>-<2hex>"
434    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/// A job to enqueue, built fluently.
484///
485/// Start with [`EnqueueRequest::new`] (which requires a queue and a job type)
486/// and layer on the optional fields with the `with_*` methods. Pass the result
487/// to [`SeppClient::enqueue`](client::SeppClient::enqueue),
488/// [`enqueue_batch`](client::SeppClient::enqueue_batch), or
489/// [`enqueue_atomic`](client::SeppClient::enqueue_atomic).
490///
491/// ```
492/// use sepp_rs::{EnqueueRequest, Payload, Priority};
493///
494/// # fn build() -> Result<EnqueueRequest, Box<dyn std::error::Error>> {
495/// let req = EnqueueRequest::new("emails", "send_welcome")?
496///     .with_payload(Payload::new(b"{}".to_vec(), "application/json"))
497///     .with_priority(Priority::P7)
498///     .with_idempotency_key("welcome-user-42")
499///     .with_max_attempts(5);
500/// # Ok(req)
501/// # }
502/// ```
503///
504/// Fields left unset fall back to the queue's configured defaults on the
505/// server.
506#[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/// Returned by [`EnqueueRequest::new`] when the queue or job type is empty.
520///
521/// These are the only two fields validated client-side; all other constraints
522/// (size limits, allowed encodings, …) are enforced by the server and surface
523/// as a [`JobRejection`].
524#[derive(Debug, thiserror::Error)]
525pub enum EnqueueRequestBuilderError {
526    /// The queue name was empty.
527    #[error("queue name must not be empty")]
528    EmptyQueue,
529    /// The job type was empty.
530    #[error("job type must not be empty")]
531    EmptyJobType,
532}
533
534impl EnqueueRequest {
535    /// Begins a request for the given queue and job type.
536    ///
537    /// Both must be non-empty, or [`EnqueueRequestBuilderError`] is returned.
538    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    /// Sets the job's payload. Its `data` and `encoding` must be non-empty —
565    /// leave the payload unset for jobs that carry no data (see [`Payload`]).
566    pub fn with_payload(mut self, payload: Payload) -> Self {
567        self.payload = Some(payload);
568        self
569    }
570
571    /// Sets a deduplication key. The server drops a duplicate enqueue with the
572    /// same key within the queue's dedup window, returning the existing job's
573    /// id with [`EnqueueAck::deduplicated`] set.
574    pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
575        self.idempotency_key = Some(key.into());
576        self
577    }
578
579    /// Overrides the queue's default [`Priority`].
580    pub fn with_priority(mut self, priority: Priority) -> Self {
581        self.priority = Some(priority);
582        self
583    }
584
585    /// Overrides the queue's default maximum delivery attempts before the job
586    /// is dead-lettered.
587    pub fn with_max_attempts(mut self, attempts: u32) -> Self {
588        self.max_attempts = Some(attempts);
589        self
590    }
591
592    /// Replaces the custom metadata map wholesale. See
593    /// [`with_custom_entry`](Self::with_custom_entry) to add one key at a time.
594    pub fn with_custom(mut self, custom: HashMap<String, Primitive>) -> Self {
595        self.custom = custom;
596        self
597    }
598
599    /// Inserts a single custom metadata entry. Any type that converts into a
600    /// [`Primitive`] (`&str`, `i64`, `bool`, …) is accepted as the value.
601    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    /// Attaches a [`TraceContext`] to the job.
611    ///
612    /// With the `opentelemetry` feature, the current span's context is injected
613    /// automatically at enqueue time, so set this only to override that or to
614    /// propagate a trace captured elsewhere.
615    pub fn with_trace_context(mut self, trace_context: TraceContext) -> Self {
616        self.trace_context = Some(trace_context);
617        self
618    }
619
620    /// Delays the job until the given time. The job is not delivered to any
621    /// worker before then. Must be within the server's
622    /// [`max_schedule_horizon`](ServerInfo::max_schedule_horizon), or the
623    /// job is rejected with [`JobRejection::ScheduledTooFar`].
624    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/// Confirmation that a job was accepted by the server.
651#[derive(Debug, Clone, PartialEq, Eq)]
652pub struct EnqueueAck {
653    /// The server-assigned job id (a UUID). When `deduplicated` is `true`, this
654    /// is the id of the pre-existing job.
655    pub job_id: String,
656    /// `true` if an [idempotency key](EnqueueRequest::with_idempotency_key)
657    /// matched an existing job, so this enqueue was a no-op.
658    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/// Why the server refused a single job.
671///
672/// Every variant except [`QueueFull`](Self::QueueFull) and
673/// [`QueueClosing`](Self::QueueClosing) is *deterministic*: re-sending the
674/// same job against the same server state produces the same rejection.
675/// Transient problems (a storage outage, a dropped connection) are never
676/// reported here — they surface as a [`ClientError`](client::ClientError)
677/// instead. Most limits behind these variants are advertised up front by
678/// [`ServerInfo`], so a producer can validate locally before sending.
679#[derive(Debug, Clone, PartialEq, thiserror::Error)]
680#[non_exhaustive]
681pub enum JobRejection {
682    /// The server is in [strict mode](ServerInfo::strict_queues) and the target
683    /// queue has not been declared.
684    #[error("queue {queue:?} is not declared on the server (strict mode)")]
685    UnknownQueue { queue: String },
686    /// The payload exceeds the queue's
687    /// [`max_payload_bytes`](ServerInfo::max_payload_bytes).
688    #[error("payload size {actual} bytes exceeds the queue limit of {limit}")]
689    PayloadTooLarge { limit: u64, actual: u64 },
690    /// The queue restricts encodings and the payload's encoding is not on the
691    /// allow-list.
692    #[error("payload encoding {encoding:?} is not allowed; accepted: {allowed:?}")]
693    EncodingNotAllowed {
694        encoding: String,
695        allowed: Vec<String>,
696    },
697    /// The queue restricts job types and this one is not on the allow-list.
698    #[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    /// The custom map has more entries than
704    /// [`max_custom_entries`](ServerInfo::max_custom_entries).
705    #[error("custom map has {actual} entries, exceeding the queue limit of {limit}")]
706    CustomEntriesTooMany { limit: u32, actual: u32 },
707    /// The custom map's total size exceeds
708    /// [`max_custom_total_bytes`](ServerInfo::max_custom_total_bytes).
709    #[error("custom map's total size {actual} bytes exceeds the queue limit of {limit}")]
710    CustomMapTooLarge { limit: u64, actual: u64 },
711    /// A custom key exceeds
712    /// [`max_custom_key_bytes`](ServerInfo::max_custom_key_bytes).
713    #[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    /// The queue name exceeds
720    /// [`max_queue_name_bytes`](ServerInfo::max_queue_name_bytes).
721    #[error("queue name is {actual} bytes, exceeding the limit of {limit}")]
722    QueueNameTooLong { limit: u32, actual: u64 },
723    /// The job type exceeds
724    /// [`max_job_type_bytes`](ServerInfo::max_job_type_bytes).
725    #[error("job_type is {actual} bytes, exceeding the limit of {limit}")]
726    JobTypeNameTooLong { limit: u32, actual: u64 },
727    /// The idempotency key exceeds
728    /// [`max_idempotency_key_bytes`](ServerInfo::max_idempotency_key_bytes).
729    #[error("idempotency_key is {actual} bytes, exceeding the limit of {limit}")]
730    IdempotencyKeyTooLong { limit: u32, actual: u64 },
731    /// [`scheduled_at`](EnqueueRequest::with_scheduled_at) is further out than
732    /// [`max_schedule_horizon`](ServerInfo::max_schedule_horizon).
733    #[error("scheduled_at {actual:?} is beyond the max schedule horizon ({horizon:?})")]
734    ScheduledTooFar {
735        horizon: Duration,
736        actual: SystemTime,
737    },
738    /// The request failed the server's structural validation (e.g. a required
739    /// field was missing); `message` carries the detail.
740    #[error("structural validation failed: {message}")]
741    InvalidRequest { message: String },
742    /// The target queue is at capacity (`limit` is its configured max depth).
743    /// Non-deterministic: it clears once the queue drains.
744    #[error("queue {queue:?} is full (max depth {limit})")]
745    QueueFull { queue: String, limit: u64 },
746    /// The target queue is being deleted and is not accepting new jobs.
747    /// Non-deterministic: it clears once the delete completes or is abandoned.
748    #[error("queue {queue:?} is being deleted and is not accepting new jobs")]
749    QueueClosing { queue: String },
750    /// The server sent a rejection reason this client version does not
751    /// recognize.
752    #[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/// A single job's rejection within an atomic batch, paired with its position
814/// in the request so the caller can identify which job failed.
815///
816/// Collected into [`AtomicEnqueueError::Validation`] when
817/// [`enqueue_atomic`](client::SeppClient::enqueue_atomic) rejects a batch.
818#[derive(Debug, Clone, PartialEq)]
819pub struct JobValidationError {
820    /// Zero-based position of the offending job in the submitted batch.
821    pub index: u32,
822    /// Why that job was rejected.
823    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/// The error type of [`enqueue_atomic`](client::SeppClient::enqueue_atomic).
839///
840/// An atomic batch is all-or-nothing: if any job fails validation, *none* are
841/// enqueued and every failure is reported together in [`Validation`].
842///
843/// [`Validation`]: AtomicEnqueueError::Validation
844#[derive(Debug, thiserror::Error)]
845#[non_exhaustive]
846pub enum AtomicEnqueueError {
847    /// The call failed at the transport or protocol level; nothing about
848    /// individual jobs is known.
849    #[error(transparent)]
850    Client(#[from] crate::client::ClientError),
851    /// One or more jobs failed validation, so the whole batch was rejected.
852    #[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/// Everything about a reserved job except its payload: identity, delivery
863/// metadata, and the handle needed to manage its lease.
864///
865/// A handler receives this as `Arc<JobCtx>` alongside the payload. It also
866/// carries an internal lease handle, which is why [`extend`](JobCtx::extend)
867/// can be called directly on it.
868#[derive(Debug, Clone)]
869pub struct JobCtx {
870    /// Server-assigned job id (a UUID).
871    pub id: String,
872    /// The queue this job was reserved from. Useful when a worker reserves from
873    /// several queues and needs to tell which one each job came from.
874    pub queue: String,
875    /// The job type, used to route to a handler.
876    pub job_type: String,
877    /// The job's effective priority.
878    pub priority: Priority,
879    /// Which delivery attempt this is, starting at `1` and incremented on each
880    /// redelivery.
881    pub attempt: u32,
882    /// The maximum attempts before the job is dead-lettered.
883    pub max_attempts: u32,
884    /// When the producer enqueued the job.
885    pub enqueued_at: SystemTime,
886    /// When the producer scheduled the job to run, if it was
887    /// [delayed](EnqueueRequest::with_scheduled_at); `None` for a job that was
888    /// available immediately.
889    pub scheduled_at: Option<SystemTime>,
890    /// Custom metadata the producer attached.
891    pub custom: HashMap<String, Primitive>,
892    /// The producer's trace context, if any and if it was well-formed.
893    pub trace_context: Option<TraceContext>,
894    /// When the current lease expires. The job must be acked, nacked, or
895    /// extended before this, or it will be redelivered.
896    pub lease_expires_at: SystemTime,
897    pub(crate) lease: crate::client::Lease,
898}
899
900impl JobCtx {
901    /// Extends this job's lease by `extension`, measured from now, and returns
902    /// the new expiry.
903    ///
904    /// Use this from inside a handler that needs more time than the original
905    /// lease allowed. A [`Worker`](worker::Worker) configured with
906    /// [`with_auto_extend`](worker::Worker::with_auto_extend) does this for you.
907    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/// A reserved job: its optional [`Payload`] and its [`JobCtx`].
930///
931/// Returned in batches by [`SeppClient::reserve`](client::SeppClient::reserve).
932/// Under a [`Worker`](worker::Worker), the payload and an `Arc<JobCtx>` are
933/// passed to your handler directly, so you usually deal with the two halves
934/// rather than this struct.
935#[derive(Debug, Clone)]
936pub struct Job {
937    /// The job's payload, if it has one.
938    pub payload: Option<Payload>,
939    /// The job's identity, delivery metadata, and lease handle.
940    pub ctx: JobCtx,
941}
942
943/// Returned when a job received from the server cannot be decoded into a
944/// [`Job`].
945///
946/// During [`reserve`](client::SeppClient::reserve) this is logged and the
947/// offending job is skipped rather than failing the whole batch, so you
948/// normally only encounter it indirectly.
949#[derive(Debug, thiserror::Error)]
950pub enum JobConversionError {
951    /// A required field (named in the payload) was absent or empty.
952    #[error("job is missing required field `{0}`")]
953    MissingField(&'static str),
954    /// The priority was outside `0..=9`.
955    #[error("job priority {0} is out of range (expected 0-9)")]
956    PriorityOutOfRange(u32),
957    /// A timestamp field held a value that is not a representable
958    /// [`SystemTime`].
959    #[error("job timestamp `{field}` is not a representable time ({value}ms)")]
960    InvalidTimestamp { field: &'static str, value: i64 },
961    /// A custom map entry was present but carried no value.
962    #[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    // An invalid trace context must not block job delivery: drop it and
1079    // lose trace continuity rather than failing the whole reservation.
1080    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            // Optional on the wire; a malformed value must not block delivery,
1103            // so it degrades to None like an invalid trace context does.
1104            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/// Parameters for a [`reserve`](client::SeppClient::reserve) call.
1114///
1115/// Constructed with [`ReserveOptions::new`] (which needs the queues to pull
1116/// from and the lease duration) and refined with the `with_*` methods. If you
1117/// use a [`Worker`](worker::Worker), it builds and manages these for you.
1118#[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/// Returned by [`ReserveOptions`] constructors and setters on invalid input.
1128#[derive(Debug, thiserror::Error)]
1129pub enum ReserveOptionsError {
1130    /// No queues were given.
1131    #[error("at least one queue must be specified")]
1132    EmptyQueues,
1133    /// The queue name at this index was empty.
1134    #[error("queue name at index {0} must not be empty")]
1135    EmptyQueueName(usize),
1136    /// The lease duration was zero.
1137    #[error("lease_duration must be at least 1ms")]
1138    LeaseDurationTooShort,
1139    /// A worker id was supplied but empty.
1140    #[error("worker_id must not be empty when set")]
1141    EmptyWorkerId,
1142}
1143
1144impl ReserveOptions {
1145    /// Begins reserve options for the given queues and lease duration.
1146    ///
1147    /// Queues are polled in order: index 0 first, then index 1, and so on. The
1148    /// lease duration is how long a returned job is held before it must be
1149    /// acked, nacked, or extended. The wait timeout defaults to 30s; override
1150    /// it with [`with_wait_timeout`](Self::with_wait_timeout).
1151    ///
1152    /// At least one non-empty queue and a non-zero lease are required.
1153    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    /// Sets how long the server holds the connection open waiting for a job
1179    /// before returning empty. The server clamps this to its configured
1180    /// [`max_wait_timeout`](ServerInfo::max_wait_timeout).
1181    ///
1182    /// A zero (or tiny) wait makes the server answer immediately, so a naive
1183    /// reserve loop turns into a hot loop of back-to-back RPCs — keep a real
1184    /// wait for polling loops. ([`Worker`](worker::Worker) rejects a zero wait
1185    /// outright and backs off on early-empty responses.)
1186    pub fn with_wait_timeout(mut self, wait: Duration) -> Self {
1187        self.wait_timeout = wait;
1188        self
1189    }
1190
1191    /// Returns the configured long-poll wait timeout.
1192    pub fn wait_timeout(&self) -> Duration {
1193        self.wait_timeout
1194    }
1195
1196    /// Sets a stable worker identifier, recorded server-side for observability.
1197    /// Must be non-empty.
1198    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    /// Sets the maximum number of jobs to return in one response (default 1).
1208    /// The server clamps this to its
1209    /// [`max_reserve_batch`](ServerInfo::max_reserve_batch). `0` is invalid:
1210    /// the server rejects it with `INVALID_ARGUMENT` on every call.
1211    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/// The server's version, capabilities, and limits, as returned by
1236/// [`get_server_info`](client::SeppClient::get_server_info).
1237///
1238/// The various `max_*` fields mirror the limits behind the [`JobRejection`]
1239/// variants. Fetching this once at startup lets a producer validate jobs
1240/// locally and avoid round-trips that would only be rejected. The limits are
1241/// the server's *defaults*; an individual queue may be configured more or less
1242/// strictly.
1243#[derive(Debug, Clone, PartialEq)]
1244pub struct ServerInfo {
1245    /// Server version (a semver string).
1246    pub version: String,
1247    /// Protocol versions the server supports.
1248    pub supported_protocol_versions: Vec<String>,
1249    /// The server's current wall-clock time, useful for detecting clock skew.
1250    pub server_time: SystemTime,
1251    /// If `true`, only encodings in [`allowed_encodings`](Self::allowed_encodings)
1252    /// are accepted; if `false`, any encoding is accepted.
1253    pub restricts_encodings: bool,
1254    /// The accepted encodings when [`restricts_encodings`](Self::restricts_encodings)
1255    /// is set.
1256    pub allowed_encodings: Vec<String>,
1257    /// Maximum payload size in bytes (→ [`JobRejection::PayloadTooLarge`]).
1258    pub max_payload_bytes: u64,
1259    /// Maximum entries in the custom map (→ [`JobRejection::CustomEntriesTooMany`]).
1260    pub max_custom_entries: u32,
1261    /// Maximum total bytes across the custom map (→ [`JobRejection::CustomMapTooLarge`]).
1262    pub max_custom_total_bytes: u64,
1263    /// Maximum bytes in a single custom key (→ [`JobRejection::CustomKeyTooLong`]).
1264    pub max_custom_key_bytes: u32,
1265    /// Maximum bytes in a queue name (→ [`JobRejection::QueueNameTooLong`]).
1266    pub max_queue_name_bytes: u32,
1267    /// Maximum bytes in a job type (→ [`JobRejection::JobTypeNameTooLong`]).
1268    pub max_job_type_bytes: u32,
1269    /// Maximum bytes in an idempotency key (→ [`JobRejection::IdempotencyKeyTooLong`]).
1270    pub max_idempotency_key_bytes: u32,
1271    /// How far ahead a job may be scheduled (→ [`JobRejection::ScheduledTooFar`]).
1272    pub max_schedule_horizon: Duration,
1273    /// Maximum jobs in one enqueue batch; larger batches fail the whole request.
1274    pub max_enqueue_batch: u32,
1275    /// Maximum jobs returned by one reserve; larger requests are clamped, not rejected.
1276    pub max_reserve_batch: u32,
1277    /// Maximum queues one reserve may list; exceeding this is an error.
1278    pub max_reserve_queues: u32,
1279    /// Maximum long-poll wait; larger requests are clamped.
1280    pub max_wait_timeout: Duration,
1281    /// Maximum lease duration; larger requests are clamped.
1282    pub max_lease_duration: Duration,
1283    /// If `true`, enqueueing to an undeclared queue is rejected with
1284    /// [`JobRejection::UnknownQueue`]; if `false`, queues are created on demand.
1285    pub strict_queues: bool,
1286    /// If `true`, the server retains dead-lettered jobs and
1287    /// [`drain_dead_letters`](client::SeppClient::drain_dead_letters) can return
1288    /// them; if `false`, dead jobs are deleted and drain always returns empty.
1289    pub dead_letter_retention_enabled: bool,
1290}
1291
1292/// Returned when a [`get_server_info`](client::SeppClient::get_server_info)
1293/// response cannot be decoded into a [`ServerInfo`].
1294#[derive(Debug, thiserror::Error)]
1295pub enum ServerInfoError {
1296    /// A required field (named in the payload) was absent.
1297    #[error("server info is missing required field `{0}`")]
1298    MissingField(&'static str),
1299    /// `server_time` was not a representable [`SystemTime`].
1300    #[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/// Why a job was moved to the server's dead-letter store.
1341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1342#[non_exhaustive]
1343pub enum DeadLetterCause {
1344    /// The cause was unset — an unknown or future variant.
1345    Unspecified,
1346    /// The job exhausted its max attempts across nacks and redeliveries.
1347    AttemptsExhausted,
1348    /// A worker nacked with
1349    /// [`RetryDirective::DeadLetter`](client::RetryDirective::DeadLetter),
1350    /// skipping its remaining attempts.
1351    Rejected,
1352    /// The lease expired while the job was on its final attempt.
1353    LeaseExpired,
1354    /// An operator dead-lettered the job through the admin API.
1355    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/// A dead-lettered job retained by the server, returned by
1372/// [`drain_dead_letters`](client::SeppClient::drain_dead_letters).
1373///
1374/// It is a snapshot for inspection and manual replay: read [`cause`](Self::cause),
1375/// [`last_reason`](Self::last_reason), and [`final_attempt`](Self::final_attempt)
1376/// to see what went wrong, then call
1377/// [`to_enqueue_request`](Self::to_enqueue_request) to re-submit it.
1378#[derive(Debug, Clone)]
1379pub struct DeadLetterRecord {
1380    /// The queue the job belonged to.
1381    pub queue: String,
1382    /// The dead-lettered job's server-assigned id.
1383    pub job_id: String,
1384    /// The producer's job-type tag.
1385    pub job_type: String,
1386    /// The job's payload, if any.
1387    pub payload: Option<Payload>,
1388    /// The job's effective priority.
1389    pub priority: Priority,
1390    /// The maximum delivery attempts the job died with (the producer's
1391    /// override, or the queue default the server applied).
1392    pub max_attempts: u32,
1393    /// Custom metadata the producer attached.
1394    pub custom: HashMap<String, Primitive>,
1395    /// The producer's trace context, if any and well-formed.
1396    pub trace_context: Option<TraceContext>,
1397    /// When the producer originally enqueued the job.
1398    pub enqueued_at: SystemTime,
1399    /// When the producer scheduled the job to run, if it was delayed; `None`
1400    /// for a job that was available immediately.
1401    pub scheduled_at: Option<SystemTime>,
1402    /// Why the job was dead-lettered.
1403    pub cause: DeadLetterCause,
1404    /// When the job was dead-lettered.
1405    pub failed_at: SystemTime,
1406    /// The attempt the job died on (1-based).
1407    pub final_attempt: u32,
1408    /// The reason from the last nack, when the job died on a nack path; `None`
1409    /// for a lease-expiry death.
1410    pub last_reason: Option<String>,
1411}
1412
1413impl DeadLetterRecord {
1414    /// Builds an [`EnqueueRequest`] that replays this job into its original
1415    /// queue, preserving its payload, priority, max attempts, custom metadata,
1416    /// and trace context. The replay is a fresh job — the server assigns a new
1417    /// id and resets the attempt counter — and it runs immediately: the
1418    /// original `scheduled_at` is deliberately not copied, since it is in the
1419    /// past by the time a job has died and been drained.
1420    ///
1421    /// Equivalent to `EnqueueRequest::from(&record)`; both leave the record
1422    /// intact. To consume the record without cloning, use
1423    /// `EnqueueRequest::from(record)` (or `record.into()`).
1424    pub fn to_enqueue_request(&self) -> EnqueueRequest {
1425        self.into()
1426    }
1427}
1428
1429impl From<DeadLetterRecord> for EnqueueRequest {
1430    /// Replays a dead-lettered job into its original queue, consuming the record.
1431    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            // Deliberately not copied: a replayed job should run now, not be
1442            // re-scheduled to a time that has already passed.
1443            scheduled_at: None,
1444        }
1445    }
1446}
1447
1448impl From<&DeadLetterRecord> for EnqueueRequest {
1449    /// Replays a dead-lettered job into its original queue, leaving the record
1450    /// intact.
1451    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            // Deliberately not copied: a replayed job should run now, not be
1462            // re-scheduled to a time that has already passed.
1463            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        // Optional on the wire; a malformed value must not block the drain,
1522        // so it degrades to None like an invalid trace context does.
1523        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; // a future variant this client does not know
2597        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; // a future variant this client does not know
2652        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; // deliberately never retried
2680        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        // A dead job's scheduled_at is in the past by the time it is drained;
2693        // the replay must run now instead of copying it.
2694        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        // The owned `From` consumes the record so a caller can write
2709        // `client.enqueue(record.into())` with no clone.
2710        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}