Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace ServiceControl.Transports.UnitTests.ASBS
{
using System;
using NUnit.Framework;
using ServiceControl.Transports.ASBS;

[TestFixture]
class QueueLengthProviderBackoffTests
{
// Base is the configured QueueLengthQueryDelayInterval (default). Max is the internal reactive-backoff cap.
static readonly TimeSpan Base = TimeSpan.FromMilliseconds(500);
static readonly TimeSpan Max = TimeSpan.FromSeconds(60);

[Test]
public void Doubles_the_delay_when_throttled()
{
// A throttled cycle backs off so the provider stops making the throttling worse.
Assert.That(QueueLengthProvider.NextDelay(Base, Base, Max, throttled: true),
Is.EqualTo(TimeSpan.FromSeconds(1)));
}

[Test]
public void Caps_the_delay_at_max_when_throttled()
{
// 40s doubled would be 80s; the cap keeps it bounded.
Assert.That(QueueLengthProvider.NextDelay(TimeSpan.FromSeconds(40), Base, Max, throttled: true),
Is.EqualTo(Max));
}

[Test]
public void Halves_the_delay_on_success_while_still_backed_off()
{
// Success steps the cadence back down gradually rather than snapping to base, avoiding
// throttle -> reset -> throttle oscillation.
Assert.That(QueueLengthProvider.NextDelay(TimeSpan.FromSeconds(2), Base, Max, throttled: false),
Is.EqualTo(TimeSpan.FromSeconds(1)));
}

[Test]
public void Never_drops_below_base_on_success()
{
Assert.That(QueueLengthProvider.NextDelay(Base, Base, Max, throttled: false),
Is.EqualTo(Base));
}
}
}
2 changes: 1 addition & 1 deletion src/ServiceControl.Transports.ASBS/AuthenticationMethod.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

public abstract class AuthenticationMethod
{
public abstract ServiceBusAdministrationClient BuildManagementClient();
public abstract ServiceBusAdministrationClient BuildManagementClient(ServiceBusAdministrationClientOptions options = null);
public abstract AzureServiceBusTransport CreateTransportDefinition(ConnectionSettings connectionSettings, TopicTopology topology);
}
}
67 changes: 64 additions & 3 deletions src/ServiceControl.Transports.ASBS/QueueLengthProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace ServiceControl.Transports.ASBS
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Azure.Messaging.ServiceBus;
using Azure.Messaging.ServiceBus.Administration;
using Microsoft.Extensions.Logging;

Expand All @@ -16,7 +17,11 @@ public QueueLengthProvider(TransportSettings settings, Action<QueueLengthEntry[]

queryDelayInterval = connectionSettings.QueryDelayInterval ?? TimeSpan.FromMilliseconds(500);

managementClient = connectionSettings.AuthenticationMethod.BuildManagementClient();
// Disable the client's internal retries so throttled (429) queries fail fast and surface as an
// exception (see ExecuteAsync) instead of being silently retried away, and drop the retry amplification.
var clientOptions = new ServiceBusAdministrationClientOptions();
clientOptions.Retry.MaxRetries = 0;
managementClient = connectionSettings.AuthenticationMethod.BuildManagementClient(clientOptions);
this.logger = logger;
}

