Edit

Share via


Timers and reminders

The Orleans runtime provides two mechanisms, timers and reminders, that enable you to specify periodic behavior for grains.

Timers

Use timers to create periodic grain behavior that isn't required to span multiple activations (instantiations of the grain). A timer is identical to the standard .NET System.Threading.Timer class. Additionally, timers are subject to single-threaded execution guarantees within the grain activation they operate on.

Each activation can have zero or more timers associated with it. The runtime executes each timer routine within the runtime context of its associated activation.

Timer usage

To start a timer, use the RegisterGrainTimer method, which returns an IGrainTimer reference:

protected IGrainTimer RegisterGrainTimer<TState>(
    Func<TState, CancellationToken, Task> callback, // function invoked when the timer ticks
    TState state,                                   // object to pass to callback
    GrainTimerCreationOptions options)              // timer creation options
  • callback: The callback function that receives the state and a CancellationToken that is canceled when the timer is disposed or the grain deactivates.
  • state: The state object passed to the callback.
  • options: A GrainTimerCreationOptions instance that configures timer behavior.

To cancel the timer, dispose of it.

A timer stops triggering if the grain deactivates or when a fault occurs and its silo crashes.

GrainTimerCreationOptions

The GrainTimerCreationOptions structure provides the following properties:

Property Type Default Description
DueTime TimeSpan Required The amount of time to delay before invoking the callback. Use TimeSpan.Zero to start immediately, or Timeout.InfiniteTimeSpan to prevent the timer from starting.
Period TimeSpan Required The time interval between invocations of the callback. Use Timeout.InfiniteTimeSpan to disable periodic signaling (one-shot timer).
Interleave bool false When true, timer callbacks can interleave with other timers and grain calls. When false, callbacks are treated like grain calls and don't interleave (unless the grain is reentrant).
KeepAlive bool false When true, timer callbacks extend the grain activation's lifetime, preventing idle collection. When false, timer callbacks don't prevent grain collection.

Example: Creating a grain timer

public class MyGrain : Grain, IMyGrain
{
    private IGrainTimer? _timer;

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        // Create a timer that fires every 10 seconds, starting 5 seconds after activation
        // RegisterGrainTimer is an extension method on IGrainBase (which Grain implements)
        _timer = this.RegisterGrainTimer(
            static (state, ct) => state.DoWorkAsync(ct),
            this,
            new GrainTimerCreationOptions
            {
                DueTime = TimeSpan.FromSeconds(5),
                Period = TimeSpan.FromSeconds(10),
                KeepAlive = true  // Prevent grain collection while timer is active
            });

        return Task.CompletedTask;
    }

    private Task DoWorkAsync(CancellationToken cancellationToken)
    {
        // Timer callback work
        return Task.CompletedTask;
    }
}

