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:
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
RegisterTimerallowed alternating. Examples on the web that showRegisterTimerdate 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:
#: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;
}
}
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.