Add scheduled Columbia catalog sync background service
Phase 4: automation. ColumbiaCatalogSyncBackgroundService wakes hourly and runs a full sync only when ColumbiaSyncEnabled is on and ColumbiaSyncIntervalDays has elapsed since the last successful run (tracked via the ColumbiaLastSyncedAt setting). No-ops quietly when disabled or unconfigured. The hourly due-check is negligible; the actual sync runs at most once per interval. Sync failures are recorded on the result/settings, never thrown, so a bad run can't kill the loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
using System.Globalization;
|
||||
using PowderCoating.Application.Constants;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Runs the Columbia Coatings catalog sync on a schedule. Wakes hourly and triggers a full sync
|
||||
/// only when the master switch (<c>ColumbiaSyncEnabled</c>) is on and the configured interval
|
||||
/// (<c>ColumbiaSyncIntervalDays</c>) has elapsed since the last successful run. A full sync is
|
||||
/// cheap (~25 API calls), so an hourly due-check is negligible; the actual work runs at most once
|
||||
/// per interval. No-ops quietly when disabled or unconfigured.
|
||||
/// </summary>
|
||||
public class ColumbiaCatalogSyncBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<ColumbiaCatalogSyncBackgroundService> _logger;
|
||||
|
||||
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(1);
|
||||
private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Uses <see cref="IServiceScopeFactory"/> because a <see cref="BackgroundService"/> is a
|
||||
/// singleton and the sync service / platform settings are scoped.
|
||||
/// </summary>
|
||||
public ColumbiaCatalogSyncBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<ColumbiaCatalogSyncBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("ColumbiaCatalogSyncBackgroundService started.");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(StartupDelay, stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await RunIfDueAsync(stoppingToken);
|
||||
await Task.Delay(CheckInterval, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutting down — expected.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the enable switch and the elapsed interval, and runs a sync when due. Failures from
|
||||
/// the sync itself are reported on its result (and recorded in platform settings) rather than
|
||||
/// thrown, so a bad run never tears down the loop.
|
||||
/// </summary>
|
||||
private async Task RunIfDueAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var settings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
|
||||
|
||||
if (!await settings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled))
|
||||
return; // master switch off
|
||||
|
||||
var intervalDays = Math.Max(1, await settings.GetIntAsync(
|
||||
ColumbiaIntegrationConstants.SettingIntervalDays,
|
||||
ColumbiaIntegrationConstants.DefaultSyncIntervalDays));
|
||||
|
||||
if (!IsDue(await settings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt), intervalDays))
|
||||
return; // synced recently enough
|
||||
|
||||
var sync = scope.ServiceProvider.GetRequiredService<IColumbiaCatalogSyncService>();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Columbia scheduled sync starting (interval {Days}d).", intervalDays);
|
||||
var result = await sync.RunSyncAsync(ct);
|
||||
|
||||
if (result.Success)
|
||||
_logger.LogInformation("Columbia scheduled sync complete: {Summary}", result.Summary);
|
||||
else
|
||||
_logger.LogWarning("Columbia scheduled sync did not succeed: {Error}", result.ErrorMessage);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Columbia scheduled sync threw unexpectedly.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sync is due when there is no recorded last-sync timestamp, or the configured number of
|
||||
/// days has elapsed since it. An unparseable timestamp is treated as "due".
|
||||
/// </summary>
|
||||
private static bool IsDue(string? lastSyncedRaw, int intervalDays)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lastSyncedRaw))
|
||||
return true;
|
||||
|
||||
if (!DateTime.TryParse(lastSyncedRaw, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind, out var lastSynced))
|
||||
return true;
|
||||
|
||||
return DateTime.UtcNow - lastSynced.ToUniversalTime() >= TimeSpan.FromDays(intervalDays);
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,7 @@ builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||
builder.Services.AddHostedService<ColumbiaCatalogSyncBackgroundService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||
|
||||
Reference in New Issue
Block a user