Important considerations:

  • When activation collection is enabled, executing a timer callback doesn't change the activation's state from idle to in-use by default. Set KeepAlive to true if you want timer callbacks to prevent deactivation.
  • The period passed to RegisterGrainTimer is the amount of time passing from the moment the Task returned by callback resolves to the moment the next invocation of callback should occur. This not only prevents successive calls to callback from overlapping but also means the time callback takes to complete affects the frequency at which callback is invoked. This is an important deviation from the semantics of System.Threading.Timer.
  • Each invocation of callback is delivered to an activation on a separate turn and never runs concurrently with other turns on the same activation.
  • Callbacks don't interleave by default. You can enable interleaving by setting Interleave to true.
  • You can update grain timers using the Change method on the returned IGrainTimer instance.
  • Callbacks can keep the grain active, preventing collection if the timer period is relatively short. Enable this by setting KeepAlive to true.
  • Callbacks can receive a CancellationToken that is canceled when the timer is disposed or the grain starts to deactivate.
  • Callbacks can dispose of the grain timer that fired them.
  • Callbacks are subject to grain call filters.
  • Callbacks are visible in distributed tracing when distributed tracing is enabled.
  • POCO grains (grain classes that don't inherit from Grain) can register grain timers using the RegisterGrainTimer extension method.

Important

The RegisterTimer API is obsolete starting in Orleans 8.2. If you're upgrading to Orleans 8.0 or later, migrate to the new RegisterGrainTimer API. See the migration section for details.

To start a timer, use the Grain.RegisterTimer method, which returns an IDisposable reference:

protected IDisposable RegisterTimer(
    Func<object, Task> asyncCallback, // function invoked when the timer ticks
    object state,                     // object to pass to asyncCallback
    TimeSpan dueTime,                 // time to wait before the first timer tick
    TimeSpan period)                  // the period of the timer

To cancel the timer, dispose of it.

A timer stops triggering if the grain deactivates or when a fault occurs and its silo crashes.

Important considerations:

  • When activation collection is enabled, executing a timer callback doesn't change the activation's state from idle to in-use. This means you can't use a timer to postpone the deactivation of otherwise idle activations.
  • The period passed to Grain.RegisterTimer is the amount of time passing from the moment the Task returned by asyncCallback resolves to the moment the next invocation of asyncCallback should occur. This not only prevents successive calls to asyncCallback from overlapping but also means the time asyncCallback takes to complete affects the frequency at which asyncCallback is invoked. This is an important deviation from the semantics of System.Threading.Timer.
  • Each invocation of asyncCallback is delivered to an activation on a separate turn and never runs concurrently with other turns on the same activation.
  • Timer callbacks can interleave with other grain calls and timers.

Migrate from RegisterTimer to RegisterGrainTimer

If you're upgrading from Orleans 7.x to Orleans 8.x or later, you should migrate from the obsolete RegisterTimer API to the new RegisterGrainTimer API.

Key differences

Aspect RegisterTimer (Orleans 7.x) RegisterGrainTimer (Orleans 8.x+)
Interleaving Callbacks interleave by default Callbacks do not interleave by default
Return type IDisposable IGrainTimer
Callback signature Func<object, Task> Func<TState, CancellationToken, Task> (receives CancellationToken)
State type object (untyped) TState (strongly typed)
CancellationToken Not supported Supported via CancellationToken, canceled on disposal or deactivation
Updatable No Yes, via Change method
KeepAlive option Not supported Supported via KeepAlive, prevents grain collection
Call filters Not subject to filters Subject to grain call filters
Distributed tracing Not visible Visible in distributed tracing

Migration example

Before (Orleans 7.x):

public class MyGrain : Grain, IMyGrain
{
    private IDisposable? _timer;

    public override Task OnActivateAsync()
    {
        _timer = RegisterTimer(
            DoWorkAsync,
            null,
            TimeSpan.FromSeconds(5),
            TimeSpan.FromSeconds(10));

        return base.OnActivateAsync();
    }

    private Task DoWorkAsync(object state)
    {
        // Timer work - this interleaves with other calls
        return Task.CompletedTask;
    }
}

After (Orleans 8.x+):

public class MyGrainAfter : Grain, IMyGrain
{
    private IGrainTimer? _timer;

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        // Use this.RegisterGrainTimer() - an extension method on IGrainBase
        _timer = this.RegisterGrainTimer(
            static (state, ct) => state.DoWorkAsync(ct),
            this,
            new GrainTimerCreationOptions
            {
                DueTime = TimeSpan.FromSeconds(5),
                Period = TimeSpan.FromSeconds(10),
                Interleave = true  // Set to true to match old behavior
            });
        
        return Task.CompletedTask;
    }

    private Task DoWorkAsync(CancellationToken cancellationToken)
    {
        // Timer work - check cancellationToken for graceful shutdown
        return Task.CompletedTask;
    }
}

Warning

The default interleaving behavior changed in Orleans 8.2. The old RegisterTimer API allowed timer callbacks to interleave with other grain calls by default. The new RegisterGrainTimer API does not interleave by default. If your grain logic depends on interleaving behavior, set Interleave to true to preserve the old behavior.

This section applies to Orleans 8.0 and later. Select the Orleans 8.0, Orleans 9.0, or Orleans 10.0 pivot to view migration guidance.

Reminders

