refinement phase 7
This commit is contained in:
-53
@@ -1,53 +0,0 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Booking;
|
||||
|
||||
/// <summary>
|
||||
/// The recurring expiry sweep for <c>booking_requests</c> (reuses the b1 in-process interval-runner seam;
|
||||
/// real Hangfire/Quartz is deferred). Each tick sends <see cref="ExpireBookingRequestsCommand"/>, which
|
||||
/// transitions stale rows (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded,
|
||||
/// idempotent batches. The interval is short because the payment window is only 30 minutes; there is no b1
|
||||
/// interval config key for booking expiry, so it is a documented constant.
|
||||
/// </summary>
|
||||
internal sealed class BookingRequestExpiryHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<BookingRequestExpiryHostedService> logger) : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await SweepSafely(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await SweepSafely(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task SweepSafely(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
|
||||
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
|
||||
logger.LogInformation(
|
||||
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
|
||||
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Host is shutting down — expected, don't log as an error.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Booking-request expiry sweep failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Notifications;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It
|
||||
/// periodically hard-deletes read notifications older than the retention window; unread notifications are
|
||||
/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here.
|
||||
/// </summary>
|
||||
internal sealed class NotificationRetentionHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<NotificationRetentionHostedService> logger) : BackgroundService
|
||||
{
|
||||
private const int RetentionDays = 90;
|
||||
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Run once at startup, then on the interval.
|
||||
await PurgeSafely(stoppingToken);
|
||||
|
||||
using var timer = new PeriodicTimer(Interval);
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
await PurgeSafely(stoppingToken);
|
||||
}
|
||||
|
||||
private async Task PurgeSafely(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
|
||||
if (removed > 0)
|
||||
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Host is shutting down — expected, don't log as an error.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Notification retention purge failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// One recurring background job driven by <see cref="RecurringJobSchedulerHostedService"/>. Each
|
||||
/// implementation re-homes a previously admin-manual (or hardcoded-interval) sweep behind the same seam so
|
||||
/// the platform runs itself: the scheduler owns the loop, the per-tick DI scope, the distributed lock, and
|
||||
/// the error handling, while the job only says <em>how often</em> it runs and <em>what</em> one idempotent
|
||||
/// run does. Every run must be idempotent — a scheduler retry (or a second instance once the lock is
|
||||
/// Redis-backed) must never double-pay or double-post; the DB uniques/state-machines are the backstop.
|
||||
/// </summary>
|
||||
internal interface IRecurringJob
|
||||
{
|
||||
/// <summary>Stable identifier — used for the distributed-lock key (<c>scheduler:{Name}</c>) and log scope.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolves this job's run interval, read fresh each tick (usually from a <c>platform_configs</c> cadence
|
||||
/// key via <see cref="Baya.Application.Contracts.Configuration.IPlatformConfig"/>) so an admin cadence
|
||||
/// change takes effect without a restart. <paramref name="services"/> is the per-tick scoped provider.
|
||||
/// </summary>
|
||||
ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Executes one idempotent run within the provided per-tick DI scope.</summary>
|
||||
ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Auto-expires stale <c>booking_requests</c> (re-homed from the b8 <c>BookingRequestExpiryHostedService</c>).
|
||||
/// Each run sends <see cref="ExpireBookingRequestsCommand"/>, which transitions stale rows
|
||||
/// (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded, idempotent batches. The interval
|
||||
/// is a short constant because the payment window is only 30 minutes; there is no cadence config key for it.
|
||||
/// Also reachable via the admin manual trigger.
|
||||
/// </summary>
|
||||
internal sealed class BookingRequestExpiryJob(ILogger<BookingRequestExpiryJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "booking_request_expiry";
|
||||
|
||||
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(TimeSpan.FromMinutes(1));
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
|
||||
logger.LogInformation(
|
||||
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
|
||||
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Scans for lapsed time-limited verification steps (criminal-record especially), reverting each to expired,
|
||||
/// raising a renewal alert/notification, and re-gating bookability. Previously admin-manual
|
||||
/// (<c>admin_verifications/scan_expiring</c>) — now scheduled on the <c>verification_expiry_scan_cadence_hours</c>
|
||||
/// cadence; the admin trigger remains an override. Sends <see cref="ScanExpiringCredentialsCommand"/> (idempotent).
|
||||
/// </summary>
|
||||
internal sealed class CredentialExpiryScanJob(ILogger<CredentialExpiryScanJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "verification_expiry_scan";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var hours = await config.GetConfig<int>("verification_expiry_scan_cadence_hours", cancellationToken);
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new ScanExpiringCredentialsCommand(), cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { } scan && scan.RevertedNurses > 0)
|
||||
logger.LogInformation(
|
||||
"Credential-expiry scan reverted {Nurses} nurse(s) across {Steps} expired step(s)",
|
||||
scan.RevertedNurses, scan.ScannedSteps);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Flags booking sessions whose scheduled start passed with no EVV check-in as no-shows. Previously admin-manual
|
||||
/// (<c>admin_evv/detect_no_shows</c>) — now scheduled on the <c>no_show_scan_cadence_hours</c> cadence; the admin
|
||||
/// trigger remains an override. Sends <see cref="DetectNoShowSessionsCommand"/> (idempotent — an already-flagged
|
||||
/// session is not re-flagged).
|
||||
/// </summary>
|
||||
internal sealed class NoShowSweepJob(ILogger<NoShowSweepJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "no_show_sweep";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var hours = await config.GetConfig<int>("no_show_scan_cadence_hours", cancellationToken);
|
||||
return TimeSpan.FromHours(hours);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
var result = await sender.Send(new DetectNoShowSessionsCommand(), cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { Missed: > 0 } sweep)
|
||||
logger.LogInformation("No-show sweep flagged {Missed} missed session(s)", sweep.Missed);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Notifications;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Hard-deletes read notifications older than the retention window (re-homed from the b1
|
||||
/// <c>NotificationRetentionHostedService</c>). Unread notifications are never deleted. Retention window and
|
||||
/// cadence are documented constants (no config key).
|
||||
/// </summary>
|
||||
internal sealed class NotificationRetentionJob(ILogger<NotificationRetentionJob> logger) : IRecurringJob
|
||||
{
|
||||
private const int RetentionDays = 90;
|
||||
|
||||
public string Name => "notification_retention";
|
||||
|
||||
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
=> ValueTask.FromResult(TimeSpan.FromHours(24));
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var notifications = services.GetRequiredService<INotificationService>();
|
||||
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
|
||||
|
||||
if (removed > 0)
|
||||
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Configuration;
|
||||
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
|
||||
using Mediator;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Generates the weekly nurse-payout batch (previously admin-manual — <c>nurse_payout_interval_days</c> was seeded
|
||||
/// but nothing read it, so nurses were paid only when an operator clicked). Scheduled on that cadence, it opens a
|
||||
/// <c>draft</c> batch over the trailing window; the admin generate trigger remains an override.
|
||||
///
|
||||
/// <para><b>Generation only — never money movement.</b> Per the phase's "keep processing human-approved until trust
|
||||
/// is earned", this schedules <em>batch generation</em>; the irreversible <c>process</c> step stays an explicit
|
||||
/// admin action. The batch is <b>system-initiated</b> (<see cref="GeneratePayoutBatchCommand.SystemInitiated"/> →
|
||||
/// null initiator). A quiet week (no eligible bookings) returns a benign failure, logged at debug — not an error.
|
||||
/// Re-running over an overlapping window is safe: the <c>nurse_payout_booking_links.booking_id</c> UNIQUE prevents
|
||||
/// re-selecting an already-paid booking.</para>
|
||||
/// </summary>
|
||||
internal sealed class WeeklyPayoutGenerationJob(ILogger<WeeklyPayoutGenerationJob> logger) : IRecurringJob
|
||||
{
|
||||
public string Name => "weekly_payout_generation";
|
||||
|
||||
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var days = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
|
||||
return TimeSpan.FromDays(days);
|
||||
}
|
||||
|
||||
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var clock = services.GetRequiredService<IDateTimeProvider>();
|
||||
var config = services.GetRequiredService<IPlatformConfig>();
|
||||
var sender = services.GetRequiredService<ISender>();
|
||||
|
||||
var intervalDays = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
|
||||
var periodEnd = DateOnly.FromDateTime(clock.UtcNow.UtcDateTime);
|
||||
var periodStart = periodEnd.AddDays(-Math.Max(intervalDays, 1));
|
||||
|
||||
var result = await sender.Send(
|
||||
new GeneratePayoutBatchCommand(periodStart, periodEnd) { SystemInitiated = true }, cancellationToken);
|
||||
|
||||
if (result.IsSuccess && result.Result is { } generated)
|
||||
logger.LogInformation(
|
||||
"Weekly payout generation opened a draft batch of {Count} payout(s) totalling {Total} IRR (awaiting admin process)",
|
||||
generated.Batch.PayoutCount, generated.Batch.TotalAmount);
|
||||
else
|
||||
// Expected on a quiet week (no eligible bookings) — not an operational error.
|
||||
logger.LogDebug("Weekly payout generation produced no batch this run (no payout-eligible bookings).");
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// The single in-process job scheduler (refinement-phase-7): it drives every registered <see cref="IRecurringJob"/>
|
||||
/// on its own cadence, replacing the two hand-written <c>PeriodicTimer</c> hosted services and giving the four
|
||||
/// previously admin-manual sweeps (credential-expiry scan, EVV no-show sweep, weekly payout-batch generation,
|
||||
/// and — Phase 8 — the Moadian reconciliation poll) a schedule. It intentionally uses <b>no new infrastructure</b>:
|
||||
/// SQL Server stays the only external dependency, so a single-instance MVP needs neither Hangfire/Quartz nor Redis.
|
||||
///
|
||||
/// <para><b>Multi-instance readiness.</b> Each tick runs under <see cref="IDistributedLock"/>
|
||||
/// (<c>scheduler:{Name}</c>). Today that lock is in-process (a no-op across instances); the moment a second API
|
||||
/// instance runs it becomes the scale-out gate — swapping the lock seam to Redis serializes ticks across nodes.
|
||||
/// Because every job is idempotent and the DB uniques/state-machines are authoritative, even a double-run is safe.</para>
|
||||
///
|
||||
/// <para><b>Not started under the "Testing" environment</b> so integration tests (WebApplicationFactory over
|
||||
/// in-memory SQLite) stay deterministic — no background sweep mutates rows mid-assertion. Each job's underlying
|
||||
/// command/service is unit-tested directly instead.</para>
|
||||
/// </summary>
|
||||
internal sealed class RecurringJobSchedulerHostedService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEnumerable<IRecurringJob> jobs,
|
||||
IDistributedLock distributedLock,
|
||||
IHostEnvironment environment,
|
||||
ILogger<RecurringJobSchedulerHostedService> logger) : BackgroundService
|
||||
{
|
||||
// Floor so a mis-set cadence key (0 or negative) can never turn a loop into a hot spin.
|
||||
private static readonly TimeSpan MinInterval = TimeSpan.FromSeconds(10);
|
||||
// Used when a job's interval resolution throws (e.g. the config store is briefly unreachable).
|
||||
private static readonly TimeSpan FallbackInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (environment.IsEnvironment("Testing"))
|
||||
return;
|
||||
|
||||
var jobList = jobs.ToArray();
|
||||
logger.LogInformation("Recurring job scheduler starting with {Count} job(s): {Jobs}",
|
||||
jobList.Length, string.Join(", ", jobList.Select(j => j.Name)));
|
||||
|
||||
// One independent loop per job so their cadences don't couple. A crash in one loop never stops the others.
|
||||
await Task.WhenAll(jobList.Select(job => RunJobLoopAsync(job, stoppingToken)));
|
||||
}
|
||||
|
||||
private async Task RunJobLoopAsync(IRecurringJob job, CancellationToken stoppingToken)
|
||||
{
|
||||
// Run once at startup, then on the job's own (re-read) cadence.
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var interval = await TickAndResolveIntervalAsync(job, stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(interval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; // Host is shutting down.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TimeSpan> TickAndResolveIntervalAsync(IRecurringJob job, CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var services = scope.ServiceProvider;
|
||||
|
||||
await RunTickAsync(job, services, stoppingToken);
|
||||
return await ResolveIntervalAsync(job, services, stoppingToken);
|
||||
}
|
||||
|
||||
private async Task RunTickAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var handle = await distributedLock.AcquireAsync($"scheduler:{job.Name}", stoppingToken);
|
||||
await job.RunAsync(services, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Host is shutting down — expected, don't log as an error.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failing tick must never kill the loop — the next tick retries on schedule.
|
||||
logger.LogError(ex, "Recurring job {Job} failed", job.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TimeSpan> ResolveIntervalAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var interval = await job.GetIntervalAsync(services, stoppingToken);
|
||||
return interval < MinInterval ? MinInterval : interval;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Recurring job {Job} interval resolution failed; using fallback {Fallback}", job.Name, FallbackInterval);
|
||||
return FallbackInterval;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user