Since module 4.3, a Kladde quote has a validity period. Whoever wants to send it after the last day gets quote.expired from the model. In the list it still shows up as "approved" or "sent", though, because nobody has changed its status. For that the quote would have to do something at midnight after its last day, without anyone calling it.

Orleans has two tools for this: timers for short ticks that may be lost, and reminders for appointments that have to survive a restart. In this lesson you get to know both, put the expiry rule into the model, let every quote expire by reminder and test all of it without waiting a single day.

Timers

A grain timer calls a method of the grain at a fixed tick for as long as the activation lives. It is created with RegisterGrainTimer, usually in OnActivateAsync:

C#
this.RegisterGrainTimer(
    static (grain, cancellationToken) => grain.ForgetTheGoneAsync(),
    this,
    new GrainTimerCreationOptions { DueTime = TimeSpan.FromSeconds(1), Period = TimeSpan.FromSeconds(1) });

The first parameter is the method that runs on every tick, the second a state that it receives (here the grain itself; static keeps the expression from capturing something else by accident). The third is the options:

Option Default Meaning
DueTime required wait until the first tick
Period required interval between two ticks; Timeout.InfiniteTimeSpan for a single tick
Interleave false The tick runs like a grain call: never at the same time as another call and without alternating with it at an await
KeepAlive false A tick does not keep the activation alive; a grain that nobody else calls is cleared away despite the timer

RegisterGrainTimer returns an IGrainTimer. With Dispose() you end the timer, with Change(dueTime, period) you change the tick. Two details distinguish it from the System.Threading.Timer you know from K1:

  • The period counts from the end of the last tick. If a tick takes two seconds, with a period of one second the next one comes three seconds after the start of the previous one. Ticks never overtake each other.
  • A tick is like a call. It waits until the running call is finished, and no call steps into the middle of a tick. The guarantee "one call after the other" from lesson 4.5.1 therefore holds for timers too. That was different until Orleans 8.1: The old, meanwhile obsolete RegisterTimer allowed alternating. Examples on the web that show RegisterTimer date from that time.

An example where it does not matter if it gets lost: "Who is looking at this quote right now?" Every open page reports regularly, and a timer strikes everybody who has not reported for a while. In the exercise folder k4-exercises/orleans, create the file presence.cs:

C#
#:package Microsoft.Orleans.Server@10.4.2
#:property PublishAot=false
#:property ServerGarbageCollection=true

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
builder.UseOrleans(silo => silo.UseLocalhostClustering());

using var host = builder.Build();
await host.StartAsync();

var presence = host.Services.GetRequiredService<IGrainFactory>().GetGrain<IPresenceGrain>("office-north/quote-17");
await presence.HereAsync("ada");
await presence.HereAsync("ben");
await Task.Delay(TimeSpan.FromSeconds(0.5));

for (var ms = 500; ms < 4000; ms += 1000)
{
    await presence.HereAsync("ada"); // ada keeps the quote open, ben has closed it
    Console.WriteLine($"{ms} ms: {string.Join(", ", await presence.WhoAsync())}");
    await Task.Delay(TimeSpan.FromSeconds(1));
}

await host.StopAsync();

public interface IPresenceGrain : IGrainWithStringKey
{
    Task HereAsync(string person);

    Task<string[]> WhoAsync();
}

public sealed class PresenceGrain : Grain, IPresenceGrain
{
    private static readonly TimeSpan Gone = TimeSpan.FromSeconds(1.5);

    private readonly Dictionary<string, DateTimeOffset> _lastSeen = [];

    public override Task OnActivateAsync(CancellationToken cancellationToken)
    {
        this.RegisterGrainTimer(
            static (grain, _) => grain.ForgetTheGoneAsync(),
            this,
            new GrainTimerCreationOptions { DueTime = TimeSpan.FromSeconds(1), Period = TimeSpan.FromSeconds(1) });
        return Task.CompletedTask;
    }

    public Task HereAsync(string person)
    {
        _lastSeen[person] = DateTimeOffset.UtcNow;
        return Task.CompletedTask;
    }

    public Task<string[]> WhoAsync() => Task.FromResult(_lastSeen.Keys.Order().ToArray());

    private Task ForgetTheGoneAsync()
    {
        var now = DateTimeOffset.UtcNow;
        foreach (var (person, seen) in _lastSeen)
        {
            if (now - seen > Gone)
            {
                _lastSeen.Remove(person);
            }
        }

        return Task.CompletedTask;
    }
}
Bash
dotnet run presence.cs
500 ms: ada, ben
1500 ms: ada, ben
2500 ms: ada
3500 ms: ada

Ben has not reported since the start. At the tick after two seconds his last report is older than a second and a half, and the timer strikes him. Ada reports every second and stays. The Remove inside the foreach loop over the same Dictionary has been allowed since .NET Core 3.0; a tick never runs at the same time as HereAsync, so no lock is needed either.

What can get lost here is harmless: If the grain is deactivated or the silo fails, the list is empty, and the open pages report again the next time. That is exactly what timers are for.