feat: add endpoint benchmarking on startup

- 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
This commit is contained in:
2026-02-07 12:51:48 -05:00
parent 43bf71c390
commit 73bd3bf308
4 changed files with 215 additions and 1 deletions

View File

@@ -477,6 +477,9 @@ else
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>(); builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
} }
// Register endpoint benchmark service
builder.Services.AddSingleton<EndpointBenchmarkService>();
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>(); builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>(); builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator>(sp => builder.Services.AddSingleton<IStartupValidator>(sp =>
@@ -484,6 +487,7 @@ builder.Services.AddSingleton<IStartupValidator>(sp =>
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(), sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(), sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls, squidWtfApiUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>())); sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>(); builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();

View File

@@ -0,0 +1,135 @@
using System.Diagnostics;
namespace allstarr.Services.Common;
/// <summary>
/// Benchmarks API endpoints on startup and maintains performance metrics.
/// Used to prioritize faster endpoints in racing scenarios.
/// </summary>
public class EndpointBenchmarkService
{
private readonly ILogger<EndpointBenchmarkService> _logger;
private readonly Dictionary<string, EndpointMetrics> _metrics = new();
private readonly SemaphoreSlim _lock = new(1, 1);
public EndpointBenchmarkService(ILogger<EndpointBenchmarkService> logger)
{
_logger = logger;
}
/// <summary>
/// Benchmarks a list of endpoints by making test requests.
/// Returns endpoints sorted by average response time (fastest first).
/// </summary>
public async Task<List<string>> BenchmarkEndpointsAsync(
List<string> endpoints,
Func<string, CancellationToken, Task<bool>> testFunc,
int pingCount = 3,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
var tasks = endpoints.Select(async endpoint =>
{
var sw = Stopwatch.StartNew();
var successCount = 0;
var totalMs = 0L;
for (int i = 0; i < pingCount; i++)
{
try
{
var pingStart = Stopwatch.GetTimestamp();
var success = await testFunc(endpoint, cancellationToken);
var pingMs = Stopwatch.GetElapsedTime(pingStart).TotalMilliseconds;
if (success)
{
successCount++;
totalMs += (long)pingMs;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
}
// Small delay between pings
if (i < pingCount - 1)
{
await Task.Delay(100, cancellationToken);
}
}
sw.Stop();
var avgMs = successCount > 0 ? totalMs / successCount : long.MaxValue;
var metrics = new EndpointMetrics
{
Endpoint = endpoint,
AverageResponseMs = avgMs,
SuccessRate = (double)successCount / pingCount,
LastBenchmark = DateTime.UtcNow
};
await _lock.WaitAsync(cancellationToken);
try
{
_metrics[endpoint] = metrics;
}
finally
{
_lock.Release();
}
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
endpoint, avgMs, metrics.SuccessRate);
return metrics;
}).ToList();
var results = await Task.WhenAll(tasks);
// Sort by: success rate first (must be > 0), then by average response time
var sorted = results
.Where(m => m.SuccessRate > 0)
.OrderByDescending(m => m.SuccessRate)
.ThenBy(m => m.AverageResponseMs)
.Select(m => m.Endpoint)
.ToList();
_logger.LogInformation("✅ Benchmark complete. Fastest: {Fastest} ({Ms}ms)",
sorted.FirstOrDefault() ?? "none",
results.Where(m => m.SuccessRate > 0).MinBy(m => m.AverageResponseMs)?.AverageResponseMs ?? 0);
return sorted;
}
/// <summary>
/// Gets the metrics for a specific endpoint.
/// </summary>
public EndpointMetrics? GetMetrics(string endpoint)
{
_metrics.TryGetValue(endpoint, out var metrics);
return metrics;
}
/// <summary>
/// Gets all endpoint metrics sorted by performance.
/// </summary>
public List<EndpointMetrics> GetAllMetrics()
{
return _metrics.Values
.OrderByDescending(m => m.SuccessRate)
.ThenBy(m => m.AverageResponseMs)
.ToList();
}
}
public class EndpointMetrics
{
public string Endpoint { get; set; } = string.Empty;
public long AverageResponseMs { get; set; }
public double SuccessRate { get; set; }
public DateTime LastBenchmark { get; set; }
}

View File

@@ -26,6 +26,31 @@ public class RoundRobinFallbackHelper
} }
} }
/// <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> /// <summary>
/// Tries the request with the next provider in round-robin, then falls back to others on failure. /// 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. /// This distributes load evenly across all providers while maintaining reliability.

View File

@@ -14,14 +14,23 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{ {
private readonly SquidWTFSettings _settings; private readonly SquidWTFSettings _settings;
private readonly RoundRobinFallbackHelper _fallbackHelper; private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly EndpointBenchmarkService _benchmarkService;
private readonly ILogger<SquidWTFStartupValidator> _logger;
public override string ServiceName => "SquidWTF"; public override string ServiceName => "SquidWTF";
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls, ILogger<SquidWTFStartupValidator> logger) public SquidWTFStartupValidator(
IOptions<SquidWTFSettings> settings,
HttpClient httpClient,
List<string> apiUrls,
EndpointBenchmarkService benchmarkService,
ILogger<SquidWTFStartupValidator> logger)
: base(httpClient) : base(httpClient)
{ {
_settings = settings.Value; _settings = settings.Value;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_benchmarkService = benchmarkService;
_logger = logger;
} }
@@ -41,6 +50,47 @@ public class SquidWTFStartupValidator : BaseStartupValidator
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan); WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
// Benchmark all endpoints to determine fastest
var apiUrls = _fallbackHelper.EndpointCount > 0
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
: new List<string>();
// Get the actual API URLs by reflection (not ideal, but works for now)
var fallbackHelperType = _fallbackHelper.GetType();
var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
if (apiUrlsField != null)
{
apiUrls = (List<string>)apiUrlsField.GetValue(_fallbackHelper)!;
}
if (apiUrls.Count > 1)
{
WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan);
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
apiUrls,
async (endpoint, ct) =>
{
try
{
var response = await _httpClient.GetAsync(endpoint, ct);
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
},
pingCount: 2,
cancellationToken);
if (orderedEndpoints.Count > 0)
{
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
}
}
// Test connectivity with fallback // Test connectivity with fallback
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {