using Bot_Api_Gateway.Database; using Bot_Api_Gateway.API.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; namespace Bot_Api_Gateway.Services.Shards { public class ShardHeartbeat : BackgroundService { private readonly ISimpleLogger _logger; private readonly IDbContextFactory _dbFactory; private readonly IHttpClientFactory _clientFactory; private int _heartbeatInterval; public ShardHeartbeat(ISimpleLogger logger, IDbContextFactory dbFactory, IOptions config, IHttpClientFactory clientFactory) { _logger = logger; _dbFactory = dbFactory; _heartbeatInterval = config.Value.HeartbeatInterval; _clientFactory = clientFactory; } protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await PerformHeartbeatCheckAsync(ct); } catch (Exception ex) { _logger.LogConsole($"Critical error in heartbeat service: {ex.Message}", ISimpleLogger.Severity.Error); } await Task.Delay(TimeSpan.FromMinutes(_heartbeatInterval), ct); } } private async Task PerformHeartbeatCheckAsync(CancellationToken ct) { await using var db = await _dbFactory.CreateDbContextAsync(ct); var shards = await db.Shards .AsNoTracking() .Where(s => s.IsAssigned && s.Address != null) .ToListAsync(ct); if (shards.Count == 0) { _logger.LogConsole("No assigned shards found for heartbeat check. Skipping beat.", ISimpleLogger.Severity.Info); return; } _logger.LogConsole($"Performing heartbeat check for {shards.Count} shard(s).", ISimpleLogger.Severity.Info); // Process shards in parallel with limited concurrency using var semaphore = new SemaphoreSlim(5); var tasks = shards.Select(shard => CheckShardHealthAsync(shard.ID, shard.Address!, semaphore, ct)); var results = await Task.WhenAll(tasks); // Update database with results foreach (var (shardid, status) in results) { var shard = await db.Shards.Where(s => s.ID == shardid) .ExecuteUpdateAsync(s => s .SetProperty(s => s.LastHeartbeat, DateTime.UtcNow) .SetProperty(s => s.Status, status), ct); } _logger.LogConsole($"Heartbeat check completed. Updated {results.Length} shard(s).", ISimpleLogger.Severity.Info); } private async Task<(int shardid, DbShard.State status)> CheckShardHealthAsync(int shardID, string address, SemaphoreSlim semaphore, CancellationToken ct) { await semaphore.WaitAsync(ct); DbShard.State state = DbShard.State.Offline; try { if (string.IsNullOrWhiteSpace(address)) { _logger.LogConsole($"Shard {shardID} has no address but is marked as assigned.", ISimpleLogger.Severity.Error); state = DbShard.State.Offline; throw new InvalidDataException("Shard address is null or empty."); } var uri = new Uri(new Uri(address.TrimEnd('/') + "/"), $"shardheartbeat/shard/{shardID}"); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); // Per-shard timeout var client = _clientFactory.CreateClient("BotHttp"); using var response = await client.GetAsync(uri, timeoutCts.Token); Response.ShardHeartbeat? heartbeatResponse; if (!response.IsSuccessStatusCode) { heartbeatResponse = null; await response.Content.LoadIntoBufferAsync(); _logger.LogConsole($"Shard {shardID} returned status code {response.StatusCode}", ISimpleLogger.Severity.Warning); throw new HttpRequestException($"Received non-success status code {response.StatusCode} from shard."); } else { heartbeatResponse = await response.Content.ReadFromJsonAsync(cancellationToken: timeoutCts.Token); } if (heartbeatResponse == null) { _logger.LogConsole($"Shard {shardID} returned null response", ISimpleLogger.Severity.Warning); throw new HttpRequestException("Received null response from shard."); } state = heartbeatResponse.Status == 1 ? DbShard.State.Online : DbShard.State.Offline; } catch (HttpRequestException) { // Shard ist physisch nicht erreichbar (Programm läuft nicht, Netzwerkproblem, etc.) // Kein Logging nötig - das ist ein erwarteter Zustand state = DbShard.State.Offline; } catch (OperationCanceledException) { // Timeout - Shard reagiert nicht rechtzeitig state = DbShard.State.Offline; } catch (InvalidDataException) { state = DbShard.State.Offline; } catch (Exception ex) { // Unerwartete Fehler sollten geloggt werden _logger.LogConsole($"Unexpected error checking shard {shardID}: {ex.Message}", ISimpleLogger.Severity.Error); state = DbShard.State.Offline; } finally { semaphore.Release(); } return (shardID, state); } } }