Expand All @@ -29,12 +34,18 @@ public override void TrackEndpointInputQueue(EndpointToQueueMapping queueToTrack

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// currentDelay backs off while throttled and recovers to the base once clear; throttled latches the log.
var currentDelay = queryDelayInterval;
var throttled = false;

while (!stoppingToken.IsCancellationRequested)
{
bool throttledThisCycle;

try
{
logger.LogDebug("Waiting for next interval");
await Task.Delay(queryDelayInterval, stoppingToken);
await Task.Delay(currentDelay, stoppingToken);

logger.LogDebug("Querying management client");

Expand All @@ -43,18 +54,65 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
logger.LogDebug("Retrieved details of {QueueCount} queues", queueRuntimeInfos.Count);

UpdateAllQueueLengths(queueRuntimeInfos);

throttledThisCycle = false;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// no-op
break;
}
catch (ServiceBusException e) when (e.Reason == ServiceBusFailureReason.ServiceBusy)
{
// With retries disabled, throttling surfaces here immediately and drives the back-off.
throttledThisCycle = true;
}
catch (Exception e)
{
// Unrelated error: log and keep the current cadence.
logger.LogError(e, "Error querying Azure Service Bus queue sizes");
continue;
}

// Store nothing on a throttled cycle; the last values stand. Ideally we'd record an explicit
// "no value" (null/-1) so the graph shows a gap, but Value is a non-nullable long and the
// API/ServicePulse coerce gaps to 0 - that needs a new API contract + a ServicePulse using it.
currentDelay = NextDelay(currentDelay, queryDelayInterval, MaxBackoffInterval, throttledThisCycle);

if (throttledThisCycle)
{
if (!throttled)
{
throttled = true;
logger.LogWarning("Azure Service Bus is throttling the management operations used to read queue lengths. Backing off to a {CurrentDelay} query interval. This is expected on busy or large namespaces; increase the 'QueueLengthQueryDelayInterval' connection string setting to reduce it. Queue length metrics may be delayed until the throttling clears.", currentDelay);
}
else
{
logger.LogDebug("Still throttled by Azure Service Bus; backing off query interval to {CurrentDelay}", currentDelay);
}
}
else if (throttled && currentDelay == queryDelayInterval)
{
// Recover only once fully back at the base, so a slow ramp-down doesn't re-arm the warning.
throttled = false;
logger.LogInformation("Azure Service Bus management throttling has cleared; resumed querying queue lengths every {QueryDelayInterval}", queryDelayInterval);
}
}
}

// Pure back-off policy (unit-tested). Throttled -> exponential up to the cap; success -> halve back toward
// the base so it settles near the sustainable rate instead of oscillating.
internal static TimeSpan NextDelay(TimeSpan current, TimeSpan baseDelay, TimeSpan maxDelay, bool throttled)
{
if (throttled)
{
var doubled = TimeSpan.FromTicks(current.Ticks * 2);
return doubled > maxDelay ? maxDelay : doubled;
}

var halved = TimeSpan.FromTicks(current.Ticks / 2);
return halved < baseDelay ? baseDelay : halved;
}

async Task<IReadOnlyDictionary<string, QueueRuntimeProperties>> GetQueueList(CancellationToken cancellationToken)
{
var queuePathToRuntimeInfo = new Dictionary<string, QueueRuntimeProperties>(StringComparer.InvariantCultureIgnoreCase);
Expand Down Expand Up @@ -111,5 +169,8 @@ void UpdateQueueLength(KeyValuePair<string, string> monitoredEndpoint, IReadOnly
readonly ServiceBusAdministrationClient managementClient;
readonly TimeSpan queryDelayInterval;
readonly ILogger<QueueLengthProvider> logger;

// Upper bound for the reactive back-off when Azure Service Bus is throttling management operations.
static readonly TimeSpan MaxBackoffInterval = TimeSpan.FromMinutes(1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<ProjectReference Include="..\ServiceControl.Transports\ServiceControl.Transports.csproj" Private="false" ExcludeAssets="runtime" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="ServiceControl.Transports.ASBS.Tests" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.ResourceManager.Monitor" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ public class SharedAccessSignatureAuthentication : AuthenticationMethod

public string ConnectionString { get; }

public override ServiceBusAdministrationClient BuildManagementClient()
=> new ServiceBusAdministrationClient(ConnectionString);
public override ServiceBusAdministrationClient BuildManagementClient(ServiceBusAdministrationClientOptions options = null)
=> options is null
? new ServiceBusAdministrationClient(ConnectionString)
: new ServiceBusAdministrationClient(ConnectionString, options);

public override AzureServiceBusTransport CreateTransportDefinition(ConnectionSettings connectionSettings, TopicTopology topology)
=> new AzureServiceBusTransport(ConnectionString, topology);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ public TokenCredentialAuthentication(string fullyQualifiedNamespace, string? cli

public string? ClientId { get; }

public override ServiceBusAdministrationClient BuildManagementClient() => new(FullyQualifiedNamespace, Credential);
public override ServiceBusAdministrationClient BuildManagementClient(ServiceBusAdministrationClientOptions? options = null)
=> options is null
? new(FullyQualifiedNamespace, Credential)
: new(FullyQualifiedNamespace, Credential, options);

public override AzureServiceBusTransport CreateTransportDefinition(ConnectionSettings connectionSettings, TopicTopology topology)
{
Expand Down
Loading