Skip to content

Commit 2b615be

Browse files
authored
Merge pull request #306 from Vulthil/fix/messaging-wire-fidelity
fix(messaging): Fault<T> payload shape, consume-side header fidelity, and TTL expiration
2 parents 208a2f0 + 7996b69 commit 2b615be

19 files changed

Lines changed: 565 additions & 31 deletions

File tree

docs/articles/messaging.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ public sealed class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
131131
`IMessageContext.CancellationToken` exposes the delivery's cancellation token for
132132
handlers that want to observe it alongside the explicit method parameter.
133133

134+
### Header values on the consume side
135+
136+
Custom headers travel as JSON, and the consume side normalizes them so every path — a Vulthil
137+
envelope, a bare-AMQP message from a non-Vulthil producer, or an outbox-relayed publish — surfaces
138+
the same CLR primitives in `IMessageContext.Headers`: strings arrive as `string`, booleans as
139+
`bool`, and numbers as `int`, `long`, or `double` (the narrowest that represents the value). A
140+
header published as `AddHeader("tenant", "acme")` is the string `"acme"` again in the consumer.
141+
Values without a JSON primitive form keep their JSON shape: objects and arrays surface as
142+
`JsonElement`, and types JSON serializes as strings (e.g. `Guid`, `DateTimeOffset`) surface as
143+
that string.
144+
134145
## Point-to-point Send
135146