Reminders are similar to timers, with a few important differences:

  • Reminders are persistent and continue to trigger in almost all situations (including partial or full cluster restarts) unless explicitly canceled.
  • Reminder "definitions" are written to storage. However, each specific occurrence with its specific time isn't stored. This has the side effect that if the cluster is down when a specific reminder tick is due, it will be missed, and only the next tick of the reminder occurs.
  • Reminders are associated with a grain, not any specific activation.
  • If a grain has no activation associated with it when a reminder ticks, Orleans creates the grain activation. If an activation becomes idle and is deactivated, a reminder associated with the same grain reactivates the grain when it ticks next.
  • Reminder delivery occurs via message and is subject to the same interleaving semantics as all other grain methods.
  • You shouldn't use reminders for high-frequency timers; their period should be measured in minutes, hours, or days.

Configuration

Since reminders are persistent, they rely on storage to function. You must specify which storage backing to use before the reminder subsystem can function. Do this by configuring one of the reminder providers via Use{X}ReminderService extension methods, where X is the name of the provider (for example, UseAzureTableReminderService).

Azure Table configuration:

public static async Task ConfigureAzureTableAsync(string[] args)
{
    // TODO replace with your connection string
    const string connectionString = "YOUR_CONNECTION_STRING_HERE";

    var builder = Host.CreateApplicationBuilder(args);
    builder.UseOrleans(siloBuilder =>
    {
        siloBuilder.UseAzureTableReminderService(connectionString);
    });

    using var host = builder.Build();
    await host.RunAsync();
}

SQL:

public static async Task ConfigureAdoNetAsync(string[] args)
{
    const string connectionString = "YOUR_CONNECTION_STRING_HERE";
    const string invariant = "YOUR_INVARIANT";

    var builder = Host.CreateApplicationBuilder(args);
    builder.UseOrleans(siloBuilder =>
    {
        siloBuilder.UseAdoNetReminderService(options =>
        {
            options.ConnectionString = connectionString; // Redacted
            options.Invariant = invariant;
        });
    });

    using var host = builder.Build();
    await host.RunAsync();
}

If you just want a placeholder implementation of reminders to work without needing to set up an Azure account or SQL database, this provides a development-only implementation of the reminder system:

public static async Task ConfigureInMemoryAsync(string[] args)
{
    var builder = Host.CreateApplicationBuilder(args);
    builder.UseOrleans(siloBuilder =>
    {
        siloBuilder.UseInMemoryReminderService();
    });

    using var host = builder.Build();
    await host.RunAsync();
}

Redis:

public static async Task ConfigureRedisAsync(string[] args)
{
    var builder = Host.CreateApplicationBuilder(args);
    builder.UseOrleans(siloBuilder =>
    {
        siloBuilder.UseRedisReminderService(options =>
        {
            options.ConfigurationOptions = new ConfigurationOptions
            {
                EndPoints = { "localhost:6379" },
                AbortOnConnectFail = false
            };
        });
    });

    using var host = builder.Build();
    await host.RunAsync();
}

The RedisReminderTableOptions class provides the following configuration options:

Property Type Description
ConfigurationOptions ConfigurationOptions The StackExchange.Redis client configuration. Required.
EntryExpiry TimeSpan? Optional expiration time for reminder entries. Only set this for ephemeral environments like testing. Default is null.
CreateMultiplexer Func<RedisReminderTableOptions, Task<IConnectionMultiplexer>> Custom factory for creating the Redis connection multiplexer.

Azure Cosmos DB:

Install the Microsoft.Orleans.Reminders.Cosmos NuGet package and configure with UseCosmosReminderService:

public static async Task ConfigureCosmosAsync(string[] args)
{
    var builder = Host.CreateApplicationBuilder(args);
    builder.UseOrleans(siloBuilder =>
    {
        siloBuilder.UseCosmosReminderService(options =>
        {
            options.ConfigureCosmosClient(
                "https://myaccount.documents.azure.com:443/",
                new DefaultAzureCredential());
            options.DatabaseName = "Orleans";
            options.ContainerName = "OrleansReminders";
            options.IsResourceCreationEnabled = true;
        });
    });

    using var host = builder.Build();
    await host.RunAsync();
}

The CosmosReminderTableOptions class provides the following configuration options:

