Envelopes
An Envelope is what is actually on the queue. Your job is the payload
inside it; everything else is the metadata the worker needs to route, retry and
account for the delivery.
pub struct Envelope { pub job_id: Uuid, pub job_type: String, pub queue: String, pub attempt: u32, pub enqueued_at_ms: u64, #[serde(default)] pub deferrals: u32, #[serde(default)] pub priority: u8, pub payload: serde_json::Value,}It is serialized as JSON in the message body.
The fields
Section titled “The fields”| Field | Type | Meaning |
|---|---|---|
job_id |
Uuid |
Unique id, stable across retries and deferrals |
job_type |
String |
Job::NAME of the payload; what the worker routes on |
queue |
String |
Broker queue name this envelope was published to |
attempt |
u32 |
1-based attempt counter. First delivery is attempt 1 |
enqueued_at_ms |
u64 |
Unix epoch milliseconds when the job was first enqueued |
deferrals |
u32 |
How often this job was deferred. Independent of attempt: a deferral is not a failed attempt |
priority |
u8 |
Broker message priority; 0 (the default) is normal work |
payload |
serde_json::Value |
The serialized job |
deferrals and priority are #[serde(default)], so envelopes written by a
version of queuey that predates deferral still decode.
enqueued_at_ms is what JobContext::age is computed from, and it is not
reset by a retry or a deferral. A job that has been failing for an hour reports
an hour of age on its next attempt.
What changes between deliveries
Section titled “What changes between deliveries”| Transition | attempt |
deferrals |
priority |
|---|---|---|---|
next_attempt(), used by a retry |
+1 | unchanged | reset to 0 |
deferred(priority), used by a deferral |
unchanged | +1 | set to the queue’s max |
Those two rows are the whole difference between a retry and a deferral on the wire. A retry spends an attempt and gives up its place in line; a deferral spends neither.
AMQP properties
Section titled “AMQP properties”On RabbitMQ the envelope body is accompanied by properties derived from it:
| Property | Value |
|---|---|
content-type |
application/json |
delivery-mode |
2 (persistent) |
message-id |
job_id |
type |
job_type |
priority |
envelope.priority, always set |
header x-attempt |
envelope.attempt |
header x-deferrals |
envelope.deferrals |
expiration is never set on a message. Per-message TTL would break the
in-order draining that hold queues rely on, so delays are expressed as a
queue-level x-message-ttl instead. See Broker topology.
An over-long message-id or type is truncated at a UTF-8 character boundary
rather than panicking, since AMQP short strings cap at 255 bytes.
Working with envelopes directly
Section titled “Working with envelopes directly”Application code does not normally construct or read an Envelope — the
producer builds one and the worker decodes it. It is public for two audiences:
people implementing a Backend, and people writing tooling that reads a
dead-letter queue.
impl Envelope { pub fn new<J: Job>(job: &J) -> Result<Self>; pub fn decode<J: Job>(&self) -> Result<J>; pub fn next_attempt(&self) -> Self; pub fn deferred(&self, priority: u8) -> Self; pub fn to_bytes(&self) -> Result<Vec<u8>>; pub fn from_bytes(bytes: &[u8]) -> Result<Self>;}decode does not check job_type against J::NAME. Callers that read
arbitrary messages — a dead-letter drain, for instance — should check it
themselves before decoding:
let envelope = Envelope::from_bytes(&body)?;if envelope.job_type == SendEmail::NAME { let job: SendEmail = envelope.decode()?; // ...}Inspecting a message by hand
Section titled “Inspecting a message by hand”The body is plain JSON, so anything that can read a message can read a job:
{ "job_id": "0d8f9c22-5e3b-4f1a-9c77-2b1a6f0e3d44", "job_type": "myapp::jobs::SendEmail", "queue": "myapp.emails", "attempt": 2, "enqueued_at_ms": 1757846400123, "deferrals": 0, "priority": 0, "payload": { "to": "a@b.c", "body": "hi" }}That is what you will see in the RabbitMQ management UI when you “Get message”
from myapp.emails.dead, and it is enough to decide whether to replay it.