diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor index df00311..e3a6efa 100644 --- a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor @@ -41,8 +41,8 @@ else
Runtime
-
4
-
Authentication, file processing, transaction generation, and polling.
+
5
+
Authentication, file processing, transaction generation, polling, and merchant scan.
Merchants
@@ -64,6 +64,11 @@ else
@snapshot.Options.FileStatusPolling.PollIntervalSeconds s
Polling cadence for completion checks.
+
+
Merchant scan
+
@snapshot.Options.MerchantScanIntervalSeconds s
+
How often the worker re-checks for new or changed merchants.
+
Transactions
@($"{snapshot.Options.TransactionGeneration.MinimumTransactionsPerContract}-{snapshot.Options.TransactionGeneration.MaximumTransactionsPerContract}")
diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor index a9165a9..3f1f742 100644 --- a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor @@ -128,8 +128,10 @@ else
+
+

Removing a merchant stops future scheduling only. If a run is already in progress, it will finish normally.

}
@@ -241,6 +243,11 @@ else return "Merchant saved."; } + if (query.Contains("removed=1", StringComparison.OrdinalIgnoreCase)) + { + return "Merchant removed."; + } + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) { var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor index f9ec76b..1ede5ad 100644 --- a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor @@ -37,6 +37,10 @@ else
Polling
@snapshot.Options.FileStatusPolling.PollIntervalSeconds seconds
+
+
Merchant scan
+
@snapshot.Options.MerchantScanIntervalSeconds seconds
+
@@ -80,7 +84,12 @@ else Polling interval seconds + +

Lower values make newly added or enabled merchants appear in the schedule faster. Higher values reduce how often the worker re-checks the configuration.

Back to configuration hub diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs index b66f377..d30c814 100644 --- a/TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/FileStatusPollingOptions.cs @@ -4,4 +4,4 @@ public sealed class FileStatusPollingOptions { public int PollIntervalSeconds { get; init; } = 30; public TimeSpan GetPollInterval() => TimeSpan.FromSeconds(this.PollIntervalSeconds); -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs index 86ed253..3924fb0 100644 --- a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs @@ -122,6 +122,7 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() var fileProcessing = await dbContext.MerchantProcessingFileProcessingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); var transactionGeneration = await dbContext.MerchantProcessingTransactionGenerationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); var fileStatusPolling = await dbContext.MerchantProcessingFileStatusPollingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); + var merchantScan = await dbContext.MerchantProcessingMerchantScanRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); var fileProfiles = await dbContext.MerchantProcessingFileProfileRecords.AsNoTracking() .OrderBy(record => record.SortOrder) @@ -135,13 +136,13 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() .OrderBy(record => record.SortOrder) .ToListAsync(cancellationToken); - if (authentication is null && fileProcessing is null && transactionGeneration is null && fileStatusPolling is null && + if (authentication is null && fileProcessing is null && transactionGeneration is null && fileStatusPolling is null && merchantScan is null && fileProfiles.Count == 0 && contractDefinitions.Count == 0 && merchants.Count == 0) { return null; } - if (authentication is null || fileProcessing is null || transactionGeneration is null || fileStatusPolling is null) + if (authentication is null || fileProcessing is null || transactionGeneration is null || fileStatusPolling is null || merchantScan is null) { return null; } @@ -173,7 +174,8 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() FileStatusPolling = new FileStatusPollingOptions { PollIntervalSeconds = fileStatusPolling.PollIntervalSeconds - } + }, + MerchantScanIntervalSeconds = merchantScan.MerchantScanIntervalSeconds }; options.FileProfiles.AddRange(fileProfiles.Select(record => @@ -271,6 +273,7 @@ private async Task HasNormalizedConfigurationAsync(MerchantFileProcessorDb await dbContext.MerchantProcessingFileProcessingRecords.AnyAsync(record => record.Id == 1, cancellationToken) || await dbContext.MerchantProcessingTransactionGenerationRecords.AnyAsync(record => record.Id == 1, cancellationToken) || await dbContext.MerchantProcessingFileStatusPollingRecords.AnyAsync(record => record.Id == 1, cancellationToken) || + await dbContext.MerchantProcessingMerchantScanRecords.AnyAsync(record => record.Id == 1, cancellationToken) || await dbContext.MerchantProcessingFileProfileRecords.AnyAsync(cancellationToken) || await dbContext.MerchantProcessingContractDefinitionRecords.AnyAsync(cancellationToken) || await dbContext.MerchantProcessingMerchantRecords.AnyAsync(cancellationToken); @@ -358,6 +361,13 @@ private static async Task PersistSingletonsAsync( UpdatedUtc = updatedUtc }); + dbContext.MerchantProcessingMerchantScanRecords.Add(new MerchantProcessingMerchantScanRecord + { + Id = 1, + MerchantScanIntervalSeconds = options.MerchantScanIntervalSeconds, + UpdatedUtc = updatedUtc + }); + await dbContext.SaveChangesAsync(cancellationToken); } @@ -514,6 +524,7 @@ private static async Task ClearConfigurationTablesAsync(MerchantFileProcessorDbC await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProcessingRecords;", cancellationToken); await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingTransactionGenerationRecords;", cancellationToken); await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileStatusPollingRecords;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingMerchantScanRecords;", cancellationToken); } private static DateTimeOffset MaxUtc(params IEnumerable?[] sources) diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs index ec5b917..55bbee1 100644 --- a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptions.cs @@ -11,9 +11,11 @@ public sealed class MerchantProcessingOptions { public FileStatusPollingOptions FileStatusPolling { get; init; } = new(); + public int MerchantScanIntervalSeconds { get; init; } = 5; + public List ContractDefinitions { get; init; } = []; public List FileProfiles { get; init; } = []; public List Merchants { get; init; } = []; -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs index 22feb77..1ef0d1b 100644 --- a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs @@ -24,6 +24,10 @@ public static bool Validate(MerchantProcessingOptions options) { return false; } + if (options.MerchantScanIntervalSeconds <= 0) { + return false; + } + if (options.ContractDefinitions.Count == 0) { return false; } @@ -78,4 +82,4 @@ private static bool AreFieldsValid(IEnumerable fields) { return true; } -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs index 1e3118d..3290a46 100644 --- a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs @@ -16,6 +16,8 @@ public sealed class MerchantFileProcessorDbContext(DbContextOptions MerchantProcessingFileStatusPollingRecords => this.Set(); + public DbSet MerchantProcessingMerchantScanRecords => this.Set(); + public DbSet MerchantProcessingFileProfileRecords => this.Set(); public DbSet MerchantProcessingFileProfileFieldRecords => this.Set(); @@ -104,6 +106,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(record => record.UpdatedUtc); }); + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingMerchantScanRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.UpdatedUtc); + }); + modelBuilder.Entity(entity => { entity.ToTable("MerchantProcessingFileProfiles"); diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs index 468b6f9..0413f0f 100644 --- a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs @@ -44,6 +44,15 @@ public sealed class MerchantProcessingFileStatusPollingRecord public DateTimeOffset UpdatedUtc { get; set; } } +public sealed class MerchantProcessingMerchantScanRecord +{ + public int Id { get; set; } + + public int MerchantScanIntervalSeconds { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + public sealed class MerchantProcessingFileProfileRecord { public int Id { get; set; } diff --git a/TransactionProcessing.MerchantFileProcessor/Program.cs b/TransactionProcessing.MerchantFileProcessor/Program.cs index f240934..cf35629 100644 --- a/TransactionProcessing.MerchantFileProcessor/Program.cs +++ b/TransactionProcessing.MerchantFileProcessor/Program.cs @@ -365,6 +365,16 @@ UpdatedUtc TEXT NOT NULL """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingMerchantScanRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingMerchantScanRecords PRIMARY KEY, + MerchantScanIntervalSeconds INTEGER NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( """ CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfiles ( diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs index 23e3ef8..a0dc5a3 100644 --- a/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs @@ -40,6 +40,7 @@ public static IEndpointRouteBuilder MapConfigurationManagementEndpoints(this IEn { PollIntervalSeconds = ParseInt(form["PollIntervalSeconds"], 30) }, + MerchantScanIntervalSeconds = ParseInt(form["MerchantScanIntervalSeconds"], 5), ContractDefinitions = options.ContractDefinitions, FileProfiles = options.FileProfiles, Merchants = options.Merchants @@ -70,6 +71,26 @@ public static IEndpointRouteBuilder MapConfigurationManagementEndpoints(this IEn var merchantIndex = options.Merchants.FindIndex(entry => string.Equals(entry.MerchantId, originalMerchantId ?? merchantId, StringComparison.OrdinalIgnoreCase)); + if (ParseBool(form["Delete"])) + { + if (merchantIndex < 0) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString("Merchant not found.")}"); + } + + options.Merchants.RemoveAt(merchantIndex); + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect("/ops/config/merchants?removed=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + } + var merchant = new MerchantOptions { Name = GetRequired(form, "Name"), @@ -308,6 +329,7 @@ private static MerchantProcessingOptions Clone(MerchantProcessingOptions options { PollIntervalSeconds = options.FileStatusPolling.PollIntervalSeconds }, + MerchantScanIntervalSeconds = options.MerchantScanIntervalSeconds, ContractDefinitions = options.ContractDefinitions .Select(contract => new ContractDefinitionOptions { diff --git a/TransactionProcessing.MerchantFileProcessor/Worker.cs b/TransactionProcessing.MerchantFileProcessor/Worker.cs index 375861a..e07e6d1 100644 --- a/TransactionProcessing.MerchantFileProcessor/Worker.cs +++ b/TransactionProcessing.MerchantFileProcessor/Worker.cs @@ -14,10 +14,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { MerchantProcessingOptions options = configurationState.Current; MerchantOptions[] enabledMerchants = options.Merchants.Where(merchant => merchant.Enabled).ToArray(); + TimeSpan scanInterval = TimeSpan.FromSeconds(Math.Max(1, options.MerchantScanIntervalSeconds)); if (enabledMerchants.Length == 0) { logger.LogWarning("No enabled merchants are configured for processing. Waiting before checking again."); - await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + try { + await Task.Delay(scanInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { + break; + } continue; } @@ -29,8 +35,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LocalLogger.Context merchantLogger = LocalLogger.For(scheduledMerchant.Merchant.MerchantId); merchantLogger.LogInformation($"Next merchant processing run scheduled at {nextRun:O} for slot {scheduledMerchant.ScheduledRunUtc:O}"); + if (delay > scanInterval) { + try { + await Task.Delay(scanInterval, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { + break; + } + continue; + } + if (delay > TimeSpan.Zero) { - await Task.Delay(delay, stoppingToken); + try { + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { + break; + } } if (stoppingToken.IsCancellationRequested) { diff --git a/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json b/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json index 333f2f7..5a32966 100644 --- a/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json +++ b/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json @@ -14,6 +14,7 @@ "FileStatusPolling": { "PollIntervalSeconds": 30 }, + "MerchantScanIntervalSeconds": 5, "ContractDefinitions": [ { "ContractId": "052A6CA5-47FC-4844-ADCA-7D44EFD8F21C", diff --git a/TransactionProcessing.MerchantPos/Pages/Config.cshtml b/TransactionProcessing.MerchantPos/Pages/Config.cshtml index 9a8b186..2a503c8 100644 --- a/TransactionProcessing.MerchantPos/Pages/Config.cshtml +++ b/TransactionProcessing.MerchantPos/Pages/Config.cshtml @@ -30,7 +30,9 @@
+
+

Smaller values check for added, enabled, disabled, or removed merchants more quickly. Larger values reduce how often the service re-scans the database.

diff --git a/TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs b/TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs index 2230db5..bfcbf5d 100644 --- a/TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs +++ b/TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs @@ -42,6 +42,7 @@ public async Task OnPostSaveAsync() settings.WorkerSettings.ClientSecret = UnmaskSecret(Config.ClientSecret, settings.WorkerSettings.ClientSecret); settings.WorkerSettings.ServiceClientId = Config.ServiceClientId; settings.WorkerSettings.ServiceClientSecret = UnmaskSecret(Config.ServiceClientSecret, settings.WorkerSettings.ServiceClientSecret); + settings.WorkerSettings.MerchantScanIntervalSeconds = Math.Max(1, Config.MerchantScanIntervalSeconds); settings.ConnectionStrings.MerchantDb = MerchantPosSettingsStore.NormalizePath(Config.MerchantDb); settings.ConnectionStrings.SettingsDb = MerchantPosSettingsStore.NormalizePath(Config.SettingsDb); @@ -66,6 +67,7 @@ public sealed class ConfigInput public string ClientSecret { get; set; } = string.Empty; public string ServiceClientId { get; set; } = string.Empty; public string ServiceClientSecret { get; set; } = string.Empty; + public int MerchantScanIntervalSeconds { get; set; } = 5; public string MerchantDb { get; set; } = string.Empty; public string SettingsDb { get; set; } = string.Empty; @@ -80,6 +82,7 @@ public static ConfigInput FromSnapshot(MerchantPosSettingsSnapshot snapshot) ClientSecret = snapshot.WorkerSettings.ClientSecret, ServiceClientId = snapshot.WorkerSettings.ServiceClientId, ServiceClientSecret = snapshot.WorkerSettings.ServiceClientSecret, + MerchantScanIntervalSeconds = snapshot.WorkerSettings.MerchantScanIntervalSeconds, MerchantDb = MerchantPosSettingsStore.NormalizePath(snapshot.ConnectionStrings.MerchantDb), SettingsDb = MerchantPosSettingsStore.NormalizePath(snapshot.ConnectionStrings.SettingsDb) }; diff --git a/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs b/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs index bf2d4fd..a4bc789 100644 --- a/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs +++ b/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs @@ -114,6 +114,9 @@ private static MerchantPosSettingsSnapshot MergeWithDefaults(MerchantPosSettings snapshot.WorkerSettings.ClientSecret = string.IsNullOrWhiteSpace(snapshot.WorkerSettings.ClientSecret) ? defaults.WorkerSettings.ClientSecret : snapshot.WorkerSettings.ClientSecret; snapshot.WorkerSettings.ServiceClientId = string.IsNullOrWhiteSpace(snapshot.WorkerSettings.ServiceClientId) ? defaults.WorkerSettings.ServiceClientId : snapshot.WorkerSettings.ServiceClientId; snapshot.WorkerSettings.ServiceClientSecret = string.IsNullOrWhiteSpace(snapshot.WorkerSettings.ServiceClientSecret) ? defaults.WorkerSettings.ServiceClientSecret : snapshot.WorkerSettings.ServiceClientSecret; + snapshot.WorkerSettings.MerchantScanIntervalSeconds = snapshot.WorkerSettings.MerchantScanIntervalSeconds <= 0 + ? defaults.WorkerSettings.MerchantScanIntervalSeconds + : snapshot.WorkerSettings.MerchantScanIntervalSeconds; if (snapshot.WorkerSettings.Merchants is null || snapshot.WorkerSettings.Merchants.Count == 0) { snapshot.WorkerSettings.Merchants = defaults.WorkerSettings.Merchants; diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs index bef7e63..117a6db 100644 --- a/TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs @@ -23,6 +23,7 @@ public void EnsureDefaults() WorkerSettings.ClientSecret ??= string.Empty; WorkerSettings.ServiceClientId ??= string.Empty; WorkerSettings.ServiceClientSecret ??= string.Empty; + WorkerSettings.MerchantScanIntervalSeconds = WorkerSettings.MerchantScanIntervalSeconds <= 0 ? 5 : WorkerSettings.MerchantScanIntervalSeconds; WorkerSettings.Merchants ??= new List(); ApiConfiguration ??= new ApiConfigurationSettings(); diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs index acea48d..854713e 100644 --- a/TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs @@ -46,6 +46,10 @@ public async Task RunAsync((String clientId, String clientSecret) serviceClient, await StartupSequence(config, cancellationToken); await RunMainLoop( config, cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } catch (Exception ex) { Logger.LogError($"Runtime crashed for merchant {config.MerchantName}. Restarting in 5s...", ex); @@ -297,4 +301,4 @@ private async Task DoReconciliation(MerchantConfig cfg, CancellationToken cancel this.Metrics.SetLastEndOfDay(cfg.MerchantId); } -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs b/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs index a873827..9d052d0 100644 --- a/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs +++ b/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs @@ -4,5 +4,6 @@ public class WorkerSettings public string ClientSecret { get; set; } = string.Empty; public string ServiceClientId { get; set; } = string.Empty; public string ServiceClientSecret { get; set; } = string.Empty; + public int MerchantScanIntervalSeconds { get; set; } = 5; public List Merchants { get; set; } = new(); } diff --git a/TransactionProcessing.MerchantPos/WorkerHost.cs b/TransactionProcessing.MerchantPos/WorkerHost.cs index 4f47b35..595ec1c 100644 --- a/TransactionProcessing.MerchantPos/WorkerHost.cs +++ b/TransactionProcessing.MerchantPos/WorkerHost.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Shared.Logger; using TransactionProcessing.MerchantPos.Runtime; @@ -5,6 +6,7 @@ public class WorkerHost : BackgroundService { private readonly IServiceProvider ServiceProvider; private readonly TransactionProcessing.MerchantPos.Persistence.MerchantPosSettingsStore SettingsStore; + private readonly ConcurrentDictionary RunningMerchantWorkers = new(); public WorkerHost(IServiceProvider serviceProvider, TransactionProcessing.MerchantPos.Persistence.MerchantPosSettingsStore settingsStore) { @@ -14,30 +16,108 @@ public WorkerHost(IServiceProvider serviceProvider, TransactionProcessing.Mercha protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var settings = SettingsStore.Current.WorkerSettings; - Logger.LogInformation($"WorkerHost starting; Merchant count: {settings.Merchants.Count}"); - - foreach (MerchantConfig m in settings.Merchants) + Logger.LogInformation($"WorkerHost starting; Merchant count: {SettingsStore.Current.WorkerSettings.Merchants.Count}"); + while (!stoppingToken.IsCancellationRequested) { - _ = StartMerchantWorker((settings.ServiceClientId, settings.ServiceClientSecret), (settings.ClientId, settings.ClientSecret), - m, stoppingToken); + SyncMerchantWorkers(stoppingToken); + + var scanIntervalSeconds = SettingsStore.Current.WorkerSettings.MerchantScanIntervalSeconds; + var delay = TimeSpan.FromSeconds(Math.Max(1, scanIntervalSeconds)); + try + { + await Task.Delay(delay, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } } + } - try + private void SyncMerchantWorkers(CancellationToken hostToken) + { + var settings = SettingsStore.Current.WorkerSettings; + var merchantsById = settings.Merchants.ToDictionary(merchant => merchant.MerchantId); + + foreach (var entry in RunningMerchantWorkers) { - await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + if (!merchantsById.TryGetValue(entry.Key, out var merchant) || !merchant.Enabled) + { + StopMerchantWorker(entry.Key, entry.Value); + } } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + + foreach (MerchantConfig merchant in settings.Merchants) { + if (!merchant.Enabled) + { + continue; + } + + if (RunningMerchantWorkers.ContainsKey(merchant.MerchantId)) + { + continue; + } + + StartMerchantWorker( + (settings.ServiceClientId, settings.ServiceClientSecret), + (settings.ClientId, settings.ClientSecret), + merchant, + hostToken); } } - private async Task StartMerchantWorker((String clientId, String clientSecret) serviceClient, (String clientId, String clientSecret) posClient, MerchantConfig merchant, CancellationToken token) + private void StartMerchantWorker((String clientId, String clientSecret) serviceClient, (String clientId, String clientSecret) posClient, MerchantConfig merchant, CancellationToken hostToken) { + var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(hostToken); MerchantRuntime runtime = this.ServiceProvider .GetRequiredService() .Create(merchant); - _ = Task.Run(() => runtime.RunAsync(serviceClient, posClient, merchant, token), token); + var workerTask = runtime.RunAsync(serviceClient, posClient, merchant, cancellationTokenSource.Token); + var workerState = new MerchantWorkerState(cancellationTokenSource, workerTask, merchant.MerchantName); + + if (!RunningMerchantWorkers.TryAdd(merchant.MerchantId, workerState)) + { + cancellationTokenSource.Cancel(); + cancellationTokenSource.Dispose(); + return; + } + + Logger.LogInformation($"Starting merchant worker for {merchant.MerchantName} ({merchant.MerchantId})"); + _ = MonitorMerchantWorkerAsync(merchant.MerchantId, workerState); + } + + private void StopMerchantWorker(Guid merchantId, MerchantWorkerState workerState) + { + if (!RunningMerchantWorkers.TryRemove(merchantId, out _)) + { + return; + } + + Logger.LogInformation($"Stopping merchant worker for {workerState.MerchantName} ({merchantId})"); + workerState.CancellationTokenSource.Cancel(); } + + private async Task MonitorMerchantWorkerAsync(Guid merchantId, MerchantWorkerState workerState) + { + try + { + await workerState.WorkerTask; + } + catch (OperationCanceledException) when (workerState.CancellationTokenSource.IsCancellationRequested) + { + } + catch (Exception ex) + { + Logger.LogError($"Merchant worker for {workerState.MerchantName} ({merchantId}) failed.", ex); + } + finally + { + RunningMerchantWorkers.TryRemove(merchantId, out _); + workerState.CancellationTokenSource.Dispose(); + } + } + + private sealed record MerchantWorkerState(CancellationTokenSource CancellationTokenSource, Task WorkerTask, string MerchantName); } diff --git a/TransactionProcessing.MerchantPos/appsettings.json b/TransactionProcessing.MerchantPos/appsettings.json index cde2477..59ffac6 100644 --- a/TransactionProcessing.MerchantPos/appsettings.json +++ b/TransactionProcessing.MerchantPos/appsettings.json @@ -4,6 +4,7 @@ "ClientSecret": "", "ServiceClientId": "", "ServiceClientSecret": "", + "MerchantScanIntervalSeconds": 5, "Merchants": [] }, diff --git a/TransactionProcessing.MerchantPos/appsettings.staging.json b/TransactionProcessing.MerchantPos/appsettings.staging.json index b652766..72b03e5 100644 --- a/TransactionProcessing.MerchantPos/appsettings.staging.json +++ b/TransactionProcessing.MerchantPos/appsettings.staging.json @@ -4,6 +4,7 @@ "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543", "ServiceClientId": "serviceClient", "ServiceClientSecret": "d192cbc46d834d0da90e8a9d50ded543", + "MerchantScanIntervalSeconds": 5, "Merchants": [ { "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22",