Skip to content

Scheduling

Platform supports two kinds of scheduling. Scheduling of an event and recurring schedules.

Event Scheduling

Event Scheduling is a database-driven delayed event publisher. As a consumer, you publish a message with a future scheduled time, and the platform stores it in the platform.MessageOutbox table. A background job (MessageOutboxHostedService) later reads due rows, publishes them to RabbitMQ, and removes them from the table once publishing succeeds.

What you need to know in practice:

Publishing is transactional, so the feature guarantees at-least-once delivery.
The outbox worker checks for due messages at least every 60 seconds, and may wake up earlier if it sees a message that should be published sooner.
Publishing is sequential per worker, so higher throughput comes from running multiple instances.
The area using the feature must have a database and a "Default" connection string.
The feature must be enabled with messagebus-config.UseRabbitMqForSchedule, and the service must load MessageOutboxModule, preferably just after MinorityMessageBusModule.
If scheduling is enabled but the setup is incomplete, publishing a scheduled event fails at runtime.
DB migrations create and update the required outbox table and stored procedures as part of normal platform package upgrades.

Logs & Metrics

Only the EOUT when the event is actually sent is logged. Successful calls to ScheduleEvent(...) or successful DB calls by the MessageOutboxModule are not logged.

The metric minority.outbox.messages is emitted. It can be grouped by message_name and is_delayed in addition to the normal tags like area-name. is_delayed as true counts events with a ScheduledTime in the past, false count when it is in the future.

Note that in order to query the total count, you need to take the max by (message_name, is_delayed, area_name), and then sum by whatever of those three you want to group by. E.g. sum(max:minority.outbox.messages{$env, $app-area} by {app-area,message_name,is_delayed}, { app-area })

Example

In the code:

// In the ServiceModule: 
protected override void Load(ContainerBuilder builder)
{
    ...
    builder.RegisterModule<MinorityMessageBusModule>();
    builder.RegisterModule<MessageOutboxModule>();
    ...
}


// In some method, to schedule an event of type EventToBeScheduled 2 minutes in the future 
var eventId = await _messagePublisher.ScheduleEvent(new EventToBeScheduled { }, DateTimeOffset.UtcNow.AddMinutes(2));

Recurring Scheduling

RecurringScheduling is a database-driven background scheduler. As a consumer, you do three things: register the module, implement a callback, and create a row in the platform.RecurringSchedule table whose ScheduleName matches the callback registration. The runtime then polls for due rows, claims one with a lease, runs your callback, and moves the schedule to the next cron occurrence.

Your callback name must match the database row’s ScheduleName exactly, otherwise the run is marked failed.
The schedule is controlled from the table, the columns CronExpression, IsEnabled, and NextRunTime decide when it runs.
Only one instance should execute a schedule at a time. The feature uses a lease plus heartbeat to avoid duplicate execution across nodes.
Your callback should be short-lived and idempotent. If the worker loses the lease, the cancellation token is cancelled and your code is expected to stop cleanly.
On success, LastRunTime is updated and LastError is cleared. On failure, LastError is saved and the next run is still advanced to the next cron time, so it does not immediately retry.

Schedules configuration should be version controled, e.g. handled by migration sql scripts in the Db project of the area.

Example

In a SQL migration script:

-- SQL to insert the schedule called areapreifxinlowercase.name-of-schedule, here configured to run every 5 minutes:
IF
    NOT EXISTS (
        SELECT 1 FROM [platform].[RecurringSchedule]
        WHERE [ScheduleName] = 'areapreifxinlowercase.name-of-schedule'
    )
    BEGIN
        INSERT INTO [platform].[RecurringSchedule] ([ScheduleName], [CronExpression], [NextRunTime])
        VALUES ('areapreifxinlowercase.name-of-schedule', '*/5 * * * *', sysutcdatetime());
    END
GO

The resulting table:

RecurringScheduleId ScheduleName CronExpression IsEnabled NextRunTime LastRunTime LastRunRuid LastError LeaseOwner LeaseExpires Created Updated
1 areapreifxinlowercase.name-of-schedule */5 * * * * 1 2026-04-02 07:00:00.0000000 2026-04-02 06:55:00.0300581 6e1c2e6e-0873-48ed-93d5-90693eb52142 NULL NULL NULL 2026-03-23 08:59:08.9201404 2026-04-02 06:55:00.0093067

In the code:

// The callback class
internal sealed class TheCallbackClass : IRecurringCallback
{
    public const string ScheduleName = "areapreifxinlowercase.name-of-schedule";

    public async Task Execute(DateTimeOffset occurrenceUtc, CancellationToken cancellationToken = default)
    {
        // implementation of the scheduled job goes here
        ...
    }
}


// In the ServiceModule: 
protected override void Load(ContainerBuilder builder)
{
    ...
    builder.RegisterRecurringCallback<TheCallbackClass>(TheCallbackClass.ScheduleName);
    ...
}