Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion docs/guide/messaging/transports/sqs/deadletterqueues.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,39 @@

By default, Wolverine will try to move dead letter messages in SQS to a single, global queue named "wolverine-dead-letter-queue."

That can be overridden on a single queue at a time (or by conventions too of course) like:
## Customizing the Default Dead Letter Queue Name <Badge type="tip" text="5.39" />

The built-in default name `wolverine-dead-letter-queue` is fine for a single deployment, but multi-environment AWS accounts that share a region collide on it across environments. Override the default for the entire SQS transport in one call:

```csharp
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransport()
.DefaultDeadLetterQueueName("my-service-dlq")
.AutoProvision();

// No per-listener config needed — every listener picks up the
// transport-wide default unless it overrides explicitly.
opts.ListenToSqsQueue("orders");
opts.ListenToSqsQueue("shipments");
}).StartAsync();
```

Resolution order, per listener:

1. `ConfigureDeadLetterQueue("name")` on the listener wins.
2. `DisableDeadLetterQueueing()` on the listener wins (no DLQ for that one).
3. Otherwise the transport-wide `DefaultDeadLetterQueueName(...)` is used.
4. If none of the above is set, the historical default `wolverine-dead-letter-queue` applies.

The supplied name is sanitized via the same SQS-name normalization (`SanitizeSqsName`) used by per-listener config, so dots and other illegal SQS characters get the same treatment everywhere. `DisableAllNativeDeadLetterQueues()` (covered below) still overrides everything; the global kill-switch takes precedence over both the transport default and any per-listener config.

Wolverine system queues (response and control queues, when `EnableSystemQueues()` is on) explicitly opt out of dead-lettering and are unaffected by the transport default — they still resolve to "no DLQ" regardless of `DefaultDeadLetterQueueName`.

## Per-listener overrides

The transport default still composes cleanly with the per-listener API. Override on a single queue at a time (or by conventions too of course) like:

<!-- snippet: sample_configuring_dead_letter_queue_for_sqs -->
<a id='snippet-sample_configuring_dead_letter_queue_for_sqs'></a>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
using JasperFx.Core.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Wolverine.AmazonSqs.Internal;
using Wolverine.Runtime;
using Xunit;

namespace Wolverine.AmazonSqs.Tests;

/// <summary>
/// Coverage for the per-transport <c>DefaultDeadLetterQueueName(...)</c> override
/// added in #2653. Each test asserts a single permutation of the resolution rule:
/// per-listener overrides win → transport-wide default → built-in
/// <c>"wolverine-dead-letter-queue"</c> fallback. <c>DisableAllNativeDeadLetterQueues()</c>
/// is the master kill-switch and trumps every other knob.
///
/// Tests use <c>UseAmazonSqsTransportLocally()</c> + <c>StartAsync</c> rather than
/// inspecting types in isolation so the exact resolution Wolverine performs at host
/// build time — the resolved <see cref="AmazonSqsQueue.DeadLetterQueueName"/> after
/// every listener-config callback has run — is what's exercised. Provisioning-side
/// behaviour is verified by checking which queues the transport's <c>Queues</c>
/// cache contains (mirrors <c>disabling_dead_letter_queue</c>).
/// </summary>
public class default_dead_letter_queue_name
{
private static AmazonSqsTransport TransportFor(IHost host) =>
host.Services.GetRequiredService<IWolverineRuntime>()
.As<WolverineRuntime>()
.Options.Transports.GetOrCreate<AmazonSqsTransport>();

[Fact]
public async Task fallback_default_name_is_wolverine_dead_letter_queue_when_neither_is_configured()
{
// No transport-wide override, no per-listener override — historical behaviour
// is preserved: every queue resolves to the built-in default.
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally().AutoProvision();
opts.ListenToSqsQueue("orders");
}).StartAsync();

var transport = TransportFor(host);

transport.DefaultDeadLetterQueueName.ShouldBe(AmazonSqsTransport.DeadLetterQueueName);
transport.Queues["orders"].DeadLetterQueueName.ShouldBe(AmazonSqsTransport.DeadLetterQueueName);
}

[Fact]
public async Task transport_default_applies_to_listeners_with_no_per_endpoint_override()
{
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("my-service-dlq")
.AutoProvision();

opts.ListenToSqsQueue("orders");
opts.ListenToSqsQueue("shipments");
}).StartAsync();

var transport = TransportFor(host);

transport.DefaultDeadLetterQueueName.ShouldBe("my-service-dlq");
transport.Queues["orders"].DeadLetterQueueName.ShouldBe("my-service-dlq");
transport.Queues["shipments"].DeadLetterQueueName.ShouldBe("my-service-dlq");
}

[Fact]
public async Task per_endpoint_override_wins_over_transport_default()
{
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("my-service-dlq")
.AutoProvision();

// Per-listener override wins for this listener only.
opts.ListenToSqsQueue("orders")
.ConfigureDeadLetterQueue("orders-errors");

// No override here — should still pick up the transport default.
opts.ListenToSqsQueue("shipments");
}).StartAsync();

var transport = TransportFor(host);

transport.Queues["orders"].DeadLetterQueueName.ShouldBe("orders-errors");
transport.Queues["shipments"].DeadLetterQueueName.ShouldBe("my-service-dlq");
}

[Fact]
public async Task per_endpoint_disable_wins_over_transport_default()
{
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("my-service-dlq")
.AutoProvision();

opts.ListenToSqsQueue("noisy-listener")
.DisableDeadLetterQueueing();

opts.ListenToSqsQueue("orders");
}).StartAsync();

var transport = TransportFor(host);

// Disabled listener resolves to null even though the transport default is set.
transport.Queues["noisy-listener"].DeadLetterQueueName.ShouldBeNull();

// Other listeners still inherit the transport default.
transport.Queues["orders"].DeadLetterQueueName.ShouldBe("my-service-dlq");
}

[Fact]
public async Task global_disable_trumps_transport_default_during_provisioning()
{
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("my-service-dlq")
.DisableAllNativeDeadLetterQueues();

opts.ListenToSqsQueue("orders");
}).StartAsync();

var transport = TransportFor(host);

// The DefaultDeadLetterQueueName property still records the user's intent —
// it's the DisableDeadLetterQueues kill-switch that suppresses provisioning
// and TryBuildDeadLetterSender (matches existing behaviour with the built-in
// default name).
transport.DisableDeadLetterQueues.ShouldBeTrue();

// No DLQ named "my-service-dlq" should be auto-provisioned.
transport.Queues.Contains("my-service-dlq").ShouldBeFalse();

// And no DLQ named with the historical default either.
transport.Queues.Contains(AmazonSqsTransport.DeadLetterQueueName).ShouldBeFalse();
}

[Fact]
public async Task transport_default_is_sanitized_consistently_with_per_listener_configuration()
{
// SanitizeSqsName replaces periods with hyphens so an SQS API call that
// disallows them succeeds. Per-listener ConfigureDeadLetterQueue does the
// same; the transport-level setter must match so a name authored with dots
// produces the identical canonical form on every queue.
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("acme.payments.dlq")
.AutoProvision();

opts.ListenToSqsQueue("orders");
}).StartAsync();

var transport = TransportFor(host);

transport.DefaultDeadLetterQueueName.ShouldBe("acme-payments-dlq");
transport.Queues["orders"].DeadLetterQueueName.ShouldBe("acme-payments-dlq");
}

[Fact]
public async Task transport_default_is_provisioned_when_listeners_inherit_it()
{
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("my-service-dlq")
.AutoProvision();

opts.ListenToSqsQueue("orders");
opts.ListenToSqsQueue("shipments");
}).StartAsync();

var transport = TransportFor(host);

// The custom DLQ is enumerated by AmazonSqsTransport.endpoints() because
// every inheriting listener resolves DeadLetterQueueName to "my-service-dlq",
// and the historical default name is no longer referenced by anyone.
transport.Queues.Contains("my-service-dlq").ShouldBeTrue();
transport.Queues.Contains(AmazonSqsTransport.DeadLetterQueueName).ShouldBeFalse();
}

[Fact]
public async Task mixed_inherit_and_override_provisions_both_dead_letter_queues()
{
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("my-service-dlq")
.AutoProvision();

opts.ListenToSqsQueue("orders"); // inherits "my-service-dlq"
opts.ListenToSqsQueue("payments")
.ConfigureDeadLetterQueue("payments-errors"); // overrides

opts.ListenToSqsQueue("notifications")
.DisableDeadLetterQueueing(); // disabled, no DLQ provisioned for this one
}).StartAsync();

var transport = TransportFor(host);

transport.Queues.Contains("my-service-dlq").ShouldBeTrue();
transport.Queues.Contains("payments-errors").ShouldBeTrue();

// The historical default and the disabled-listener never get provisioned as
// DLQs.
transport.Queues.Contains(AmazonSqsTransport.DeadLetterQueueName).ShouldBeFalse();
}

[Fact]
public void default_dead_letter_queue_name_throws_for_null_or_whitespace()
{
var transport = new AmazonSqsTransport();
var configuration = new AmazonSqsTransportConfiguration(transport, new WolverineOptions());

Should.Throw<ArgumentException>(() => configuration.DefaultDeadLetterQueueName(null!));
Should.Throw<ArgumentException>(() => configuration.DefaultDeadLetterQueueName(string.Empty));
Should.Throw<ArgumentException>(() => configuration.DefaultDeadLetterQueueName(" "));
}

