Pulse — Scheduled Messages

Fire commands and events on cron-scheduled intervals using Nimbus.Extensions.Pulse

Overview

Nimbus.Extensions.Pulse fires commands and events on cron-scheduled intervals. It is designed for recurring background work — hourly reports, health checks, cache warming, cleanup tasks — without needing a separate scheduler process.

The pulse engine starts and stops with the bus lifecycle, so there is nothing extra to manage.

If you run more than one instance of your application, see Running multiple instances — by default every instance fires every schedule.

Installation

dotnet add package Nimbus.Extensions.Pulse

To coordinate schedules across multiple instances, also install a coordinator:

dotnet add package Nimbus.Extensions.Pulse.Redis

Configuration

Add .WithPulse(...) to your bus builder chain, passing one or more (cronExpression, message) tuples. It must come after all other configuration — it returns a PulseEnabledBusBuilderConfiguration, and you call .Build() on that:

var bus = new BusBuilder()
    .Configure()
    .WithTransport(...)
    .WithNames("MyApp", Environment.MachineName)
    .WithTypesFrom(typeProvider)
    .WithPulse(
        ("0 * * * *",   new HourlyReportCommand()),
        ("*/5 * * * *", new HealthCheckEvent())
    )
    .Build();

Each message must implement either IBusCommand or IBusEvent. The type is detected automatically:

  • Commands are dispatched via bus.Send() — single competing consumer
  • Events are dispatched via bus.Publish() — all subscribers receive a copy

The message you configure is a template. Pulse copies it on every fire, so the instance you passed in is never the one handed to the bus.

Naming schedules

There is also a builder form, which lets you name a schedule explicitly:

    .WithPulse(p => p
        .Add("0 * * * *", new HourlyReportCommand())
        .Add("0 3 * * *", new NightlyRollupCommand(), name: "rollup")
        .Add("0 4 * * *", new NightlyRollupCommand(), name: "rollup-retry"))

A schedule’s name is what identifies it to the coordinator. Left unnamed, it is derived from the message type and cron expression — MyApp.Messages.HourlyReportCommand:0_*_*_*_*. Name a schedule explicitly when you either:

  • run the same message type on the same cron expression twice, which would otherwise derive the same name for both, or
  • want to change a cron expression without the schedule’s identity changing along with it.

Two schedules that resolve to the same name are rejected when the bus is built, since they would contend for the same claim and only one would ever fire. The comparison ignores case, so a name stays unambiguous on coordinators whose storage is case-insensitive.

IPulseMessage

If you need the scheduled tick time inside your handler, implement IPulseMessage on your message type:

using Nimbus.Extensions.Pulse;
using Nimbus.MessageContracts;

public class HourlyReportCommand : IBusCommand, IPulseMessage
{
    public DateTimeOffset PulseTime { get; set; }
}

When the pulse fires, PulseTime is set to the nominal scheduled occurrence from the cron expression — the time it should have fired, not the wall-clock time it actually fired. This gives handlers a clean, jitter-free value suitable for idempotency checks, log correlation, or grouping work by time bucket.

public class HourlyReportCommandHandler : IHandleCommand<HourlyReportCommand>
{
    public async Task Handle(HourlyReportCommand command)
    {
        var reportPeriod = command.PulseTime; // e.g. 2026-05-29T14:00:00+00:00
        // ...
    }
}

IPulseMessage is optional. Messages that don’t implement it are fired normally with no modification.

Cron expressions

Expressions are parsed by Cronos. Pulse accepts two formats and picks between them by counting fields:

FieldsOrderResolution
5minute, hour, day-of-month, month, day-of-weekMinute
6second, minute, hour, day-of-month, month, day-of-weekSecond
ExpressionFires
* * * * *Every minute
*/5 * * * *Every 5 minutes
0 * * * *Every hour (on the hour)
0 9 * * 1-59 AM on weekdays
0 0 * * *Midnight every day
0 0 1 * *Midnight on the 1st of each month
*/30 * * * * *Every 30 seconds
* * * * * *Every second

Occurrences are always evaluated in UTC.

Second-resolution schedules put proportional load on your coordinator — every instance attempts a claim on every occurrence. * * * * * * across six instances is six claims per second, indefinitely. Prefer the coarsest interval that does the job.

Running multiple instances

By default, every running instance fires every schedule. Three instances with an hourly report schedule produce three reports an hour.

Configure a coordinator to prevent that. Instances then race for each occurrence and exactly one wins:

using Nimbus.Extensions.Pulse.Redis.Configuration;

var bus = new BusBuilder()
    .Configure()
    .WithTransport(...)
    .WithNames("MyApp", Environment.MachineName)
    .WithTypesFrom(typeProvider)
    .WithPulse(("0 * * * *", new HourlyReportCommand()))
    .WithRedisCoordinator("localhost:6379")
    .Build();

That is the whole change — nothing about your schedules or handlers differs.

The coordinator is independent of your transport. A bus running on Azure Service Bus, RabbitMQ or NATS can use the Redis coordinator; it only needs a Redis server it can reach.

How claims work

Claims are made per occurrence, rather than by electing a long-lived leader. Each instance works out the next occurrence of a schedule, and at that moment tries to claim the pair of (schedule name, scheduled time). The winner fires it; the others go back to sleep.

This matters most when something goes wrong. There is no leader to lose and no lease to renew, so an instance dying costs you nothing — the next occurrence is claimed by whichever instances are still up. There is no window during which nothing fires.

The Redis coordinator implements this as a single SET key owner NX PX ttl. Keys look like:

pulse:MyApp:MyApp.Messages.HourlyReportCommand:0_*_*_*_*:639212581800000000

MyApp is the application name from .WithNames(...), so separate applications never contend with each other while instances of the same application do. The trailing number is the occurrence in UTC ticks, which keeps the key byte-identical across instances running in different time zones.

Claim lifetime

Claims expire, defaulting to one hour:

    .WithRedisCoordinator("localhost:6379", claimTimeToLive: TimeSpan.FromHours(6))

A claim only needs to outlive the spread between instances reaching the same occurrence — clock skew plus scheduling lag — after which Redis discards the key on its own. An instance whose clock is far enough behind that it arrives after the claim has expired will fire that occurrence a second time, so set this comfortably above the worst-case skew across your fleet.

When the coordinator is unavailable

If the coordinator cannot be reached, Pulse skips the occurrence and logs an error. It does not fire.

That is deliberate. The alternative — firing when the claim cannot be checked — means an outage produces one message per instance, which is the exact failure the coordinator exists to prevent. A missed occurrence shows up in your logs; a duplicate charge or duplicate email does not.

Schedules do not run while the coordinator is unreachable. If a missed occurrence is worse for you than a duplicated one, implement IPulseCoordinator yourself and return true on failure.

Even with a coordinator, idempotent handlers are worth having. A claim guarantees a single send, but transports deliver at least once, so a handler can still see the same message twice.

Next Steps

  • Commands — how IBusCommand and competing consumers work
  • Events — how IBusEvent and multicast delivery work
  • Error Handling — dead letter queues and retry policies