mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
- New EndpointBenchmarkService pings all endpoints on startup - Measures average response time and success rate - Reorders endpoints by performance (fastest first) - RoundRobinFallbackHelper now uses benchmarked order - Racing still happens, but starts with fastest endpoints - Reduces latency by prioritizing known-fast servers - Logs benchmark results for visibility
193 lines
7.3 KiB
C#
193 lines
7.3 KiB
C#
namespace allstarr.Services.Common;
|
|
|
|
/// <summary>
|
|
/// Helper for round-robin load balancing with fallback across multiple API endpoints.
|
|
/// Distributes load evenly while maintaining reliability through automatic failover.
|
|
/// </summary>
|
|
public class RoundRobinFallbackHelper
|
|
{
|
|
private readonly List<string> _apiUrls;
|
|
private int _currentUrlIndex = 0;
|
|
private readonly object _urlIndexLock = new object();
|
|
private readonly ILogger _logger;
|
|
private readonly string _serviceName;
|
|
|
|
public int EndpointCount => _apiUrls.Count;
|
|
|
|
public RoundRobinFallbackHelper(List<string> apiUrls, ILogger logger, string serviceName)
|
|
{
|
|
_apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_serviceName = serviceName ?? "Service";
|
|
|
|
if (_apiUrls.Count == 0)
|
|
{
|
|
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the endpoint order based on benchmark results (fastest first).
|
|
/// </summary>
|
|
public void SetEndpointOrder(List<string> orderedEndpoints)
|
|
{
|
|
lock (_urlIndexLock)
|
|
{
|
|
// Reorder _apiUrls to match the benchmarked order
|
|
var reordered = orderedEndpoints.Where(e => _apiUrls.Contains(e)).ToList();
|
|
|
|
// Add any endpoints that weren't benchmarked (shouldn't happen, but be safe)
|
|
foreach (var url in _apiUrls.Where(u => !reordered.Contains(u)))
|
|
{
|
|
reordered.Add(url);
|
|
}
|
|
|
|
_apiUrls.Clear();
|
|
_apiUrls.AddRange(reordered);
|
|
_currentUrlIndex = 0;
|
|
|
|
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
|
_serviceName, string.Join(", ", _apiUrls.Take(3)));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
|
/// This distributes load evenly across all providers while maintaining reliability.
|
|
/// Throws exception if all endpoints fail.
|
|
/// </summary>
|
|
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
|
{
|
|
// Start with the next URL in round-robin to distribute load
|
|
var startIndex = 0;
|
|
lock (_urlIndexLock)
|
|
{
|
|
startIndex = _currentUrlIndex;
|
|
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
|
}
|
|
|
|
// Try all URLs starting from the round-robin selected one
|
|
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
|
{
|
|
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
|
var baseUrl = _apiUrls[urlIndex];
|
|
|
|
try
|
|
{
|
|
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
|
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
|
return await action(baseUrl);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
|
_serviceName, baseUrl);
|
|
|
|
if (attempt == _apiUrls.Count - 1)
|
|
{
|
|
_logger.LogError("All {Count} {Service} endpoints failed", _apiUrls.Count, _serviceName);
|
|
throw;
|
|
}
|
|
}
|
|
}
|
|
throw new Exception($"All {_serviceName} endpoints failed");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Races all endpoints in parallel and returns the first successful result.
|
|
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations.
|
|
/// </summary>
|
|
public async Task<T> RaceAllEndpointsAsync<T>(Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
|
|
{
|
|
if (_apiUrls.Count == 1)
|
|
{
|
|
// No point racing with one endpoint
|
|
return await action(_apiUrls[0], cancellationToken);
|
|
}
|
|
|
|
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
|
|
|
|
// Start all requests in parallel
|
|
foreach (var baseUrl in _apiUrls)
|
|
{
|
|
var task = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
_logger.LogDebug("Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
|
|
var result = await action(baseUrl, raceCts.Token);
|
|
return (result, baseUrl, true);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
|
|
return (default(T)!, baseUrl, false);
|
|
}
|
|
}, raceCts.Token);
|
|
|
|
tasks.Add(task);
|
|
}
|
|
|
|
// Wait for first successful completion
|
|
while (tasks.Count > 0)
|
|
{
|
|
var completedTask = await Task.WhenAny(tasks);
|
|
var (result, endpoint, success) = await completedTask;
|
|
|
|
if (success)
|
|
{
|
|
_logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
|
raceCts.Cancel(); // Cancel all other requests
|
|
return result;
|
|
}
|
|
|
|
tasks.Remove(completedTask);
|
|
}
|
|
|
|
throw new Exception($"All {_serviceName} endpoints failed in race");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
|
/// Returns default value if all endpoints fail (does not throw).
|
|
/// </summary>
|
|
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
|
{
|
|
// Start with the next URL in round-robin to distribute load
|
|
var startIndex = 0;
|
|
lock (_urlIndexLock)
|
|
{
|
|
startIndex = _currentUrlIndex;
|
|
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
|
}
|
|
|
|
// Try all URLs starting from the round-robin selected one
|
|
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
|
{
|
|
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
|
var baseUrl = _apiUrls[urlIndex];
|
|
|
|
try
|
|
{
|
|
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
|
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
|
return await action(baseUrl);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
|
_serviceName, baseUrl);
|
|
|
|
if (attempt == _apiUrls.Count - 1)
|
|
{
|
|
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
|
|
_apiUrls.Count, _serviceName);
|
|
return defaultValue;
|
|
}
|
|
}
|
|
}
|
|
return defaultValue;
|
|
}
|
|
}
|