From 73bd3bf3085ea75691a61072ddc34ca8f28a5b9c Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 7 Feb 2026 12:51:48 -0500 Subject: [PATCH] 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 --- allstarr/Program.cs | 4 + .../Common/EndpointBenchmarkService.cs | 135 ++++++++++++++++++ .../Common/RoundRobinFallbackHelper.cs | 25 ++++ .../SquidWTF/SquidWTFStartupValidator.cs | 52 ++++++- 4 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 allstarr/Services/Common/EndpointBenchmarkService.cs diff --git a/allstarr/Program.cs b/allstarr/Program.cs index 714fe01..894018c 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -477,6 +477,9 @@ else builder.Services.AddSingleton(); } +// Register endpoint benchmark service +builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => @@ -484,6 +487,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService>(), sp.GetRequiredService().CreateClient(), squidWtfApiUrls, + sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddSingleton(); diff --git a/allstarr/Services/Common/EndpointBenchmarkService.cs b/allstarr/Services/Common/EndpointBenchmarkService.cs new file mode 100644 index 0000000..688e3d9 --- /dev/null +++ b/allstarr/Services/Common/EndpointBenchmarkService.cs @@ -0,0 +1,135 @@ +using System.Diagnostics; + +namespace allstarr.Services.Common; + +/// +/// Benchmarks API endpoints on startup and maintains performance metrics. +/// Used to prioritize faster endpoints in racing scenarios. +/// +public class EndpointBenchmarkService +{ + private readonly ILogger _logger; + private readonly Dictionary _metrics = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + public EndpointBenchmarkService(ILogger logger) + { + _logger = logger; + } + + /// + /// Benchmarks a list of endpoints by making test requests. + /// Returns endpoints sorted by average response time (fastest first). + /// + public async Task> BenchmarkEndpointsAsync( + List endpoints, + Func> 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; + } + + /// + /// Gets the metrics for a specific endpoint. + /// + public EndpointMetrics? GetMetrics(string endpoint) + { + _metrics.TryGetValue(endpoint, out var metrics); + return metrics; + } + + /// + /// Gets all endpoint metrics sorted by performance. + /// + public List 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; } +} diff --git a/allstarr/Services/Common/RoundRobinFallbackHelper.cs b/allstarr/Services/Common/RoundRobinFallbackHelper.cs index 601aa72..a290262 100644 --- a/allstarr/Services/Common/RoundRobinFallbackHelper.cs +++ b/allstarr/Services/Common/RoundRobinFallbackHelper.cs @@ -26,6 +26,31 @@ public class RoundRobinFallbackHelper } } + /// + /// Updates the endpoint order based on benchmark results (fastest first). + /// + public void SetEndpointOrder(List 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))); + } + } + /// /// 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. diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index 5049eb8..e1bbbfc 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -14,14 +14,23 @@ public class SquidWTFStartupValidator : BaseStartupValidator { private readonly SquidWTFSettings _settings; private readonly RoundRobinFallbackHelper _fallbackHelper; + private readonly EndpointBenchmarkService _benchmarkService; + private readonly ILogger _logger; public override string ServiceName => "SquidWTF"; - public SquidWTFStartupValidator(IOptions settings, HttpClient httpClient, List apiUrls, ILogger logger) + public SquidWTFStartupValidator( + IOptions settings, + HttpClient httpClient, + List apiUrls, + EndpointBenchmarkService benchmarkService, + ILogger logger) : base(httpClient) { _settings = settings.Value; _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); + _benchmarkService = benchmarkService; + _logger = logger; } @@ -41,6 +50,47 @@ public class SquidWTFStartupValidator : BaseStartupValidator 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(); + + // 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)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 var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => {