Skip to content

Scheduling — One-Shot Future Actions

Granit.Scheduling provides a generic mechanism for scheduling one-shot actions at a specific future date. Unlike Granit.BackgroundJobs (recurring cron), scheduled actions execute exactly once at the specified time, carry a typed payload, and support cancellation and rescheduling. The module handles persistence, Wolverine integration, atomic claim to prevent double-execution, a catch-up safety net for lost messages, and a full audit trail.

  • DirectoryGranit.Scheduling/ Core: IScheduler, IScheduledPayload, ScheduledAction, Diagnostics
    • Granit.Scheduling.EntityFrameworkCore EF Core store (SQL Server / PostgreSQL)
    • Granit.Scheduling.Endpoints Minimal API admin endpoints (RBAC + QueryEngine)
    • Granit.Scheduling.Wolverine Durable ScheduleSend, atomic claim middleware
    • Granit.Scheduling.BackgroundJobs Catch-up safety net for overdue actions
PackageRoleDepends on
Granit.SchedulingIScheduler, IScheduledPayload, ScheduledAction aggregate, metrics, type registryGranit, Granit.Timing, Granit.Guids
Granit.Scheduling.EntityFrameworkCoreSchedulingDbContext, EfScheduledActionStore, atomic claimGranit.Scheduling, Granit.Persistence
Granit.Scheduling.EndpointsAdmin API with QueryEngine pagination + RBACGranit.Scheduling, Granit.Authorization, Granit.QueryEngine.AspNetCore
Granit.Scheduling.WolverineWolverineScheduler, ScheduledActionStatusMiddlewareGranit.Scheduling, Granit.Wolverine
Granit.Scheduling.BackgroundJobsSchedulingCatchUpJob (every 6h)Granit.Scheduling, Granit.BackgroundJobs, Granit.Scheduling.Wolverine
graph TD
    S[Granit.Scheduling] --> C[Granit]
    S --> T[Granit.Timing]
    S --> G[Granit.Guids]
    EF[Granit.Scheduling.EntityFrameworkCore] --> S
    EF --> P[Granit.Persistence]
    EP[Granit.Scheduling.Endpoints] --> S
    EP --> A[Granit.Authorization]
    EP --> Q[Granit.QueryEngine.AspNetCore]
    W[Granit.Scheduling.Wolverine] --> S
    W --> WM[Granit.Wolverine]
    BJ[Granit.Scheduling.BackgroundJobs] --> S
    BJ --> B[Granit.BackgroundJobs]
    BJ --> W
[DependsOn(
typeof(GranitSchedulingEntityFrameworkCoreModule),
typeof(GranitSchedulingWolverineModule),
typeof(GranitSchedulingEndpointsModule),
typeof(GranitSchedulingBackgroundJobsModule))]
public class AppModule : GranitModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Builder.AddGranitSchedulingEntityFrameworkCore(
options => options.UseNpgsql(
context.Configuration.GetConnectionString("Scheduling")));
}
}

Map the admin endpoints:

app.MapGranitScheduling();

A payload is a sealed record implementing IScheduledPayload. It is serialized to JSON and delivered to its Wolverine handler at execution time.

// 1. Define the payload
public sealed record ApplyPlanChangePayload(
Guid TenantId,
Guid SubscriptionId,
Guid NewPlanId) : IScheduledPayload;
// 2. Write the handler (standard Wolverine handler)
public static class ApplyPlanChangeHandler
{
public static async Task HandleAsync(
ApplyPlanChangePayload payload,
ISubscriptionWriter subscriptionWriter,
CancellationToken ct)
{
await subscriptionWriter.ChangePlanAsync(
SubscriptionId.Create(payload.SubscriptionId),
PlanId.Create(payload.NewPlanId),
ct);
}
}
public sealed class SubscriptionService(IScheduler scheduler)
{
// Schedule a plan change for the future
public async Task<ScheduledActionId> ScheduleUpgradeAsync(
Guid subscriptionId, Guid newPlanId, DateTimeOffset upgradeDate, CancellationToken ct)
{
return await scheduler.ScheduleAsync(
new ApplyPlanChangePayload(currentTenant.Id!.Value, subscriptionId, newPlanId),
upgradeDate,
correlationId: $"subscription:{subscriptionId}",
ct);
}
// Cancel if the customer changes their mind
public Task CancelUpgradeAsync(ScheduledActionId actionId, CancellationToken ct) =>
scheduler.CancelAsync(actionId, ct);
// Reschedule to a different date
public Task RescheduleUpgradeAsync(
ScheduledActionId actionId, DateTimeOffset newDate, CancellationToken ct) =>
scheduler.RescheduleAsync(actionId, newDate, ct);
}
stateDiagram-v2
    [*] --> Pending: ScheduleAsync()
    Pending --> Cancelled: CancelAsync()
    Pending --> Processing: TryClaimForExecution (atomic)
    Pending --> Pending: RescheduleAsync()
    Processing --> Executed: Handler succeeds
    Processing --> Failed: Handler throws (retries exhausted)
    Cancelled --> [*]
    Executed --> [*]
    Failed --> [*]

The Processing state is transitional: it prevents double-execution when concurrent dispatches (catch-up job + original Wolverine message) target the same action. The claim is atomic via ExecuteUpdateAsync with a WHERE Status = Pending guard.

The ScheduledPayloadTypeRegistry scans all loaded assemblies at startup and builds an allowlist of concrete IScheduledPayload implementations. When deserializing a payload (reschedule, catch-up), the registry validates the type name against this allowlist — preventing confused-deputy attacks via crafted PayloadType strings.

Message brokers may lose scheduled messages over long periods (months). The SchedulingCatchUpJob runs every 6 hours and re-dispatches overdue pending actions:

[RecurringJob("0 */6 * * *", "scheduling-catch-up")]
Query: Status == Pending AND ExecuteAt < now - 5min
Action: re-dispatch payload via Wolverine (immediate, with ActionId header)

The ScheduledActionStatusMiddleware prevents double-execution: the atomic claim ensures only one dispatch wins, regardless of whether it comes from the original schedule or the catch-up job.

MethodRoutePermissionDescription
GET/api/granit/scheduling/{id}Scheduling.Actions.ReadGet action by ID
GET/api/granit/scheduling/queryScheduling.Actions.ReadPaginated list (QueryEngine)
DELETE/api/granit/scheduling/{id}Scheduling.Actions.ManageCancel a pending action
PUT/api/granit/scheduling/{id}/rescheduleScheduling.Actions.ManageReschedule to new date
MetricTypeTagsDescription
granit.scheduling.action.scheduledCountertenant_id, payload_typeActions scheduled
granit.scheduling.action.executedCountertenant_id, payload_typeActions executed successfully
granit.scheduling.action.cancelledCountertenant_idActions cancelled
granit.scheduling.action.failedCountertenant_id, payload_typeActions failed
granit.scheduling.action.rescheduledCountertenant_idActions rescheduled
granit.scheduling.catchup.redispatchedCountertenant_id, payload_typeCatch-up re-dispatched

ActivitySource Granit.Scheduling with operations: Schedule, Cancel, Reschedule, Execute, CatchUp.

Table prefix: scheduling_ (configurable via GranitSchedulingDbProperties.DbTablePrefix).

ColumnTypeNotes
IdGUIDPrimary key
PayloadTypevarchar(500)Assembly-qualified CLR type name
PayloadJsontextJSON-serialized payload
ExecuteAtdatetimeoffsetScheduled execution time (UTC)
CorrelationIdvarchar(500)Optional domain entity link
Statusint0=Pending, 1=Executed, 2=Cancelled, 3=Failed, 4=Processing
ExecutedAtdatetimeoffset?Completion timestamp
CancelledByvarchar(450)User who cancelled
FailureReasonvarchar(500)Truncated error message
TenantIdGUID?Multi-tenant isolation
CreatedAtdatetimeoffsetAudit: creation time
CreatedByvarchar(450)Audit: creator
ModifiedAtdatetimeoffset?Audit: last modification
ModifiedByvarchar(450)Audit: last modifier

Indexes:

  • ix_scheduling_scheduled_actions_status_execute_at — catch-up queries
  • ix_scheduling_scheduled_actions_correlation_id — domain entity lookup