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; // Expresiones Cron private readonly CronExpression _agroSchedule; private readonly CronExpression _bcrSchedule; private readonly CronExpression _bolsasSchedule; private readonly CronExpression _holidaysSchedule; // Próximas ejecuciones private DateTime? _nextAgroRun; private DateTime? _nextBcrRun; private DateTime? _nextBolsasRun; private DateTime? _nextHolidaysRun; private readonly Dictionary _lastAlertSent = new(); private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); // Eliminamos IHolidayService del constructor 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"]!); _holidaysSchedule = CronExpression.Parse(configuration["Schedules:Holidays"]!); } /// /// 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); // La ejecución inicial sigue comentada // 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); _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); // Usamos un PeriodicTimer que "despierta" cada 30 segundos. using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken)) { utcNow = DateTime.UtcNow; var nowInArgentina = TimeZoneInfo.ConvertTimeFromUtc(utcNow, _argentinaTimeZone); // Tarea de actualización de Feriados (semanal) if (_nextHolidaysRun.HasValue && utcNow >= _nextHolidaysRun.Value) { _logger.LogInformation("Ejecutando tarea semanal de actualización de feriados."); await RunFetcherByNameAsync("Holidays", stoppingToken); _nextHolidaysRun = _holidaysSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } // Tarea de Mercado Agroganadero (diaria) if (_nextAgroRun.HasValue && utcNow >= _nextAgroRun.Value) { // Comprueba si NO es feriado en Argentina para ejecutar if (!await IsMarketHolidayAsync("BA", nowInArgentina)) { await RunFetcherByNameAsync("MercadoAgroganadero", stoppingToken); } else { _logger.LogInformation("Ejecución de MercadoAgroganadero omitida por ser feriado."); } // Recalcula la próxima ejecución sin importar si corrió o fue feriado _nextAgroRun = _agroSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } // Tarea de Granos BCR (diaria) if (_nextBcrRun.HasValue && utcNow >= _nextBcrRun.Value) { if (!await IsMarketHolidayAsync("BA", nowInArgentina)) { await RunFetcherByNameAsync("BCR", stoppingToken); } else { _logger.LogInformation("Ejecución de BCR omitida por ser feriado."); } _nextBcrRun = _bcrSchedule.GetNextOccurrence(utcNow, _argentinaTimeZone); } // Tarea de Bolsas (recurrente) if (_nextBolsasRun.HasValue && utcNow >= _nextBolsasRun.Value) { _logger.LogInformation("Ventana de ejecución para Bolsas detectada."); var bolsaTasks = new List(); // Comprueba el mercado local (Argentina) if (!await IsMarketHolidayAsync("BA", nowInArgentina)) { bolsaTasks.Add(RunFetcherByNameAsync("YahooFinance", stoppingToken)); } else { _logger.LogInformation("Ejecución de YahooFinance (Mercado Local) omitida por ser feriado."); } // Comprueba el mercado de EEUU if (!await IsMarketHolidayAsync("US", nowInArgentina)) { bolsaTasks.Add(RunFetcherByNameAsync("Finnhub", stoppingToken)); } else { _logger.LogInformation("Ejecución de Finnhub (Mercado EEUU) omitida por ser feriado."); } // Si hay alguna tarea para ejecutar, las lanza en paralelo if (bolsaTasks.Any()) { _logger.LogInformation("Iniciando {Count} fetcher(s) de bolsa en paralelo...", bolsaTasks.Count); await Task.WhenAll(bolsaTasks); } _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 // Creamos una única función para comprobar feriados que obtiene el servicio // desde un scope. private async Task IsMarketHolidayAsync(string marketCode, DateTime date) { using var scope = _serviceProvider.CreateScope(); var holidayService = scope.ServiceProvider.GetRequiredService(); return await holidayService.IsMarketHolidayAsync(marketCode, date); } } }