Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d6add28
Map GroupId to SessionId and preserve retry message fields
niemyjski Oct 5, 2026
c38bf15
Register empty GroupId and isolate session guard tests
niemyjski Oct 5, 2026
48e7520
Pin guarded build workflow for feature branch validation
niemyjski Oct 6, 2026
fd745d6
Stop claiming GroupId affects delivery
niemyjski Oct 6, 2026
f85c690
Schedule the delayed retry before completing the original message
niemyjski Oct 6, 2026
2723afc
Limit retry changes to SessionId and send-before-complete; restore ma…
niemyjski Oct 8, 2026
c32361f
Use matching GroupId core and TestHarness preview
niemyjski Oct 8, 2026
4ef8fae
Add session receive for session-enabled queues; make emulator drain s…
niemyjski Oct 14, 2026
791246b
Update Foundatio to 13.0.5-preview.0.10 and point build workflow back…
niemyjski Oct 14, 2026
7ded01a
Keep session receiver until settlement succeeds and untrack cancelled…
niemyjski Oct 14, 2026
8ab8077
Address review feedback: pattern matching, shared stats/untrack helpe…
niemyjski Oct 14, 2026
92bc44a
Use pattern matching for failure reason checks
niemyjski Oct 14, 2026
4543029
Harden sessions: keep group order on abandon, validate GroupId before…
niemyjski Oct 14, 2026
eca2270
Detect duplicate detection on existing queues, compare-and-remove tra…
niemyjski Oct 14, 2026
8653e24
Refresh duplicate-detection state on create and delete, abandon entri…
niemyjski Oct 14, 2026
969458c
Include the exception message in unsettled-message log entries
niemyjski Oct 14, 2026
14af709
Add missing blank lines between members
niemyjski Oct 14, 2026
9a3d792
Add blank line after stale session receiver disposal
niemyjski Oct 14, 2026
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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ await messageBus.PublishAsync(new MyMessage { Data = "Hello" });
| [AWS (S3/SQS/SNS)](https://github.com/FoundatioFx/Foundatio.AWS) | | ✅ | ✅ | ✅ | |
| [RabbitMQ](https://github.com/FoundatioFx/Foundatio.RabbitMQ) | | | ✅ | | |
| [Kafka](https://github.com/FoundatioFx/Foundatio.Kafka) | | | ✅ | | |
| [Minio](https://github.com/FoundatioFx/Foundatio.Minio) | | | | ✅ | |
| [Aliyun](https://github.com/FoundatioFx/Foundatio.Aliyun) | | | | ✅ | |
Comment thread
niemyjski marked this conversation as resolved.
| [SFTP](https://github.com/FoundatioFx/Foundatio.Storage.SshNet) | | | | ✅ | |

Expand Down
10 changes: 10 additions & 0 deletions ServiceBus-Emulator/Config/Config.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
"DefaultMessageTimeToLive": "PT1H",
"DeadLetteringOnMessageExpiration": true
}
},
{
"Name": "foundatio-test-queue-sessions",
"Properties": {
"LockDuration": "PT1M",
"MaxDeliveryCount": 5,
"DefaultMessageTimeToLive": "PT1H",
"DeadLetteringOnMessageExpiration": true,
"RequiresSession": true
}
}
],
"Topics": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<PackageReference Include="Azure.Messaging.ServiceBus" Version="7.21.0" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />

<PackageReference Include="Foundatio" Version="13.0.4" Condition="'$(ReferenceFoundatioSource)' == '' OR '$(ReferenceFoundatioSource)' == 'false'" />
<PackageReference Include="Foundatio" Version="13.0.5-preview.0.10" Condition="'$(ReferenceFoundatioSource)' == '' OR '$(ReferenceFoundatioSource)' == 'false'" />
<ProjectReference Include="..\..\..\Foundatio\src\Foundatio\Foundatio.csproj" Condition="'$(ReferenceFoundatioSource)' == 'true'" />
</ItemGroup>
</Project>
594 changes: 490 additions & 104 deletions src/Foundatio.AzureServiceBus/Queues/AzureServiceBusQueue.cs

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions src/Foundatio.AzureServiceBus/Queues/AzureServiceBusQueueEntry.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Linq;
using Azure.Messaging.ServiceBus;
using Foundatio.AzureServiceBus.Utility;
Expand All @@ -8,6 +9,11 @@ public class AzureServiceBusQueueEntry<T> : QueueEntry<T> where T : class
{
public ServiceBusReceivedMessage UnderlyingMessage { get; }

/// <summary>
/// The session receiver that holds this message's session on session-enabled queues; otherwise null.
/// </summary>
internal ServiceBusSessionReceiver? SessionReceiver { get; init; }

public AzureServiceBusQueueEntry(ServiceBusReceivedMessage message, T value, IQueue<T> queue)
: base(message.MessageId, message.CorrelationId, value, queue, message.EnqueuedTime.UtcDateTime, GetAttemptCount(message))
{
Expand All @@ -21,17 +27,17 @@ public AzureServiceBusQueueEntry(ServiceBusReceivedMessage message, T value, IQu
}

UnderlyingMessage = message;
GroupId = String.IsNullOrEmpty(message.SessionId) ? null : message.SessionId;
}

/// <summary>
/// Gets the attempt count from the message. Uses the _attempts application property if available (for scheduled retries),
/// otherwise falls back to the DeliveryCount.
/// Gets the attempt count from the message. A scheduled retry copy stores the attempts of the original in the
/// <c>_attempts</c> application property; deliveries of the copy itself (lock expiry or abandon) add its DeliveryCount.
/// </summary>
private static int GetAttemptCount(ServiceBusReceivedMessage message)
{
// Check if we have a stored attempt count from a scheduled retry
if (message.ApplicationProperties.TryGetValue("_attempts", out object? attemptsValue) && attemptsValue is int storedAttempts)
return storedAttempts + 1; // Add 1 because this is a new delivery of that retry
return storedAttempts + message.DeliveryCount;

// Fall back to delivery count for normal abandon/retry
return message.DeliveryCount;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ namespace Foundatio.AzureServiceBus.Queues;

public record AzureServiceBusQueueEntryOptions : QueueEntryOptions
{
/// <summary>
/// The Service Bus session id. Overrides <see cref="QueueEntryOptions.GroupId"/> when both are set.
/// </summary>
public string? SessionId { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ public class AzureServiceBusQueueOptions<T> : SharedQueueOptions<T> where T : cl
public string? UserMetadata { get; set; }

/// <summary>
/// The function to calculate retry delay based on the attempt number.
/// The function to calculate retry delay based on the attempt number. A delayed retry is sent as a scheduled copy of the
/// message. It is not applied on session-enabled queues, where the copy would be processed after later messages in its
/// group, or on queues with duplicate detection, where the copy would be discarded; those queues retry immediately.
/// </summary>
public Func<int, TimeSpan> RetryDelay { get; set; } = attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)) + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 100));
}
Expand Down
2 changes: 1 addition & 1 deletion tests/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.5" PrivateAssets="All" />

<PackageReference Include="Foundatio.TestHarness" Version="13.0.4" Condition="'$(ReferenceFoundatioSource)' == '' OR '$(ReferenceFoundatioSource)' == 'false'" />
<PackageReference Include="Foundatio.TestHarness" Version="13.0.5-preview.0.10" Condition="'$(ReferenceFoundatioSource)' == '' OR '$(ReferenceFoundatioSource)' == 'false'" />
<ProjectReference Include="..\..\..\Foundatio\src\Foundatio.TestHarness\Foundatio.TestHarness.csproj" Condition="'$(ReferenceFoundatioSource)' == 'true'" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using Foundatio.AzureServiceBus.Queues;
using Foundatio.Queues;
using Foundatio.Serializer;
using Foundatio.Tests.Queue;
Expand Down Expand Up @@ -40,7 +41,7 @@ public AzureServiceBusQueueTests(ITestOutputHelper output) : base(output)
_assertStats = false;
}

protected override IQueue<SimpleWorkItem>? GetQueue(int retries = 1, TimeSpan? workItemTimeout = null, TimeSpan? retryDelay = null, int[]? retryMultipliers = null, int deadLetterMaxItems = 100, bool runQueueMaintenance = true, TimeProvider? timeProvider = null, ISerializer? serializer = null)
protected override IQueue<SimpleWorkItem>? GetQueue(int retries = 1, TimeSpan? workItemTimeout = null, TimeSpan? retryDelay = null, int[]? retryMultipliers = null, int deadLetterMaxItems = 100, bool runQueueMaintenance = true, TimeProvider? timeProvider = null, ISerializer? serializer = null, Func<SimpleWorkItem, string?>? groupIdResolver = null)
{
string? connectionString = Configuration.GetConnectionString("AzureServiceBusConnectionString");
if (String.IsNullOrEmpty(connectionString))
Expand All @@ -62,6 +63,9 @@ public AzureServiceBusQueueTests(ITestOutputHelper output) : base(output)
if (retryDelay.HasValue)
o.RetryDelay(_ => retryDelay.Value);

if (groupIdResolver is not null)
o.GroupId(groupIdResolver);

if (!_isEmulator)
{
o.AutoDeleteOnIdle(TimeSpan.FromMinutes(5))
Expand Down Expand Up @@ -242,6 +246,18 @@ public override Task DequeueAsync_AfterAbandonWithMutatedValue_ReturnsOriginalVa
return base.DequeueAsync_AfterAbandonWithMutatedValue_ReturnsOriginalValueAsync();
}

[Fact]
public override Task AbandonAsync_WithGroupId_PreservesGroupIdOnRetryAsync()
{
return base.AbandonAsync_WithGroupId_PreservesGroupIdOnRetryAsync();
}

[Fact]
public override Task AbandonAsync_WithGroupIdAndRetryDelay_PreservesGroupIdOnRetryAsync()
{
return base.AbandonAsync_WithGroupIdAndRetryDelay_PreservesGroupIdOnRetryAsync();
}

[Fact]
public override Task DequeueAsync_WithDispose_AutoAbandonsEntryAsync()
{
Expand Down Expand Up @@ -318,12 +334,171 @@ public override Task DuplicateDetection_WithNullIdentifier_AcceptsAllItemsAsync(
return base.DuplicateDetection_WithNullIdentifier_AcceptsAllItemsAsync();
}

[Fact]
public override Task EnqueueAsync_WhenEnqueuingHandlerClearsGroupId_EnqueuesWithoutGroupAsync()
{
return base.EnqueueAsync_WhenEnqueuingHandlerClearsGroupId_EnqueuesWithoutGroupAsync();
}

[Fact]
public override Task EnqueueAsync_WithEmptyGroupId_EnqueuesWithoutGroupAsync()
{
return base.EnqueueAsync_WithEmptyGroupId_EnqueuesWithoutGroupAsync();
}

[Fact]
public async Task EnqueueAsync_WithGroupId_DoesNotClaimGroupedDeliveryAsync()
{
// Arrange: the queue is not session-enabled and cannot receive from sessions, so GroupId cannot affect delivery
using var queue = GetQueue();
if (queue is null)
return;

try
{
await queue.DeleteQueueAsync();
await AssertEmptyQueueAsync(queue);

// Act
await queue.EnqueueAsync(new SimpleWorkItem { Data = "no-grouped-delivery" }, new QueueEntryOptions { GroupId = "tenant-123" });

// Assert
Assert.Contains(Log.LogEntries, e => e.LogLevel == LogLevel.Debug && e.Message.Contains("delivery order or fairness"));
var entry = await queue.DequeueAsync(TimeSpan.FromSeconds(5));
Assert.NotNull(entry);
await entry.CompleteAsync();
}
finally
{
await CleanupQueueAsync(queue);
}
}

[Fact]
public override Task EnqueueAsync_WithGroupId_RoundTripsGroupIdAsync()
{
return base.EnqueueAsync_WithGroupId_RoundTripsGroupIdAsync();
}

[Fact]
public async Task EnqueueAsync_WithGroupId_SendsGroupIdAsSessionIdAsync()
{
// Arrange
using var queue = GetQueue();
if (queue is null)
return;

try
{
await queue.DeleteQueueAsync();
await AssertEmptyQueueAsync(queue);

// Act
await queue.EnqueueAsync(new SimpleWorkItem { Data = "session-id-test" }, new QueueEntryOptions { GroupId = "tenant-123" });

// Assert
var entry = await queue.DequeueAsync(TimeSpan.FromSeconds(5));
var serviceBusEntry = Assert.IsType<AzureServiceBusQueueEntry<SimpleWorkItem>>(entry);
Assert.Equal("tenant-123", serviceBusEntry.UnderlyingMessage.SessionId);
Assert.Equal("tenant-123", serviceBusEntry.GroupId);
await serviceBusEntry.CompleteAsync();
}
finally
{
await CleanupQueueAsync(queue);
}
}

[Fact]
public override Task EnqueueAsync_WithSerializationError_ThrowsAndLeavesQueueEmptyAsync()
{
return base.EnqueueAsync_WithSerializationError_ThrowsAndLeavesQueueEmptyAsync();
}

[Fact]
public async Task EnqueueAsync_WithSessionIdAndGroupId_UsesSessionIdAsync()
{
// Arrange
using var queue = GetQueue();
if (queue is null)
return;

try
{
await queue.DeleteQueueAsync();
await AssertEmptyQueueAsync(queue);

// Act
await queue.EnqueueAsync(new SimpleWorkItem { Data = "session-id-test" },
new AzureServiceBusQueueEntryOptions { GroupId = "tenant-123", SessionId = "session-456" });

// Assert
var entry = await queue.DequeueAsync(TimeSpan.FromSeconds(5));
var serviceBusEntry = Assert.IsType<AzureServiceBusQueueEntry<SimpleWorkItem>>(entry);
Assert.Equal("session-456", serviceBusEntry.UnderlyingMessage.SessionId);
Assert.Equal("session-456", serviceBusEntry.GroupId);
await serviceBusEntry.CompleteAsync();
}
finally
{
await CleanupQueueAsync(queue);
}
}

[Fact]
public override Task EnqueueAsync_WithReusedOptions_DoesNotChangeCallerOptionsAsync()
{
return base.EnqueueAsync_WithReusedOptions_DoesNotChangeCallerOptionsAsync();
}

[Fact]
public override Task EnqueueAsync_WhenResolverReturnsEmpty_EnqueuesWithoutGroupAsync()
{
return base.EnqueueAsync_WhenResolverReturnsEmpty_EnqueuesWithoutGroupAsync();
}

[Fact]
public override Task EnqueueAsync_WhenResolverReturnsNull_EnqueuesWithoutGroupAsync()
{
return base.EnqueueAsync_WhenResolverReturnsNull_EnqueuesWithoutGroupAsync();
}

[Fact]
public override Task EnqueueAsync_WithActivity_AppliesCorrelationBeforeEnqueuingHandlersAsync()
{
return base.EnqueueAsync_WithActivity_AppliesCorrelationBeforeEnqueuingHandlersAsync();
}

[Fact]
public override Task EnqueueAsync_WithEmptyGroupIdAndResolver_UsesPayloadValueAsync()
{
return base.EnqueueAsync_WithEmptyGroupIdAndResolver_UsesPayloadValueAsync();
}

[Fact]
public override Task EnqueueAsync_WithExplicitGroupId_OverridesResolverAsync()
{
return base.EnqueueAsync_WithExplicitGroupId_OverridesResolverAsync();
}

[Fact]
public override Task EnqueueAsync_WithGroupIdResolver_DoesNotChangeCallerOptionsAsync()
{
return base.EnqueueAsync_WithGroupIdResolver_DoesNotChangeCallerOptionsAsync();
}

[Fact]
public override Task EnqueueAsync_WithGroupIdResolver_UsesPayloadValueAsync()
{
return base.EnqueueAsync_WithGroupIdResolver_UsesPayloadValueAsync();
}

[Fact]
public override Task EnqueueAsync_WithReusedOptionsAndGroupIdResolver_EnqueuesEachMessageAsync()
{
return base.EnqueueAsync_WithReusedOptionsAndGroupIdResolver_EnqueuesEachMessageAsync();
}

[Fact]
public override Task EnqueueAsync_WithUniqueId_UsesProvidedIdAsync()
{
Expand Down
Loading
Loading