Compatibility
Published
A queue is a boundary that does not look like one. The producer and the consumer are usually the same repository, written by the same team, in the same language, so the payload between them feels like an internal data structure. It is not. It is serialised, it is stored, and it is read back by a process that was started at a different time from a build that may not be the same one.
What was measured, and who wrote the keys
One worker file driven through five protection profiles for the base column, and through both member-renaming profiles for everything after. It reads a batch of jobs that another service enqueued, enforces a retry limit, dispatches each job through a handler table keyed by job type, does the arithmetic a billing worker does, computes an idempotency key, acknowledges each job back to the queue server, and enqueues one job for the next stage.
The inbound payload is parsed from text, so its property names were written by the enqueueing service and are not rename sites. Every spelling of those names inside the worker is. The acknowledgement and the outbound job are the other half of the contract: the worker writes those keys and the queue server reads them, which is the output side that has no in-application signal at all.
The base column is clean. All five profiles reproduced the unprotected output exactly: the job count, the dispatch list, the dead-letter list, the unroutable list, the total, the idempotency keys, the acknowledgement and the enqueued job. A worker protected with a default configuration behaves like a worker.
Every job becomes unroutable
Dispatch is the first thing to go. With a pattern matching the job type, the worker read the type off each job as undefined, found no handler under that key, and routed every job to the unroutable branch. The dispatch list went from a real handler result to none, and the running total went from a real figure to zero.
The handler table itself is untouched, and that is the detail that makes this hard to see. Its keys are string literals, and a string literal is not an identifier, so the table in the protected file still lists every job type your system supports. Reading the emitted bundle suggests the router is intact. It is the read of the incoming job that fails.
The consequences depend entirely on how your worker treats an unknown job type. A worker that discards them loses the work silently. A worker that parks them fills a dead-letter queue with jobs whose payloads are perfectly valid, and the operator inspecting one of those payloads sees a job that should obviously have matched.
The same arm changes what the next stage receives. The job the worker enqueues carries a generated name where the type belongs, so the downstream consumer sees a job with no type at all. One protected worker in the middle of a pipeline is enough to make every stage after it fail differently.
The retry limit stops existing
The sharpest arm in this area is the attempt counter. With a pattern matching it, the comparison against the maximum stopped being satisfied, so the dead-letter branch was never taken. A job that had already used all five of its attempts was dispatched and executed, and the total moved to include work that should never have run again.
Nobody sets a retry limit by accident. It is there because the alternative is a poison job cycling forever, or a side effect being repeated after it has already been applied. This is the same class as a renamed dry-run flag or a renamed buffer limit: the option is not given a wrong value, it stops being applied, and the default behaviour is the unrestricted one.
The acknowledgement in the same run shows the second half. The worker reports the attempt count back to the queue server as the old count plus one; with the property renamed that arithmetic produced a value that is not a number, and serialising it wrote a null. The queue server therefore records a null attempt count for a job that is on its third attempt, which disables its own backoff logic as well.
The evidence disappears along with the behaviour
A pattern matching the job identifier produced the most misleading output of the pass. The worker still parked the exhausted job and still rejected the unroutable one, but both log lines came back empty and printed the placeholder used when nothing happened. The lists were built by pushing an undefined identifier into them, and joining a list of undefined values produces an empty string.
So the log says no jobs were dead-lettered and no jobs were unroutable, while jobs were being dead-lettered and rejected on the same pass. Anyone triaging from those logs concludes the worker is healthy and looks somewhere else.
The idempotency key degrades in the same arm. Each key is built from the job identifier and the job type; with the identifier reading undefined, every key becomes the same prefix followed by the type. Two jobs of the same type then produce the same key, so the second one is treated as a replay of the first and dropped. Deduplication that works by identity starts deduplicating by category.
The acknowledgement loses the identifier entirely rather than sending a wrong one, because a property whose value is undefined is omitted during serialisation. The queue server receives an acknowledgement it cannot match to any job, and reports a required field missing on a message your source demonstrably fills in.
The loud half, and why you want it
Two arms failed immediately and helpfully. A pattern matching the payload container threw on the first job, quoting a property read from an undefined object, and a pattern matching the array of jobs threw at the top of the run: on the default target with a message about reading an index of undefined, and on the modern target with a message naming the generated identifier directly.
That is the good outcome. A container that something is read out of fails at the first access, before any work is done, and the process exits non-zero where a deployment pipeline can see it. Renaming a leaf value instead lets the worker run to completion with the wrong numbers, and only the arithmetic arm hints at it: reading the order identifier and amount off the payload produced a dispatch result naming an undefined order and a total that is not a number.
Whether a queue failure is loud or silent is therefore decided by the shape of what was renamed, not by how carefully the worker was written. That is worth knowing before you spend an afternoon adding guards, because a guard turns the loud version into the silent one.
A queue outlives the build that filled it
Everything above assumes the producer and the consumer are the same build. A queue is the one place in most systems where that assumption is routinely false. Messages sit in it across a deploy, so a job enqueued by the release you are replacing is executed by the release you are shipping, and a rolling deploy runs both at once by design.
Generated member names are assigned per build, in the order each build first sees them, so two builds do not share a name map. A payload whose keys were written by one protected build and read by another is therefore a second, independent way for this to fail, and it is not fixed by reserving names -- both sides can reserve correctly and still disagree if either side renames anything the other reads.
The practical consequence is narrow and easy to state. If any property name inside a queued message is renamed, the queue must be drained before the deploy, and mixed-version consumers must be avoided. If none of them is renamed, which is what reserving those names achieves, the question does not arise and rolling deploys stay safe.
What this means in practice
Treat a job payload exactly as you would treat a request body from an external caller. The names in it were written by another process, and increasingly by another release of that process, since a queue is the one place where messages outlive the build that produced them. If member renaming is on, every property name that appears in a serialised job belongs in the reserved set.
Anchor the member pattern to names you own on both ends. A prefix convention for private members is the cheapest enforcement, because a job field can then never match by accident, and the arms above show the failure is not confined to data: the retry limit and the dispatch key are control flow.
Do not verify a worker by reading its logs. In the measured run the logs claimed nothing was dead-lettered while jobs were being dead-lettered, which is the specific way this class of failure defends itself. Verify by enqueuing one known job against the protected build and printing three things: which handler ran, the total the worker computed, and the acknowledgement as it goes on the wire.
If your producer and consumer are separate deployments, keep them on one release and one build. Two protected builds do not share a name map, so a payload written by one and read by the other is a second, independent way for this to go wrong.
Frequently asked questions
Does obfuscation break background jobs and queues?
Not in the default configuration. A worker that reads a batch of enqueued jobs, enforces a retry limit, dispatches through a handler table, computes an idempotency key and acknowledges each job produced identical output on all five protection profiles measured. Queues become a surface when member renaming matches the property names inside a job payload.
Why did every job become unroutable after obfuscation?
Because the job type is read off a payload that was parsed from text. The parsed job carries the original name and the protected worker asks for a generated one, so the read produced undefined, no handler matched, and every job took the unroutable branch. The handler table is unaffected because its keys are string literals.
Can member renaming disable a retry limit?
Yes, and it was measured. With the attempt counter renamed, the comparison against the maximum was never satisfied, so a job that had already exhausted all five attempts was dispatched and executed again. The limit was not raised, it stopped being applied, which is the same pattern as a renamed dry-run flag.
Why does my dead-letter log look empty when jobs are being parked?
Because the log lines are built from the job identifier, and a renamed read of it produces undefined. Joining a list of undefined values produces an empty string, so the worker printed the placeholder it uses when nothing happened while it was parking jobs on the same pass. The behaviour and the evidence of it fail together.
Why does the queue server say my acknowledgement is missing a field?
Because a property whose value is undefined is omitted when the message is serialised. The measured acknowledgement lost its job identifier entirely rather than carrying a generated name, so the server received a message it could not match to any job and reported a required field missing on something your source plainly sets.
Do queue failures fail loudly or silently under member renaming?
Both, and the shape of the renamed name decides which. Renaming a container -- the payload object or the array of jobs -- threw immediately at the first access, before any work was done. Renaming a leaf let the worker finish with an undefined order identifier and a total that was not a number.
How do I keep a protected worker safe?
Reserve every property name that appears in a serialised job, anchor the member pattern to a prefix used only by internal members, and keep the producer and the consumer in one release and one build, since two protected builds do not share a name map. Verify by enqueuing one known job and printing which handler ran, the computed total and the acknowledgement as it goes on the wire.
Related reading