136147
`IPublisher.PublishAsync` fans a message out via its per-type exchange to any number of
@@ -441,6 +452,15 @@ public record Fault<TMessage> where TMessage : notnull
441452
}
442453
```
443454

455+
`Message` is the faulted message's own payload — for envelope-wrapped (Vulthil-produced) deliveries
456+
the wire envelope is unwrapped before the fault is built — so a subscriber reads the original fields
457+
with a plain deserialization:
458+
459+
```csharp
460+
var fault = JsonSerializer.Deserialize<Fault<OrderCreatedEvent>>(body, jsonOptions)!;
461+
var orderId = fault.Message.OrderId;
462+
```
463+
444464
The fault exchange is a diagnostics/observability broadcast — drain it with a monitoring service or
445465
any AMQP consumer bound to the exchange — rather than a typed `IConsumer<Fault<T>>` endpoint.
446466

docs/articles/outbox-pattern.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,13 @@ Capture is relational-only (it enlists in the ambient transaction); the relay wo
156156
the general publish/send **filter pipeline** (`IPublishFilter`, registered via `AddPublishFilter<T>()`), which is the
157157
publish-side counterpart to consume filters and can host other cross-cutting concerns.
158158

159+
The capture preserves the publish faithfully: the message id, correlation id, routing key, send destination, custom
160+
headers, and the current trace context (`TraceParent`/`TraceState`, when tracing is enabled) are stored with the row
161+
and re-applied on relay — the relayed publish runs under an activity linked to the capturing trace. Headers persist
162+
as JSON, so a relayed message and a directly-published one carry identical header bytes on the wire and surface
163+
identically to consumers; only CLR types with no JSON primitive form (e.g. a `Guid` header value) are re-published
164+
in their JSON-serialized form rather than as the original CLR type.
165+
159166
On startup the relay waits for the broker transport to finish declaring its subscriber topology (exchanges, queues,
160167
and bindings) before its first publish — otherwise the commit-time trigger could relay a message before the
161168
subscriber queues exist, and a pub/sub message with no bound queue is silently dropped. This is wired automatically

src/Vulthil.Messaging.Abstractions/Consumers/IMessageContext.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ public interface IMessageContext
5555
/// <summary>
5656
/// Gets the transport headers associated with the message, containing custom metadata.
5757
/// </summary>
58+
/// <remarks>
59+
/// Values are normalized when the context is built, so every consume path (envelope, bare AMQP, outbox
60+
/// relay) surfaces the same CLR primitives: strings arrive as <see cref="string"/>, booleans as
61+
/// <see cref="bool"/>, and numbers as <see cref="int"/>, <see cref="long"/>, or <see cref="double"/> —
62+
/// the narrowest of the three that represents the value. Header values published as non-primitive
63+
/// objects have no CLR type on the wire and surface in their JSON form: as a
64+
/// <see cref="System.Text.Json.JsonElement"/> when published as an object or array, or as a
65+
/// <see cref="string"/> for types JSON represents as strings (e.g. <see cref="Guid"/>,
66+
/// <see cref="DateTimeOffset"/>).
67+
/// </remarks>
5868
IReadOnlyDictionary<string, object?> Headers { get; }
5969

6070
// --- Timing & Lifecycle ---

src/Vulthil.Messaging.Outbox/BrokerOutboxMetadata.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ internal sealed record BrokerOutboxMetadata
1616

1717
public string? DestinationAddress { get; init; }
1818

19+
/// <summary>
20+
/// The captured publish headers. Values persist as JSON, so the relay reproduces JSON-primitive values
21+
/// exactly and republishes anything else as its serialized JSON form — a relayed message's headers match
22+
/// a directly-published message's on the wire, but CLR types with no JSON representation (e.g.
23+
/// <see cref="Guid"/>) are not rematerialized as their original type.
24+
/// </summary>
1925
[JsonConverter(typeof(BrokerOutboxMetadataHeadersConverter))]
2026
public Dictionary<string, object?>? Headers { get; init; }
2127
}

src/Vulthil.Messaging.Outbox/BrokerOutboxMetadataHeadersConverter.cs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33

44
namespace Vulthil.Messaging.Outbox;
55

6+
/// <summary>
7+
/// Round-trips <see cref="BrokerOutboxMetadata.Headers"/> through the persisted metadata JSON so a relayed
8+
/// publish re-serializes to the same wire bytes as the captured one: primitives rematerialize as the CLR
9+
/// types the consume side normalizes to (<see cref="string"/>, <see cref="bool"/>, and <see cref="int"/>/
10+
/// <see cref="long"/>/<see cref="double"/> — the narrowest that represents the value), and non-primitive
11+
/// values rematerialize as detached <see cref="JsonElement"/>s, which serialize back verbatim.
12+
/// </summary>
613
internal sealed class BrokerOutboxMetadataHeadersConverter : JsonConverter<Dictionary<string, object?>>
714
{
815
public override Dictionary<string, object?> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
@@ -55,16 +62,21 @@ public override void Write(Utf8JsonWriter writer, Dictionary<string, object?> va
5562
case JsonTokenType.Null:
5663
return null;
5764
case JsonTokenType.Number:
58-
if (reader.TryGetInt64(out var integer))
65+
if (reader.TryGetInt32(out var intValue))
5966
{
60-
return integer;
67+
return intValue;
68+
}
69+
70+
if (reader.TryGetInt64(out var longValue))
71+
{
72+
return longValue;
6173
}
6274

6375
return reader.GetDouble();
6476
default:
6577
using (var document = JsonDocument.ParseValue(ref reader))
6678
{
67-
return document.RootElement.GetRawText();
79+
return document.RootElement.Clone();
6880
}
6981
}
7082
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.Text;
2+
3+
namespace Vulthil.Messaging.RabbitMq.Consumers;
4+
5+
/// <summary>
6+
/// Normalizes received AMQP header values so the bare-AMQP compat path surfaces the same CLR primitives as the
7+
/// envelope path: the client wire-encodes every string header as a UTF-8 byte array, so byte arrays decode back
8+
/// to <see cref="string"/>, and nested tables/arrays are normalized recursively. Values the client already
9+
/// surfaces as typed primitives (<see cref="int"/>, <see cref="long"/>, <see cref="bool"/>, ...) pass through
10+
/// unchanged.
11+
/// </summary>
12+
internal static class AmqpHeaderValueNormalizer
13+
{
14+
public static Dictionary<string, object?> Normalize(IDictionary<string, object?> headers)
15+
{
16+
var normalized = new Dictionary<string, object?>(headers.Count);
17+
foreach (var (key, value) in headers)
18+
{
19+
normalized[key] = NormalizeValue(value);
20+
}
21+
22+
return normalized;
23+
}
24+
25+
private static object? NormalizeValue(object? value) => value switch
26+
{
27+
byte[] bytes => Encoding.UTF8.GetString(bytes),
28+
List<object?> list => list.ConvertAll(NormalizeValue),
29+
IDictionary<string, object?> table => Normalize(table),
30+
_ => value,
31+
};
32+
}

src/Vulthil.Messaging.RabbitMq/Consumers/MessageContextFactory.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using RabbitMQ.Client;
12
using RabbitMQ.Client.Events;
23
using Vulthil.Messaging.Abstractions.Consumers;
34
using Vulthil.Messaging.Abstractions.Publishers;
@@ -8,7 +9,10 @@ namespace Vulthil.Messaging.RabbitMq.Consumers;
89
/// <summary>
910
/// Builds <see cref="MessageContext"/> instances from RabbitMQ deliveries: the AMQP property/header paths plus a
1011
/// serializable snapshot for faults. The envelope receive path delegates the transport-agnostic mapping to
11-
/// <see cref="MessageContext.CreateFromEnvelope{TMessage}"/>.
12+
/// <see cref="MessageContext.CreateFromEnvelope{TMessage}"/>. Header values are normalized via
13+
/// <see cref="AmqpHeaderValueNormalizer"/> so both receive paths honor the <see cref="IMessageContext.Headers"/>
14+
/// contract, and a per-message TTL is anchored to the delivery's timestamp when one is present (the AMQP
15+
/// expiration is relative to publish, not to consumption).
1216
/// </summary>
1317
internal static class MessageContextFactory
1418
{
@@ -83,13 +87,14 @@ private static MessageContext BuildMetadata(BasicDeliverEventArgs ea)
8387
{
8488
var props = ea.BasicProperties;
8589
var headers = props.Headers ?? new Dictionary<string, object?>();
90+
var sentTime = GetSentTime(props);
8691
return new MessageContext
8792
{
8893
MessageId = props.MessageId,
8994
CorrelationId = props.CorrelationId,
9095
RequestId = props.CorrelationId,
9196
RoutingKey = ea.RoutingKey,
92-
Headers = headers.ToDictionary(),
97+
Headers = AmqpHeaderValueNormalizer.Normalize(headers),
9398
Redelivered = ea.Redelivered,
9499
RetryCount = RabbitMqConstants.GetRetryCount(headers),
95100
ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"),
@@ -99,8 +104,8 @@ private static MessageContext BuildMetadata(BasicDeliverEventArgs ea)
99104
ResponseAddress = RabbitMqConstants.GetHeaderUri(headers, "ResponseAddress")
100105
?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")),
101106
FaultAddress = RabbitMqConstants.GetHeaderUri(headers, "FaultAddress"),
102-
SentTime = props.Timestamp.UnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(props.Timestamp.UnixTime) : null,
103-
ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration)
107+
SentTime = sentTime,
108+
ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration, sentTime)
104109
};
105110
}
106111

@@ -113,6 +118,7 @@ private static MessageContext<TMessage> BuildTypedMetadata<TMessage>(
113118
{
114119
var props = ea.BasicProperties;
115120
var headers = props.Headers ?? new Dictionary<string, object?>();
121+
var sentTime = GetSentTime(props);
116122
return new MessageContext<TMessage>
117123
{
118124
Message = message,
@@ -123,7 +129,7 @@ private static MessageContext<TMessage> BuildTypedMetadata<TMessage>(
123129
CorrelationId = props.CorrelationId,
124130
RequestId = props.CorrelationId,
125131
RoutingKey = ea.RoutingKey,
126-
Headers = headers.ToDictionary(),
132+
Headers = AmqpHeaderValueNormalizer.Normalize(headers),
127133
Redelivered = ea.Redelivered,
128134
RetryCount = RabbitMqConstants.GetRetryCount(headers),
129135
ConversationId = RabbitMqConstants.GetHeaderString(headers, "ConversationId"),
@@ -133,8 +139,11 @@ private static MessageContext<TMessage> BuildTypedMetadata<TMessage>(
133139
ResponseAddress = RabbitMqConstants.GetHeaderUri(headers, "ResponseAddress")
134140
?? (string.IsNullOrEmpty(props.ReplyTo) ? null : new Uri($"queue:{props.ReplyTo}")),
135141
FaultAddress = RabbitMqConstants.GetHeaderUri(headers, "FaultAddress"),
136-
SentTime = props.Timestamp.UnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(props.Timestamp.UnixTime) : null,
137-
ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration)
142+
SentTime = sentTime,
143+
ExpirationTime = RabbitMqConstants.TryParseExpiration(props.Expiration, sentTime)
138144
};
139145
}
146+
147+
private static DateTimeOffset? GetSentTime(IReadOnlyBasicProperties props)
148+
=> props.Timestamp.UnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(props.Timestamp.UnixTime) : null;
140149
}

src/Vulthil.Messaging.RabbitMq/Consumers/RabbitMqConsumerWorker.cs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ private async Task ProcessAsync(PreparedDelivery prepared, BasicDeliverEventArgs
181181

182182
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
183183
activity?.AddException(ex);
184-
await HandleFailureAsync(ex, ea, prepared.DiagnosticTypeName);
184+
await HandleFailureAsync(ex, ea, prepared.Envelope, prepared.DiagnosticTypeName);
185185
}
186186
}
187187

@@ -216,7 +216,7 @@ private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, P
216216
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
217217
activity?.AddException(ex);
218218
MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, prepared.DiagnosticTypeName, ea.RoutingKey);
219-
await PublishFaultAsync(ex, ea, ea.BasicProperties.Headers ?? new Dictionary<string, object?>(), prepared.DiagnosticTypeName);
219+
await PublishFaultAsync(ex, ea, ea.BasicProperties.Headers ?? new Dictionary<string, object?>(), prepared.Envelope, prepared.DiagnosticTypeName);
220220
await NackAsync(ea);
221221
return;
222222
}
@@ -262,7 +262,7 @@ private async Task ExecuteWithInMemoryRetryAsync(RetryPolicyDefinition policy, P
262262
return activity;
263263
}
264264

265-
private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, string messageTypeName)
265+
private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, MessageEnvelope? envelope, string messageTypeName)
266266
{
267267
var plan = _typeCache.GetPlan(messageTypeName);
268268
var policy = GetPolicy(plan, _queueDefinition);
@@ -272,7 +272,7 @@ private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, st
272272
if (policy is null)
273273
{
274274
MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, messageTypeName, ea.RoutingKey);
275-
await PublishFaultAsync(ex, ea, headers, messageTypeName);
275+
await PublishFaultAsync(ex, ea, headers, envelope, messageTypeName);
276276
await NackAsync(ea);
277277
return;
278278
}
@@ -296,28 +296,32 @@ private async Task HandleFailureAsync(Exception ex, BasicDeliverEventArgs ea, st
296296
}
297297

298298
MessagingLog.ConsumerFailed(_logger, ex, _queueDefinition.Name, messageTypeName, ea.RoutingKey);
299-
await PublishFaultAsync(ex, ea, headers, messageTypeName);
299+
await PublishFaultAsync(ex, ea, headers, envelope, messageTypeName);
300300
await NackAsync(ea);
301301
}
302302

303303
/// <summary>
304304
/// Publishes a <see cref="Fault{TMessage}"/> for a terminally-failed delivery. When the delivery carries an
305305
/// explicit <c>FaultAddress</c> the fault is routed point-to-point to that address (via the broker's default
306306
/// exchange); otherwise it is published by convention to the shared fault exchange with the faulted message's
307-
/// URN as the routing key. Exactly one fault is emitted per failure. Publishing is best-effort: a failure to
308-
/// publish the fault is logged and never disrupts settling the original delivery.
307+
/// URN as the routing key. Exactly one fault is emitted per failure. The fault's <c>Message</c> is the original
308+
/// message payload: for an envelope-wrapped delivery the wire envelope is unwrapped (re-parsing the body when
309+
/// the caller has no parsed envelope at hand), so a subscriber can deserialize the fault as
310+
/// <see cref="Fault{TMessage}"/> of the faulted message type. Publishing is best-effort: a failure to publish
311+
/// the fault is logged and never disrupts settling the original delivery.
309312
/// </summary>
310-
private async Task PublishFaultAsync(Exception ex, BasicDeliverEventArgs ea, IDictionary<string, object?> headers, string messageTypeName)
313+
private async Task PublishFaultAsync(Exception ex, BasicDeliverEventArgs ea, IDictionary<string, object?> headers, MessageEnvelope? envelope, string messageTypeName)
311314
{
312315
var (exchange, routingKey) = ResolveFaultRoute(headers, _messageConfigurationProvider.FaultExchangeName, messageTypeName);
313316

314317
try
315318
{
316-
var originalBody = JsonSerializer.Deserialize<JsonElement>(ea.Body.Span, _jsonOptions);
319+
var faultedEnvelope = envelope ?? TryParseEnvelope(ea.Body, _jsonOptions);
320+
var originalMessage = faultedEnvelope?.Message ?? JsonSerializer.Deserialize<JsonElement>(ea.Body.Span, _jsonOptions);
317321

318322
var fault = new Fault<JsonElement>
319323
{
320-
Message = originalBody,
324+
Message = originalMessage,
321325
ExceptionMessage = ex.Message,
322326
StackTrace = ex.StackTrace,
323327
ExceptionType = ex.GetType().FullName ?? "Unknown",

src/Vulthil.Messaging.RabbitMq/RabbitMqConstants.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,18 @@ public static int GetRetryCount(IDictionary<string, object?>? headers)
2222
return 0;
2323
}
2424

25-
public static DateTimeOffset? TryParseExpiration(string? expiration)
25+
/// <summary>
26+
/// Maps a per-message AMQP TTL to an absolute expiration instant. The TTL is relative to when the message
27+
/// was published, so it is anchored to <paramref name="sentTime"/> when the delivery carries a timestamp;
28+
/// without one the consume-side clock is the only anchor available and the result is an upper bound.
29+
/// </summary>
30+
public static DateTimeOffset? TryParseExpiration(string? expiration, DateTimeOffset? sentTime)
2631
{
2732
if (!string.IsNullOrWhiteSpace(expiration) && long.TryParse(expiration, out var ms))
2833
{
2934
try
3035
{
31-
return DateTimeOffset.UtcNow.AddMilliseconds(ms);
36+
return (sentTime ?? DateTimeOffset.UtcNow).AddMilliseconds(ms);
3237
}
3338
catch (ArgumentOutOfRangeException)
3439
{
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System.Text.Json;
2+
3+
namespace Vulthil.Messaging.Transport;
4+
5+
/// <summary>
6+
/// Normalizes header values that crossed the JSON wire so consumers observe stable CLR primitives instead of
7+
/// <see cref="JsonElement"/> wrappers: strings, booleans, and numbers unwrap to <see cref="string"/>,
8+
/// <see cref="bool"/>, and <see cref="int"/>/<see cref="long"/>/<see cref="double"/> (the narrowest of the
9+
/// three that represents the value). Objects and arrays stay <see cref="JsonElement"/>, and values that never
10+
/// crossed the wire (in-memory transports) pass through unchanged, so the normalization is idempotent.
11+
/// </summary>
12+
internal static class HeaderValueNormalizer
13+
{
14+
public static object? Normalize(object? value) => value switch
15+
{
16+
JsonElement element => NormalizeElement(element),
17+
_ => value,
18+
};
19+
20+
private static object? NormalizeElement(JsonElement element) => element.ValueKind switch
21+
{
22+
JsonValueKind.String => element.GetString(),
23+
JsonValueKind.True => true,
24+
JsonValueKind.False => false,
25+
JsonValueKind.Null or JsonValueKind.Undefined => null,
26+
JsonValueKind.Number => NormalizeNumber(element),
27+
_ => element,
28+
};
29+
30+
private static object NormalizeNumber(JsonElement element)
31+
{
32+
if (element.TryGetInt32(out var intValue))
33+
{
34+
return intValue;
35+
}
36+
37+
if (element.TryGetInt64(out var longValue))
38+
{
39+
return longValue;
40+
}
41+
42+
return element.GetDouble();
43+
}
44+
}

0 commit comments

Comments
 (0)