mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
135
allstarr/Services/Common/EndpointBenchmarkService.cs
Normal file
135
allstarr/Services/Common/EndpointBenchmarkService.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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) =>
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user