[Fact]
public async Task system_queues_keep_their_explicit_no_dlq_setting_under_a_transport_default()
{
// tryBuildSystemEndpoints sets queue.DeadLetterQueueName = null on the
// response queue to opt it out of DLQ provisioning. With the new lazy
// resolver that null setter goes through and is recorded as an explicit
// override — so even when the transport-wide DefaultDeadLetterQueueName
// is set, the response queue still resolves to null. Without this
// guarantee the Wolverine response/control queues would inherit a DLQ on
// hosts that opt into DefaultDeadLetterQueueName.
using var host = await Host.CreateDefaultBuilder()
.UseWolverine(opts =>
{
opts.UseAmazonSqsTransportLocally()
.DefaultDeadLetterQueueName("my-service-dlq")
.EnableSystemQueues()
.AutoProvision();

opts.ListenToSqsQueue("orders");
}).StartAsync();

var transport = TransportFor(host);

transport.Queues["orders"].DeadLetterQueueName.ShouldBe("my-service-dlq");

// Every system queue should resolve to null (explicit DLQ opt-out preserved).
foreach (var systemQueue in transport.SystemQueues)
{
systemQueue.DeadLetterQueueName.ShouldBeNull(
$"System queue '{systemQueue.QueueName}' should not inherit the transport-wide DLQ.");
}
}
}
31 changes: 29 additions & 2 deletions src/Transports/AWS/Wolverine.AmazonSqs/Internal/AmazonSqsQueue.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,37 @@ public int VisibilityTimeout
[ChildDescription]
public CreateQueueRequest Configuration { get; }

private string? _deadLetterQueueName;
private bool _deadLetterQueueNameSetExplicitly;

/// <summary>
/// Name of the dead letter queue for this SQS queue where failed messages will be moved
/// Name of the dead letter queue for this SQS queue where failed messages will be moved.
/// Resolution order:
/// <list type="number">
/// <item>If <c>ConfigureDeadLetterQueue</c> or <c>DisableDeadLetterQueueing</c> ran on
/// this listener, the explicit value (including <c>null</c> for "disabled") wins.</item>
/// <item>Otherwise, falls back to
/// <see cref="AmazonSqsTransport.DefaultDeadLetterQueueName"/> on the parent transport
/// — which itself defaults to <see cref="AmazonSqsTransport.DeadLetterQueueName"/>
/// (<c>"wolverine-dead-letter-queue"</c>) for hosts that haven't opted into a custom
/// transport-wide default.</item>
/// </list>
/// This means an unconfigured queue picks up whatever the transport's default is at the
/// point Wolverine reads the property — the order between
/// <c>UseAmazonSqsTransport().DefaultDeadLetterQueueName(...)</c> and the per-listener
/// bootstrap calls doesn't matter.
/// </summary>
public string? DeadLetterQueueName { get; set; } = AmazonSqsTransport.DeadLetterQueueName;
public string? DeadLetterQueueName
{
get => _deadLetterQueueNameSetExplicitly
? _deadLetterQueueName
: _parent.DefaultDeadLetterQueueName;
set
{
_deadLetterQueueName = value;
_deadLetterQueueNameSetExplicitly = true;
}
}

/// <summary>
/// Optional list of message attribute names to request in ReceiveMessage.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public AmazonSqsTransport(string protocol) : base(protocol, "Amazon SQS", ["aws"
{
Queues = new LightweightCache<string, AmazonSqsQueue>(name => new AmazonSqsQueue(name, this));
IdentifierDelimiter = "-";
DefaultDeadLetterQueueName = DeadLetterQueueName;
}

public AmazonSqsTransport() : this("sqs")
Expand Down Expand Up @@ -58,6 +59,22 @@ internal AmazonSqsTransport(IAmazonSQS client) : this()
public bool UseLocalStackInDevelopment { get; set; }
public bool DisableDeadLetterQueues { get; set; }

/// <summary>
/// Transport-wide default dead-letter-queue name. Every <see cref="AmazonSqsQueue"/>
/// that hasn't had an explicit per-listener override applied
/// (via <c>ConfigureDeadLetterQueue</c> or <c>DisableDeadLetterQueueing</c>) reads
/// its <see cref="AmazonSqsQueue.DeadLetterQueueName"/> through this property.
/// Defaults to <see cref="DeadLetterQueueName"/> (<c>"wolverine-dead-letter-queue"</c>),
/// matching the historical behaviour for hosts that don't opt in.
///
/// Set via <c>AmazonSqsTransportConfiguration.DefaultDeadLetterQueueName(string)</c>
/// at host bootstrap; also honoured by auto-provision (the default DLQ is provisioned
/// once under whatever name resolves at that point). Per-listener overrides always
/// win over this default; <c>DisableAllNativeDeadLetterQueues()</c> disables the
/// whole DLQ surface regardless of what's configured here.
/// </summary>
public string? DefaultDeadLetterQueueName { get; set; }

/// <summary>
/// Is this transport connection allowed to build and use response and control queues
/// for just this node? Default is false, requiring explicit opt-in.
Expand Down
Loading
Loading