Skip to content

Leader Election & Daemons

When you run several instances of your application, some background work must run on one instance only. Emit handles this with leader election, which picks one node in charge, and daemons, which run your work on exactly one node at a time.

Both need a persistence provider: coordination goes through its database, so there is no extra infrastructure to run.

Coordination is scoped to a service: all replicas of the same application. An API and a worker sharing one database each get their own leader and run their own daemons.

The service name defaults to the application name. Set ServiceName when two applications sharing a database have the same application name, and keep it stable across deployments.

Every node sends a heartbeat to the database (every 15 seconds by default), and one node per service holds the leadership. If the leader stops, another node takes over once its lease runs out; a clean shutdown hands leadership over at once.

Each node has a stable identity, exposed through INodeIdentity. Register your own implementation before AddEmit() to tie it to something meaningful, such as a pod name.

Inject ILeaderElectionService to check the current node’s role:

public class MyService(ILeaderElectionService leaderElection)
{
public void DoWork()
{
if (leaderElection.IsLeader)
{
// Only the leader reaches this branch
}
// Cancelled when the node loses leadership.
// Pass it to long-running leader-only operations.
var token = leaderElection.LeadershipToken;
}
}

Register an ILeaderElectionObserver with emit.AddLeaderElectionObserver<T>() to react when this node is elected or loses leadership, or when nodes join or leave the service.

A daemon is background work that runs continuously on exactly one node of the service, and moves to another node when that node goes away: a scheduler, a poller, a reconciliation job. The outbox runs as a daemon too (emit:outbox).

Implement IDaemonAgent and register it with AddDaemon:

public sealed class InvoiceReminderDaemon(IServiceScopeFactory scopes) : IDaemonAgent
{
public string DaemonId => "invoice-reminders";
public async Task RunAsync(DaemonRunContext context)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
while (await timer.WaitForNextTickAsync(context.AssignmentToken))
{
await using var scope = scopes.CreateAsyncScope();
var reminders = scope.ServiceProvider.GetRequiredService<IReminderService>();
foreach (var invoice in await reminders.GetDueAsync(context.AssignmentToken))
{
// Stop as soon as this node no longer holds the daemon
if (!context.HoldsAssignment)
{
return;
}
await reminders.SendAsync(invoice, context.AssignmentToken);
}
}
}
}
builder.Services.AddEmit(emit =>
{
// ... a persistence provider
emit.AddDaemon<InvoiceReminderDaemon>();
});

A few rules:

  • Run until AssignmentToken is cancelled, then stop promptly (within DrainTimeout). A run that ends or throws on its own counts as a failure and is restarted; one that keeps failing moves to another node.
  • Check HoldsAssignment right before each side effect. It turns false the moment the node must stop.
  • Create a scope per unit of work for scoped services such as a DbContext: the daemon itself lives as long as the application.
  • Keep DaemonId stable and register the same daemons on every replica. Identifiers starting with emit: are reserved. One class can back several daemons with AddDaemon(sp => new ReportDaemon("daily-report")).

If your daemon writes somewhere that must never see two runs overlap, even after a frozen process wakes up, pass context.FencingKey and context.Generation along with the write and have that system refuse a generation lower than the last one it accepted. Each new assignment of a daemon gets a higher generation.

To unit test a daemon, call RunAsync with a context you build yourself: new DaemonRunContext(token, generation: 1, fencingKey: Guid.NewGuid(), holdsAssignment: () => true).

  • A node joins: daemons spread out, one at a time, so no node runs two more than another.
  • A node shuts down: it stops its daemons and hands them over right away.
  • A node crashes or loses the database: it stops its own daemons, and they move once its registration expires (90 seconds by default).

Use an IDaemonObserver or the daemon metrics to see where daemons run.

You needUse
Work that runs continuously on exactly one node, and moves when that node goes awayA daemon
A short critical section that any node may run, one at a timeA distributed lock
A leader-only branch in code that already runs on every nodeILeaderElectionService.IsLeader and LeadershipToken

The defaults suit most deployments. Give every node of a service the same values.

OptionDefaultNotes
ServiceNameApplication nameShared by all replicas of a service
HeartbeatInterval15 secondsHow often each node sends a heartbeat
LeaseDuration60 secondsHow long leadership lasts without a heartbeat before another node takes over
NodeRegistrationTtl90 secondsHow long a silent node keeps its daemons before they move
DrainTimeout8 secondsHow long a daemon may take to stop. Keep the host’s shutdown timeout comfortably longer (its default of 30 seconds is fine)
MaxConsecutiveFailures3Failures in a row before a daemon moves to another node

See the Configuration Reference for every option and the rules between them.