using Cronos; using Mercados.Infrastructure.DataFetchers; using Mercados.Infrastructure.Services; using Microsoft.Extensions.Configuration; namespace Mercados.Worker { /// /// Servicio de fondo que orquesta la obtención de datos de diversas fuentes /// de forma programada y periódica. /// public class DataFetchingService : BackgroundService { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly TimeZoneInfo _argentinaTimeZone; // Almacenamos las expresiones Cron parseadas para no tener que hacerlo en cada ciclo. private readonly CronExpression _agroSchedule; private readonly CronExpression _bcrSchedule; private readonly CronExpression _bolsasSchedule; // Almacenamos la próxima ejecución calculada para cada tarea. private DateTime? _nextAgroRun; private DateTime? _nextBcrRun; private DateTime? _nextBolsasRun; // Diccionario para rastrear la hora de la última alerta ENVIADA por cada tarea. private readonly Dictionary _lastAlertSent = new(); // Definimos el período de "silencio" para las alertas (ej. 4 horas). private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); public DataFetchingService( ILogger logger, IServiceProvider serviceProvider, IConfiguration configuration) { _logger = logger; _serviceProvider = serviceProvider; // Se define explícitamente la zona horaria de Argentina. try { _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); } catch (TimeZoneNotFoundException) { _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); } // Parseamos las expresiones Cron UNA SOLA VEZ, en el constructor. // Si una expresión es inválida o nula, el servicio fallará al iniciar, // lo cual es un comportamiento deseable para alertar de una mala configuración. // El '!' le dice al compilador que confiamos que estos valores no serán nulos. _agroSchedule = CronExpression.Parse(configuration["Schedules:MercadoAgroganadero"]!); _bcrSchedule = CronExpression.Parse(configuration["Schedules:BCR"]!); _bolsasSchedule = CronExpression.Parse(configuration["Schedules:Bolsas"]!); } /// /// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca. /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); // Ejecutamos una vez al inicio para tener datos frescos inmediatamente. await RunAllFetchersAsync(stoppingToken); // Calculamos las primeras ejecuciones programadas al arrancar. var utcNow = DateTime.UtcNow; _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); // Usamos un PeriodicTimer que "despierta" cada 30 segundos para revisar si hay tareas pendientes. // Un intervalo más corto aumenta la precisión del disparo de las tareas. using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { utcNow = DateTime.UtcNow; // Comprobamos si ha llegado el momento de la próxima ejecución para cada tarea. if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) { await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); // Inmediatamente después de ejecutar, calculamos la SIGUIENTE ocurrencia. _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value) { await RunFetcherByNameAsync("BCR", stoppingToken); _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) { _logger.LogInformation("Ventana de ejecución para Bolsas. Iniciando en paralelo..."); await Task.WhenAll( RunFetcherByNameAsync("YahooFinance", stoppingToken), RunFetcherByNameAsync("Finnhub", stoppingToken) ); _nextBolsasRun = _bolsasSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } } } /// /// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones. /// private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) { if (stoppingToken.IsCancellationRequested) return; _logger.LogInformation("Intentando ejecutar fetcher: {sourceName}", sourceName); using var scope = _serviceProvider.CreateScope(); var fetchers = scope.ServiceProvider.GetRequiredService>(); var fetcher = fetchers.FirstOrDefault(f => f.SourceName.Equals(sourceName, StringComparison.OrdinalIgnoreCase)); if (fetcher != null) { var (success, message) = await fetcher.FetchDataAsync(); if (!success) { var errorMessage = $"Falló la ejecución del fetcher {sourceName}: {message}"; _logger.LogError(errorMessage); if (ShouldSendAlert(sourceName)) { var notifier = scope.ServiceProvider.GetRequiredService(); await notifier.SendFailureAlertAsync($"Fallo Crítico en el Fetcher: {sourceName}", errorMessage, DateTime.UtcNow); _lastAlertSent[sourceName] = DateTime.UtcNow; } else { _logger.LogWarning("Fallo repetido para {sourceName}. Alerta silenciada temporalmente.", sourceName); } } } else { _logger.LogWarning("No se encontró un fetcher con el nombre: {sourceName}", sourceName); } } /// /// Ejecuta todos los fetchers en paralelo al iniciar el servicio. /// private async Task RunAllFetchersAsync(CancellationToken stoppingToken) { _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); using var scope = _serviceProvider.CreateScope(); var fetchers = scope.ServiceProvider.GetRequiredService>(); var tasks = fetchers.Select(fetcher => RunFetcherByNameAsync(fetcher.SourceName, stoppingToken)); await Task.WhenAll(tasks); _logger.LogInformation("Ejecución inicial de todos los fetchers completada."); } #region Funciones de Ayuda para la Planificación /// /// Determina si se debe enviar una alerta o si está en período de silencio. /// private bool ShouldSendAlert(string taskName) { if (!_lastAlertSent.ContainsKey(taskName)) { return true; } var lastAlertTime = _lastAlertSent[taskName]; return DateTime.UtcNow.Subtract(lastAlertTime) > _alertSilencePeriod; } #endregion } }