Property Type Default Description
DatabaseName string "Orleans" The name of the Cosmos DB database.
ContainerName string "OrleansReminders" The name of the container for reminder data.
IsResourceCreationEnabled bool false When true, automatically creates the database and container if they don't exist.
DatabaseThroughput int? null The provisioned throughput for the database. If null, uses serverless mode.
ContainerThroughputProperties ThroughputProperties? null The throughput properties for the container.
ClientOptions CosmosClientOptions new() The options passed to the Cosmos DB client.

Important

If you have a heterogenous cluster, where the silos handle different grain types (implement different interfaces), every silo must add the configuration for Reminders, even if the silo itself doesn't handle any reminders.

.NET Aspire integration for reminders

When using .NET Aspire, you can configure Orleans reminders declaratively in your AppHost project. Aspire automatically injects the necessary configuration into your silo projects via environment variables.

Redis reminders with Aspire

AppHost project (Program.cs):

public static void RemindersRedisAppHost(string[] args)
{
    var builder = DistributedApplication.CreateBuilder(args);

    var redis = builder.AddRedis("redis");

    var orleans = builder.AddOrleans("cluster")
        .WithClustering(redis)
        .WithReminders(redis);

    builder.AddProject<Projects.Silo>("silo")
        .WithReference(orleans)
        .WaitFor(redis);

    builder.Build().Run();
}

Silo project (Program.cs):

public static void RemindersRedisSilo(string[] args)
{
    var builder = Host.CreateApplicationBuilder(args);

    builder.AddServiceDefaults();
    builder.AddKeyedRedisClient("redis");
    builder.UseOrleans();

    builder.Build().Run();
}

Azure Table Storage reminders with Aspire

AppHost project (Program.cs):

public static void RemindersAzureTableAppHost(string[] args)
{
    var builder = DistributedApplication.CreateBuilder(args);

    var storage = builder.AddAzureStorage("storage")
        .RunAsEmulator();

    var reminders = storage.AddTables("reminders");

    var orleans = builder.AddOrleans("cluster")
        .WithClustering(reminders)
        .WithReminders(reminders);

    builder.AddProject<Projects.Silo>("silo")
        .WithReference(orleans)
        .WaitFor(storage);

    builder.Build().Run();
}

Silo project (Program.cs):

public static void RemindersAzureTableSilo(string[] args)
{
    var builder = Host.CreateApplicationBuilder(args);

    builder.AddServiceDefaults();
    builder.AddKeyedAzureTableClient("reminders");
    builder.UseOrleans();

    builder.Build().Run();
}

Tip

During local development, Aspire automatically uses the Azurite emulator for Azure Storage. In production, configure a real Azure Storage account in your AppHost.

In-memory reminders for development with Aspire

For local development, you can use in-memory reminders that don't require external storage:

AppHost project (Program.cs):

public static void RemindersInMemoryAppHost(string[] args)
{
    var builder = DistributedApplication.CreateBuilder(args);

    var redis = builder.AddRedis("redis");

    var orleans = builder.AddOrleans("cluster")
        .WithClustering(redis)
        .WithMemoryReminders();

    builder.AddProject<Projects.Silo>("silo")
        .WithReference(orleans)
        .WaitFor(redis);

    builder.Build().Run();
}

Silo project (Program.cs):

public static void RemindersInMemorySilo(string[] args)
{
    var builder = Host.CreateApplicationBuilder(args);

    builder.AddServiceDefaults();
    builder.UseOrleans();

    builder.Build().Run();
}

Warning

In-memory reminders are lost when the silo restarts. Only use WithMemoryReminders() for local development and testing. For production, always use a persistent reminder storage provider like Redis, Azure Table Storage, or SQL.

Important

You must call the appropriate AddKeyed* method (such as AddKeyedRedisClient or AddKeyedAzureTableClient) to register the backing resource in the dependency injection container. Orleans providers look up resources by their keyed service name—if you skip this step, Orleans won't be able to resolve the resource and will throw a dependency resolution error at runtime.

For more information about Orleans and .NET Aspire integration, see Orleans and .NET Aspire integration.

Reminder usage

