diff --git a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs index c23bfee9..fd5c3755 100644 --- a/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Orbit.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using System.Text; using FluentValidation; +using Hangfire; +using Hangfire.PostgreSql; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.EntityFrameworkCore; @@ -23,6 +25,7 @@ using Orbit.Application.Habits.Validators; using Orbit.Domain.Interfaces; using Orbit.Infrastructure.AI; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Configuration; using Orbit.Infrastructure.Persistence; using Orbit.Infrastructure.Services; @@ -341,6 +344,7 @@ public static WebApplicationBuilder AddOrbitInfrastructure(this WebApplicationBu .ConfigureHttpClient(c => c.Timeout = httpTimeout); builder.Services.AddMemoryCache(); + AddOrbitDistributedCache(builder); builder.Services.AddValidatorsFromAssemblyContaining(); @@ -444,6 +448,24 @@ private static void AddPushAndReferralServices(WebApplicationBuilder builder, Ti } private static void AddBackgroundServices(WebApplicationBuilder builder) + { + builder.Services.AddScoped(); + + var useDurableQueue = builder.Configuration.GetSection(BackgroundJobSettings.SectionName) + .Get()?.UseDurableQueue ?? false; + + builder.Services.AddHostedService(); + + if (useDurableQueue) + AddDurableRecurringJobs(builder); + else + AddInProcessSchedulers(builder); + + builder.Services.AddHealthChecks() + .AddCheck("background-services"); + } + + private static void AddInProcessSchedulers(WebApplicationBuilder builder) { builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -452,14 +474,67 @@ private static void AddBackgroundServices(WebApplicationBuilder builder) builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); - builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); - builder.Services.AddScoped(); + } - builder.Services.AddHealthChecks() - .AddCheck("background-services"); + private static void AddDurableRecurringJobs(WebApplicationBuilder builder) + { + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); + if (string.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException( + $"{BackgroundJobSettings.SectionName}:UseDurableQueue is true but ConnectionStrings:DefaultConnection is not configured."); + + builder.Services.AddHangfire(config => config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UsePostgreSqlStorage(postgres => postgres.UseNpgsqlConnection(connectionString))); + builder.Services.AddHangfireServer(); + + builder.Services.AddSingleton(); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + AddScheduledJob(builder); + + builder.Services.AddHostedService(); + } + + private static void AddScheduledJob(WebApplicationBuilder builder) + where TJob : class, IScheduledJob + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + } + + private static void AddOrbitDistributedCache(WebApplicationBuilder builder) + { + var redisSettings = builder.Configuration.GetSection(RedisCacheSettings.SectionName).Get() + ?? new RedisCacheSettings(); + + if (!redisSettings.Enabled) + { + builder.Services.AddDistributedMemoryCache(); + return; + } + + if (string.IsNullOrWhiteSpace(redisSettings.ConnectionString)) + throw new InvalidOperationException( + $"{RedisCacheSettings.SectionName}:Enabled is true but {RedisCacheSettings.SectionName}:ConnectionString is not configured."); + + builder.Services.AddStackExchangeRedisCache(options => + { + options.Configuration = redisSettings.ConnectionString; + options.InstanceName = redisSettings.InstanceName; + }); } private static void AddCorsPolicies(WebApplicationBuilder builder) diff --git a/src/Orbit.Api/Orbit.Api.csproj b/src/Orbit.Api/Orbit.Api.csproj index a490853d..53d401fe 100644 --- a/src/Orbit.Api/Orbit.Api.csproj +++ b/src/Orbit.Api/Orbit.Api.csproj @@ -11,7 +11,10 @@ + + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Orbit.Api/appsettings.json b/src/Orbit.Api/appsettings.json index 461d64bc..92817eba 100644 --- a/src/Orbit.Api/appsettings.json +++ b/src/Orbit.Api/appsettings.json @@ -78,7 +78,13 @@ "GoalDeadlineIntervalMinutes": 30, "SlipAlertIntervalMinutes": 5, "AccountDeletionIntervalHours": 24, - "DueDateAdvancementIntervalMinutes": 30 + "DueDateAdvancementIntervalMinutes": 30, + "UseDurableQueue": false + }, + "Redis": { + "Enabled": false, + "ConnectionString": "", + "InstanceName": "orbit:" }, "Sentry": { "Dsn": "", diff --git a/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs b/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs index 446d6961..79c8c19b 100644 --- a/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs +++ b/src/Orbit.Application/Profile/Commands/SetTimezoneCommand.cs @@ -29,7 +29,7 @@ public async Task Handle(SetTimezoneCommand request, CancellationToken c return result; await unitOfWork.SaveChangesAsync(cancellationToken); - userDateService.InvalidateUserDatePreferences(request.UserId); + await userDateService.InvalidateUserDatePreferencesAsync(request.UserId, cancellationToken); return Result.Success(); } diff --git a/src/Orbit.Application/Profile/Commands/SetWeekStartDayCommand.cs b/src/Orbit.Application/Profile/Commands/SetWeekStartDayCommand.cs index fb7c3da8..e3fb7e60 100644 --- a/src/Orbit.Application/Profile/Commands/SetWeekStartDayCommand.cs +++ b/src/Orbit.Application/Profile/Commands/SetWeekStartDayCommand.cs @@ -29,7 +29,7 @@ public async Task Handle(SetWeekStartDayCommand request, CancellationTok return result; await unitOfWork.SaveChangesAsync(cancellationToken); - userDateService.InvalidateUserDatePreferences(request.UserId); + await userDateService.InvalidateUserDatePreferencesAsync(request.UserId, cancellationToken); return Result.Success(); } diff --git a/src/Orbit.Domain/Interfaces/IUserDateService.cs b/src/Orbit.Domain/Interfaces/IUserDateService.cs index 902a23fd..5dffacf6 100644 --- a/src/Orbit.Domain/Interfaces/IUserDateService.cs +++ b/src/Orbit.Domain/Interfaces/IUserDateService.cs @@ -16,5 +16,5 @@ public interface IUserDateService /// from any command that mutates User.TimeZone or User.WeekStartDay, otherwise subsequent /// date math can lag by up to 15 minutes. /// - void InvalidateUserDatePreferences(Guid userId); + Task InvalidateUserDatePreferencesAsync(Guid userId, CancellationToken cancellationToken = default); } diff --git a/src/Orbit.Infrastructure/BackgroundJobs/HangfireRecurringJobRegistrar.cs b/src/Orbit.Infrastructure/BackgroundJobs/HangfireRecurringJobRegistrar.cs new file mode 100644 index 00000000..bae0b9ae --- /dev/null +++ b/src/Orbit.Infrastructure/BackgroundJobs/HangfireRecurringJobRegistrar.cs @@ -0,0 +1,37 @@ +using Hangfire; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Orbit.Infrastructure.BackgroundJobs; + +/// +/// Registers every as a Hangfire recurring job on startup when the +/// durable-queue flag is on. Each job is keyed by its stable name, so re-registering on every boot +/// reconciles cron changes without creating duplicates, and Hangfire's storage keeps the schedule +/// across restarts while its distributed lock ensures a single instance runs each occurrence. +/// +public sealed partial class HangfireRecurringJobRegistrar( + IRecurringJobManager recurringJobManager, + IEnumerable jobs, + ILogger logger) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + foreach (var job in jobs) + { + recurringJobManager.AddOrUpdate( + job.Name, + runner => runner.RunAsync(job.Name, CancellationToken.None), + job.CronExpression); + + LogJobRegistered(logger, job.Name, job.CronExpression); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Registered durable recurring job {JobName} with schedule {CronExpression}")] + private static partial void LogJobRegistered(ILogger logger, string jobName, string cronExpression); +} diff --git a/src/Orbit.Infrastructure/BackgroundJobs/IScheduledJob.cs b/src/Orbit.Infrastructure/BackgroundJobs/IScheduledJob.cs new file mode 100644 index 00000000..4a6977aa --- /dev/null +++ b/src/Orbit.Infrastructure/BackgroundJobs/IScheduledJob.cs @@ -0,0 +1,17 @@ +namespace Orbit.Infrastructure.BackgroundJobs; + +/// +/// A recurring background scan that can run either as an in-process polling loop or as a durable +/// Hangfire recurring job, selected by the BackgroundServices:UseDurableQueue flag. Each +/// scheduler exposes its stable (the Hangfire recurring-job id and lock key) and +/// the that mirrors its default in-process interval, while +/// performs one occurrence of the same work the polling loop runs each tick. +/// +public interface IScheduledJob +{ + string Name { get; } + + string CronExpression { get; } + + Task RunAsync(CancellationToken cancellationToken); +} diff --git a/src/Orbit.Infrastructure/BackgroundJobs/ScheduledJobRunner.cs b/src/Orbit.Infrastructure/BackgroundJobs/ScheduledJobRunner.cs new file mode 100644 index 00000000..cdf031b7 --- /dev/null +++ b/src/Orbit.Infrastructure/BackgroundJobs/ScheduledJobRunner.cs @@ -0,0 +1,18 @@ +namespace Orbit.Infrastructure.BackgroundJobs; + +/// +/// Single Hangfire entry point for every . Hangfire persists only the +/// job's in storage and resolves a fresh runner per execution, so +/// adding or renaming a job never changes the serialized recurring-job payload. The runner looks up +/// the matching job by name and executes one occurrence of its work. +/// +public sealed class ScheduledJobRunner(IEnumerable jobs) +{ + public Task RunAsync(string jobName, CancellationToken cancellationToken) + { + var job = jobs.FirstOrDefault(candidate => candidate.Name == jobName) + ?? throw new InvalidOperationException($"No scheduled job registered with name '{jobName}'."); + + return job.RunAsync(cancellationToken); + } +} diff --git a/src/Orbit.Infrastructure/Configuration/BackgroundJobSettings.cs b/src/Orbit.Infrastructure/Configuration/BackgroundJobSettings.cs new file mode 100644 index 00000000..dee070ac --- /dev/null +++ b/src/Orbit.Infrastructure/Configuration/BackgroundJobSettings.cs @@ -0,0 +1,16 @@ +namespace Orbit.Infrastructure.Configuration; + +/// +/// Background-processing rollout settings. When is false (default), +/// each recurring scheduler runs as its own in-process BackgroundService polling loop exactly +/// as before. When true, those recurring scans are registered as Hangfire recurring jobs backed by +/// PostgreSQL instead: occurrences survive restarts, a distributed lock prevents more than one +/// instance running the same occurrence, and failed runs retry with exponential backoff. The +/// one-shot startup data-encryption migration always runs as a hosted service regardless of this flag. +/// +public sealed class BackgroundJobSettings +{ + public const string SectionName = "BackgroundServices"; + + public bool UseDurableQueue { get; init; } +} diff --git a/src/Orbit.Infrastructure/Configuration/RedisCacheSettings.cs b/src/Orbit.Infrastructure/Configuration/RedisCacheSettings.cs new file mode 100644 index 00000000..efb601c6 --- /dev/null +++ b/src/Orbit.Infrastructure/Configuration/RedisCacheSettings.cs @@ -0,0 +1,18 @@ +namespace Orbit.Infrastructure.Configuration; + +/// +/// Distributed-cache rollout settings. When is false (default), the app +/// registers an in-process IDistributedCache and behaves exactly as before. When true, +/// the same IDistributedCache seam is backed by Redis so cached user-date preferences stay +/// consistent across multiple API instances. is required when enabled. +/// +public sealed class RedisCacheSettings +{ + public const string SectionName = "Redis"; + + public bool Enabled { get; init; } + + public string ConnectionString { get; init; } = ""; + + public string InstanceName { get; init; } = "orbit:"; +} diff --git a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj index da90ddd3..ad55ac07 100644 --- a/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj +++ b/src/Orbit.Infrastructure/Orbit.Infrastructure.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Orbit.Infrastructure/Services/AccountDeletionService.cs b/src/Orbit.Infrastructure/Services/AccountDeletionService.cs index 31619d60..4850eeff 100644 --- a/src/Orbit.Infrastructure/Services/AccountDeletionService.cs +++ b/src/Orbit.Infrastructure/Services/AccountDeletionService.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -11,11 +12,22 @@ namespace Orbit.Infrastructure.Services; public partial class AccountDeletionService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService + IConfiguration configuration) : BackgroundService, IScheduledJob { private readonly TimeSpan _interval = TimeSpan.FromHours( configuration.GetValue("BackgroundServices:AccountDeletionIntervalHours", 24)); + public string Name => "account-deletion"; + + public string CronExpression => "0 3 * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await ProcessScheduledDeletions(cancellationToken); + await CleanupStaleSentRecords(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("AccountDeletion"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs b/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs index 92fee453..5592d4f2 100644 --- a/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs +++ b/src/Orbit.Infrastructure/Services/BackgroundServiceHealthCheck.cs @@ -17,7 +17,8 @@ public class BackgroundServiceHealthCheck : IHealthCheck ["StreakFreezeAutoActivation"] = TimeSpan.FromMinutes(180), ["AccountDeletion"] = TimeSpan.FromHours(72), ["SyncCleanup"] = TimeSpan.FromHours(48), - ["PlayNotificationCleanup"] = TimeSpan.FromHours(48) + ["PlayNotificationCleanup"] = TimeSpan.FromHours(48), + ["CalendarAutoSync"] = TimeSpan.FromMinutes(45) }; public static void RecordTick(string serviceName) diff --git a/src/Orbit.Infrastructure/Services/CalendarAutoSyncService.cs b/src/Orbit.Infrastructure/Services/CalendarAutoSyncService.cs index 2b1520f2..a7925853 100644 --- a/src/Orbit.Infrastructure/Services/CalendarAutoSyncService.cs +++ b/src/Orbit.Infrastructure/Services/CalendarAutoSyncService.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Orbit.Application.Calendar.Commands; using Orbit.Domain.Enums; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -19,13 +20,23 @@ public partial class CalendarAutoSyncService( IServiceScopeFactory scopeFactory, ILogger logger, IConfiguration configuration, - TimeProvider timeProvider) : BackgroundService + TimeProvider timeProvider) : BackgroundService, IScheduledJob { private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:CalendarAutoSyncIntervalMinutes", 15)); private static readonly TimeSpan DedupeWindow = TimeSpan.FromHours(4); private const int BatchSize = 50; + public string Name => "calendar-auto-sync"; + + public string CronExpression => "*/15 * * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await ProcessTick(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("CalendarAutoSync"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs b/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs index af16f045..9fb13b16 100644 --- a/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs +++ b/src/Orbit.Infrastructure/Services/GoalDeadlineNotificationService.cs @@ -8,6 +8,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -15,13 +16,23 @@ namespace Orbit.Infrastructure.Services; public partial class GoalDeadlineNotificationService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService + IConfiguration configuration) : BackgroundService, IScheduledJob { private static readonly int[] NotifyDaysBefore = [7, 3, 1]; private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:GoalDeadlineIntervalMinutes", 30)); + public string Name => "goal-deadline-notification"; + + public string CronExpression => "*/30 * * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await CheckAndSendDeadlineNotifications(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("GoalDeadlineNotification"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/HabitDueDateAdvancementService.cs b/src/Orbit.Infrastructure/Services/HabitDueDateAdvancementService.cs index 6e692392..a1a64490 100644 --- a/src/Orbit.Infrastructure/Services/HabitDueDateAdvancementService.cs +++ b/src/Orbit.Infrastructure/Services/HabitDueDateAdvancementService.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Orbit.Application.Common; using Orbit.Domain.Entities; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -13,11 +14,21 @@ namespace Orbit.Infrastructure.Services; public partial class HabitDueDateAdvancementService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService + IConfiguration configuration) : BackgroundService, IScheduledJob { private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:DueDateAdvancementIntervalMinutes", 30)); + public string Name => "habit-due-date-advancement"; + + public string CronExpression => "*/30 * * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await AdvanceStaleDueDates(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("HabitDueDateAdvancement"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/PlayNotificationCleanupService.cs b/src/Orbit.Infrastructure/Services/PlayNotificationCleanupService.cs index 20f3d25b..2dadd5b6 100644 --- a/src/Orbit.Infrastructure/Services/PlayNotificationCleanupService.cs +++ b/src/Orbit.Infrastructure/Services/PlayNotificationCleanupService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -13,12 +14,22 @@ namespace Orbit.Infrastructure.Services; /// public partial class PlayNotificationCleanupService( IServiceScopeFactory scopeFactory, - ILogger logger) : BackgroundService + ILogger logger) : BackgroundService, IScheduledJob { private static readonly TimeSpan Interval = TimeSpan.FromHours(24); private static readonly TimeSpan PlayRetentionPeriod = TimeSpan.FromDays(30); private static readonly TimeSpan StripeRetentionPeriod = TimeSpan.FromDays(90); + public string Name => "play-notification-cleanup"; + + public string CronExpression => "0 4 * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await PurgeOldNotifications(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("PlayNotificationCleanup"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs index f90cf835..bf8ec614 100644 --- a/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/ReminderSchedulerService.cs @@ -8,6 +8,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -15,11 +16,21 @@ namespace Orbit.Infrastructure.Services; public partial class ReminderSchedulerService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService + IConfiguration configuration) : BackgroundService, IScheduledJob { private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:ReminderIntervalMinutes", 1)); + public string Name => "reminder-scheduler"; + + public string CronExpression => "* * * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await CheckAndSendReminders(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("ReminderScheduler"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs b/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs index 9b6f8081..499d5895 100644 --- a/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs +++ b/src/Orbit.Infrastructure/Services/SlipAlertSchedulerService.cs @@ -7,6 +7,7 @@ using Orbit.Application.Habits.Services; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -14,7 +15,7 @@ namespace Orbit.Infrastructure.Services; public partial class SlipAlertSchedulerService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService + IConfiguration configuration) : BackgroundService, IScheduledJob { private const int DefaultMorningHour = 8; private const int MaxTimeZoneSkewDays = 1; @@ -22,6 +23,16 @@ public partial class SlipAlertSchedulerService( private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:SlipAlertIntervalMinutes", 5)); + public string Name => "slip-alert-scheduler"; + + public string CronExpression => "*/5 * * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await CheckAndSendAlerts(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("SlipAlertScheduler"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs index 083903fa..223cd85f 100644 --- a/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs +++ b/src/Orbit.Infrastructure/Services/StreakFreezeAutoActivationService.cs @@ -6,6 +6,7 @@ using Orbit.Application.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -22,13 +23,23 @@ namespace Orbit.Infrastructure.Services; public partial class StreakFreezeAutoActivationService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService + IConfiguration configuration) : BackgroundService, IScheduledJob { private const int MaxTimeZoneSkewDays = 1; private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:StreakFreezeIntervalMinutes", 60)); + public string Name => "streak-freeze-auto-activation"; + + public string CronExpression => "0 * * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await ActivateMissedDayFreezes(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("StreakFreezeAutoActivation"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs b/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs index 27e28737..c3ac30ba 100644 --- a/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs +++ b/src/Orbit.Infrastructure/Services/StreakGoalSyncService.cs @@ -8,6 +8,7 @@ using Orbit.Domain.Entities; using Orbit.Domain.Enums; using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -25,11 +26,21 @@ namespace Orbit.Infrastructure.Services; public partial class StreakGoalSyncService( IServiceScopeFactory scopeFactory, ILogger logger, - IConfiguration configuration) : BackgroundService + IConfiguration configuration) : BackgroundService, IScheduledJob { private readonly TimeSpan _interval = TimeSpan.FromMinutes( configuration.GetValue("BackgroundServices:StreakGoalSyncIntervalMinutes", 60)); + public string Name => "streak-goal-sync"; + + public string CronExpression => "0 * * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await SyncActiveStreakGoals(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("StreakGoalSync"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/SyncCleanupService.cs b/src/Orbit.Infrastructure/Services/SyncCleanupService.cs index 3c89d594..841085a6 100644 --- a/src/Orbit.Infrastructure/Services/SyncCleanupService.cs +++ b/src/Orbit.Infrastructure/Services/SyncCleanupService.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Orbit.Application.Common; +using Orbit.Infrastructure.BackgroundJobs; using Orbit.Infrastructure.Persistence; namespace Orbit.Infrastructure.Services; @@ -15,13 +16,23 @@ namespace Orbit.Infrastructure.Services; /// public partial class SyncCleanupService( IServiceScopeFactory scopeFactory, - ILogger logger) : BackgroundService + ILogger logger) : BackgroundService, IScheduledJob { private static readonly TimeSpan Interval = TimeSpan.FromHours(24); private static readonly TimeSpan RetentionPeriod = TimeSpan.FromDays(AppConstants.MaxSyncWindowDays + AppConstants.SyncCleanupMarginDays); private static readonly TimeSpan SuggestionRetentionPeriod = TimeSpan.FromDays(14); + public string Name => "sync-cleanup"; + + public string CronExpression => "30 3 * * *"; + + public async Task RunAsync(CancellationToken cancellationToken) + { + await PurgeSoftDeletedEntities(cancellationToken); + BackgroundServiceHealthCheck.RecordTick("SyncCleanup"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LogServiceStarted(logger); diff --git a/src/Orbit.Infrastructure/Services/UserDateService.cs b/src/Orbit.Infrastructure/Services/UserDateService.cs index 42306b16..68f05af8 100644 --- a/src/Orbit.Infrastructure/Services/UserDateService.cs +++ b/src/Orbit.Infrastructure/Services/UserDateService.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Caching.Memory; +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; using Orbit.Application.Common; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -7,8 +8,13 @@ namespace Orbit.Infrastructure.Services; public class UserDateService( IGenericRepository userRepository, - IMemoryCache cache) : IUserDateService + IDistributedCache cache) : IUserDateService { + private static readonly DistributedCacheEntryOptions CacheEntryOptions = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15) + }; + private record UserDatePreferences(string? TimeZone, int WeekStartDay); private static string CacheKey(Guid userId) => $"user-tz:{userId}"; @@ -29,15 +35,22 @@ public async Task GetUserWeekStartDayAsync(Guid userId, CancellationToken c private async Task GetPreferencesAsync(Guid userId, CancellationToken cancellationToken) { var cacheKey = CacheKey(userId); - if (!cache.TryGetValue(cacheKey, out UserDatePreferences? preferences) || preferences is null) + var cached = await cache.GetStringAsync(cacheKey, cancellationToken); + if (cached is not null) { - var user = await userRepository.GetByIdAsync(userId, cancellationToken); - preferences = new UserDatePreferences(user?.TimeZone, user?.WeekStartDay ?? 1); - cache.Set(cacheKey, preferences, TimeSpan.FromMinutes(15)); + var deserialized = JsonSerializer.Deserialize(cached); + if (deserialized is not null) + return deserialized; } + var user = await userRepository.GetByIdAsync(userId, cancellationToken); + var preferences = new UserDatePreferences(user?.TimeZone, user?.WeekStartDay ?? 1); + await cache.SetStringAsync( + cacheKey, JsonSerializer.Serialize(preferences), CacheEntryOptions, cancellationToken); + return preferences; } - public void InvalidateUserDatePreferences(Guid userId) => cache.Remove(CacheKey(userId)); + public Task InvalidateUserDatePreferencesAsync(Guid userId, CancellationToken cancellationToken = default) => + cache.RemoveAsync(CacheKey(userId), cancellationToken); } diff --git a/tests/Orbit.Application.Tests/Commands/Profile/SetWeekStartDayCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Profile/SetWeekStartDayCommandHandlerTests.cs index 4b626fa6..299e2571 100644 --- a/tests/Orbit.Application.Tests/Commands/Profile/SetWeekStartDayCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Profile/SetWeekStartDayCommandHandlerTests.cs @@ -52,7 +52,7 @@ public async Task Handle_ValidDay_UpdatesAndSaves() result.IsSuccess.Should().BeTrue(); user.WeekStartDay.Should().Be(0); await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); - _userDateService.Received(1).InvalidateUserDatePreferences(UserId); + await _userDateService.Received(1).InvalidateUserDatePreferencesAsync(UserId, Arg.Any()); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs new file mode 100644 index 00000000..a40e6f29 --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRegistryTests.cs @@ -0,0 +1,94 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Orbit.Application.Common; +using Orbit.Domain.Interfaces; +using Orbit.Infrastructure.BackgroundJobs; +using Orbit.Infrastructure.Persistence; +using Orbit.Infrastructure.Services; + +namespace Orbit.Infrastructure.Tests.BackgroundJobs; + +/// +/// Guards the durable-queue registry: every recurring scheduler exposes the +/// contract Hangfire registration depends on, and the job names that key Hangfire's storage are unique +/// so re-registration never silently overwrites (loses) a job. +/// +public class ScheduledJobRegistryTests +{ + private static readonly IConfiguration EmptyConfiguration = new ConfigurationBuilder().Build(); + + public static IEnumerable AllScheduledJobs() => + BuildAll().Select(job => new object[] { job }); + + [Theory] + [MemberData(nameof(AllScheduledJobs))] + public void Job_HasNameAndCronExpression(IScheduledJob job) + { + job.Name.Should().NotBeNullOrWhiteSpace(); + job.CronExpression.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void JobNames_AreUnique() + { + var names = BuildAll().Select(job => job.Name).ToList(); + + names.Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void AllTenRecurringSchedulers_AreRegisteredAsJobs() + { + BuildAll().Should().HaveCount(10); + } + + [Fact] + public async Task RunAsync_ExecutesUnderlyingScan_WithoutDoubleRunningSideEffects() + { + await using var dbContext = NewDbContext(); + var pushService = Substitute.For(); + var scopeFactory = ScopeFactoryFor(dbContext, pushService); + + var reminder = new ReminderSchedulerService( + scopeFactory, NullLogger.Instance, EmptyConfiguration); + + await ((IScheduledJob)reminder).RunAsync(CancellationToken.None); + + await pushService.DidNotReceive().SendToUserAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + private static List BuildAll() => + [ + new ReminderSchedulerService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new GoalDeadlineNotificationService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new SlipAlertSchedulerService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new AccountDeletionService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new HabitDueDateAdvancementService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new StreakGoalSyncService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new StreakFreezeAutoActivationService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration), + new SyncCleanupService(ScopeFactory(), NullLogger.Instance), + new PlayNotificationCleanupService(ScopeFactory(), NullLogger.Instance), + new CalendarAutoSyncService(ScopeFactory(), NullLogger.Instance, EmptyConfiguration, TimeProvider.System), + ]; + + private static IServiceScopeFactory ScopeFactory() => Substitute.For(); + + private static OrbitDbContext NewDbContext() => + new(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"ScheduledJobRegistryTests_{Guid.NewGuid()}") + .Options); + + private static IServiceScopeFactory ScopeFactoryFor(OrbitDbContext dbContext, IPushNotificationService pushService) + { + var provider = new ServiceCollection() + .AddSingleton(dbContext) + .AddSingleton(pushService) + .BuildServiceProvider(); + return provider.GetRequiredService(); + } +} diff --git a/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRunnerTests.cs b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRunnerTests.cs new file mode 100644 index 00000000..a5d9c9ca --- /dev/null +++ b/tests/Orbit.Infrastructure.Tests/BackgroundJobs/ScheduledJobRunnerTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using Orbit.Infrastructure.BackgroundJobs; + +namespace Orbit.Infrastructure.Tests.BackgroundJobs; + +public class ScheduledJobRunnerTests +{ + [Fact] + public async Task RunAsync_DispatchesToJobMatchingName() + { + var first = new FakeScheduledJob("alpha"); + var second = new FakeScheduledJob("beta"); + var runner = new ScheduledJobRunner([first, second]); + + await runner.RunAsync("beta", CancellationToken.None); + + second.RunCount.Should().Be(1); + first.RunCount.Should().Be(0); + } + + [Fact] + public async Task RunAsync_UnknownName_Throws() + { + var runner = new ScheduledJobRunner([new FakeScheduledJob("alpha")]); + + var act = () => runner.RunAsync("missing", CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("*missing*"); + } + + [Fact] + public async Task RunAsync_ForwardsCancellationToken() + { + var job = new FakeScheduledJob("alpha"); + var runner = new ScheduledJobRunner([job]); + using var cts = new CancellationTokenSource(); + + await runner.RunAsync("alpha", cts.Token); + + job.ObservedToken.Should().Be(cts.Token); + } + + private sealed class FakeScheduledJob(string name) : IScheduledJob + { + public string Name => name; + public string CronExpression => "* * * * *"; + public int RunCount { get; private set; } + public CancellationToken ObservedToken { get; private set; } + + public Task RunAsync(CancellationToken cancellationToken) + { + RunCount++; + ObservedToken = cancellationToken; + return Task.CompletedTask; + } + } +} diff --git a/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs b/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs index 3bf526da..bf46241b 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/UserDateServiceTests.cs @@ -1,5 +1,7 @@ using FluentAssertions; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using NSubstitute; using Orbit.Domain.Entities; using Orbit.Domain.Interfaces; @@ -10,7 +12,7 @@ namespace Orbit.Infrastructure.Tests.Services; public class UserDateServiceTests { private readonly IGenericRepository _userRepo = Substitute.For>(); - private readonly MemoryCache _cache = new(new MemoryCacheOptions()); + private readonly IDistributedCache _cache = NewDistributedCache(); private readonly UserDateService _sut; private static readonly Guid UserId = Guid.NewGuid(); @@ -72,4 +74,42 @@ public async Task GetUserTodayAsync_CachesTimezoneOnFirstCall() await _userRepo.Received(1).GetByIdAsync(UserId, Arg.Any()); } + + [Fact] + public async Task GetUserWeekStartDayAsync_AfterInvalidation_RereadsFromRepository() + { + var user = User.Create("Test", "test@test.com").Value; + user.SetWeekStartDay(0); + _userRepo.GetByIdAsync(UserId, Arg.Any()) + .Returns(user); + + (await _sut.GetUserWeekStartDayAsync(UserId)).Should().Be(0); + + await _sut.InvalidateUserDatePreferencesAsync(UserId); + await _sut.GetUserWeekStartDayAsync(UserId); + + await _userRepo.Received(2).GetByIdAsync(UserId, Arg.Any()); + } + + [Fact] + public async Task GetUserWeekStartDayAsync_SecondInstanceSharingCache_ReadsCachedValueWithoutRepository() + { + var user = User.Create("Test", "test@test.com").Value; + user.SetWeekStartDay(0); + _userRepo.GetByIdAsync(UserId, Arg.Any()) + .Returns(user); + + await _sut.GetUserWeekStartDayAsync(UserId); + + var secondInstanceRepo = Substitute.For>(); + var secondInstance = new UserDateService(secondInstanceRepo, _cache); + + var result = await secondInstance.GetUserWeekStartDayAsync(UserId); + + result.Should().Be(0); + await secondInstanceRepo.DidNotReceive().GetByIdAsync(UserId, Arg.Any()); + } + + private static IDistributedCache NewDistributedCache() => + new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); }