Repository navigation
Redis: RedisEnvelopeMapper duplicates the protocol version header when re-sending a received envelope #4595
Description
RedisEnvelopeMapper writes the protocol version header twice whenever it re-sends an envelope it received. The field name is wolverine-wolverine-protocol-version, and the duplicate lands in the Redis stream entry — most visibly in the native dead letter queue.
Mechanism
writeOutgoingHeader prefixes every header with wolverine-, and MapEnvelopeToOutgoing unconditionally appends TransportConstants.ProtocolVersion (itself already the literal wolverine-protocol-version), so the wire field is wolverine-wolverine-protocol-version. That part is fine on its own.
The problem is the round trip. writeIncomingHeaders copies every wolverine--prefixed wire field into Envelope.Headers with exactly one prefix stripped:
so the received envelope carries a literal wolverine-protocol-version entry in Headers. The next time that envelope is mapped outgoing, the header is re-prefixed and MapEnvelopeToOutgoing adds its own copy:
https://github.com/JasperFx/wolverine/blob/main/src/Wolverine/Transports/EnvelopeMapper.cs#L149-L155
Two identical field names in one StreamEntry. It stabilizes at two rather than growing per hop, because Headers is a dictionary and both copies collapse back to one key on the way in.
Repro
No broker required:
var transport = new RedisTransport();
var endpoint = transport.StreamEndpoint("scratch-stream");
var mapper = new RedisEnvelopeMapper(endpoint);
// 1. Map an outgoing envelope, exactly as a sender does.
var outgoing = new List<NameValueEntry>();
mapper.MapEnvelopeToOutgoing(new Envelope { Data = "{}"u8.ToArray(), MessageType = "scratch" }, outgoing);
outgoing.Count(x => x.Name == "wolverine-wolverine-protocol-version").ShouldBe(1); // passes
// 2. Read it back, exactly as the listener does.
var received = new Envelope();
mapper.MapIncomingToEnvelope(received, new StreamEntry("1-0", outgoing.ToArray()));
// 3. Re-send that received envelope -- what the dead letter sender does.
var resent = new List<NameValueEntry>();
mapper.MapEnvelopeToOutgoing(received, resent);
resent.Count(x => x.Name == "wolverine-wolverine-protocol-version").ShouldBe(1); // FAILS: was 2Fields written on the second pass:
wolverine-wolverine-protocol-version
wolverine-id
wolverine-sent-at
wolverine-message-type
wolverine-accepted-content-types
payload
wolverine-wolverine-protocol-version
How it reaches a user
A Redis listener that dead-letters under BufferedInMemory or Durable listening. Those modes route the failure through BufferedReceiver/DurableReceiver → endpoint.TryBuildDeadLetterSender → InlineRedisStreamSender → the envelope mapper, rather than through RedisStreamListener.MoveToErrorsAsync, which builds its fields explicitly and is not affected.
So the dead letter entry is malformed, and any consumer that reads it into a keyed structure throws. StreamEntry.Values.ToDictionary(...) gives System.ArgumentException : An item with the same key has already been added. Key: wolverine-wolverine-protocol-version.
This is latent on main today only because the Redis DeadLetterQueueTests currently run with an Inline listener, which takes the MoveToErrorsAsync path. It surfaced in CI on #4506, where dropping a SendInline() moved those tests onto the buffered path. It is not caused by that PR.
Worth noting separately: the two dead-letter paths write different shapes. MoveToErrorsAsync writes exception-type, exception-message, exception-stack, failed-at, envelope-id, message-type and attempts; the dead letter sender path writes mapper-shaped wolverine-* fields. Whether the native dead letter metadata should be mode-dependent at all is probably the larger question behind this bug.
Suggested fix
Have writeIncomingHeaders skip the protocol version rather than copying it into Headers — it is transport plumbing that the outgoing mapper re-stamps anyway, so it should not survive as an envelope header. RedisEnvelopeMapper already does exactly this for RedisEntryIdHeader in writeOutgoingHeader, so the shape of the fix is established.
Other transports that copy prefixed headers wholesale on the way in are worth a look for the same pattern.
Activity
JasperFxGH-4059: keep the two mode-sensitive tests Inline instead of …
GH-4595: stop the protocol version header surviving a round trip (#4599)