A grain using reminders must implement the IRemindable.ReceiveReminder method.

Task IRemindable.ReceiveReminder(string reminderName, TickStatus status)
{
    Console.WriteLine("Thanks for reminding me-- I almost forgot!");
    return Task.CompletedTask;
}

To start a reminder, use the Grain.RegisterOrUpdateReminder method, which returns an IGrainReminder object:

protected Task<IGrainReminder> RegisterOrUpdateReminder(
    string reminderName,
    TimeSpan dueTime,
    TimeSpan period)
  • reminderName: is a string that must uniquely identify the reminder within the scope of the contextual grain.
  • dueTime: specifies a quantity of time to wait before issuing the first-timer tick.
  • period: specifies the period of the timer.

Since reminders survive the lifetime of any single activation, you must explicitly cancel them (as opposed to disposing of them). Cancel a reminder by calling Grain.UnregisterReminder:

protected Task UnregisterReminder(IGrainReminder reminder)

The reminder is the handle object returned by Grain.RegisterOrUpdateReminder.

Instances of IGrainReminder aren't guaranteed to be valid beyond the lifespan of an activation. If you wish to identify a reminder persistently, use a string containing the reminder's name.

If you only have the reminder's name and need the corresponding IGrainReminder instance, call the Grain.GetReminder method:

protected Task<IGrainReminder> GetReminder(string reminderName)

Decide which to use

We recommend using timers in the following circumstances:

  • If it doesn't matter (or is desirable) that the timer stops functioning when the activation deactivates or failures occur.
  • The timer's resolution is small (e.g., reasonably expressible in seconds or minutes).
  • You can start the timer callback from Grain.OnActivateAsync() or when a grain method is invoked.

We recommend using reminders in the following circumstances:

  • When the periodic behavior needs to survive activation and any failures.
  • Performing infrequent tasks (e.g., reasonably expressible in minutes, hours, or days).

Combine timers and reminders

You might consider using a combination of reminders and timers to accomplish your goal. For example, if you need a timer with a small resolution that must survive across activations, you can use a reminder running every five minutes. Its purpose would be to wake up a grain that restarts a local timer possibly lost due to deactivation.

POCO grain registrations

To register a timer or reminder with a POCO grain, implement the IGrainBase interface and inject ITimerRegistry or IReminderRegistry into the grain's constructor.

using Orleans.Timers;

namespace Timers;

public sealed class PingGrain : IGrainBase, IPingGrain, IDisposable
{
    private const string ReminderName = "ExampleReminder";

    private readonly IReminderRegistry _reminderRegistry;

    private IGrainReminder? _reminder;

    public  IGrainContext GrainContext { get; }

    public PingGrain(
        ITimerRegistry timerRegistry,
        IReminderRegistry reminderRegistry,
        IGrainContext grainContext)
    {
        // Register timer
        timerRegistry.RegisterGrainTimer(
            grainContext,
            callback: static async (state, cancellationToken) =>
            {
                // Omitted for brevity...
                // Use state

                await Task.CompletedTask;
            },
            state: this,
            options: new GrainTimerCreationOptions
            {
                DueTime = TimeSpan.FromSeconds(3),
                Period = TimeSpan.FromSeconds(10)
            });

        _reminderRegistry = reminderRegistry;

        GrainContext = grainContext;
    }

    public async Task Ping()
    {
        _reminder = await _reminderRegistry.RegisterOrUpdateReminder(
            callingGrainId: GrainContext.GrainId,
            reminderName: ReminderName,
            dueTime: TimeSpan.Zero,
            period: TimeSpan.FromHours(1));
    }

    void IDisposable.Dispose()
    {
        if (_reminder is not null)
        {
            _reminderRegistry.UnregisterReminder(
                GrainContext.GrainId, _reminder);
        }
    }
}

The preceding code does the following:

  • Defines a POCO grain implementing IGrainBase, IPingGrain, and IDisposable.
  • Registers a timer invoked every 10 seconds, starting 3 seconds after registration.
  • When Ping is called, registers a reminder invoked every hour, starting immediately after registration.
  • The Dispose method cancels the reminder if it's registered.