Elarion

Coordination & role leases

Elect one instance for a coarse application role on PostgreSQL, gate work at the holder, and route HTTP traffic to it when needed.

Elarion has three deliberately different coordination shapes. Choose the one that matches the unit being coordinated:

NeedPrimitiveCoordination key
Run one recurring occurrence across the clusterScheduler claimJob + due time
Deliver one integration consumer invocation at a timeOutbox delivery leaseMessage + consumer
Make exactly one instance hold a long-lived application roleRole leaseRole name

A role lease answers a coarse question such as “which instance is the maintenance coordinator?” It is a heartbeat-renewed row in the application's PostgreSQL database, exposed as an IRoleLease keyed by role name. The current holder renews; other instances keep trying; graceful shutdown releases immediately; a crashed holder fails over when its lease expires.

A role lease is not a distributed-lock API. Do not create one per tenant, record, or work item. An application should have a handful of stable roles. Use database constraints and optimistic concurrency for rows, scheduler claims for job occurrences, and outbox leases for messages.

This default targets Elarion's normal scale: roughly 1–10 application nodes on the PostgreSQL the app already runs. Beyond that tier, replace the coordination seam with dedicated infrastructure rather than growing a membership system inside the application.

Map the lease table

Add Elarion.Coordination.PostgreSql and opt the application's concrete DbContext into the model:

using Elarion.Coordination.PostgreSql;
using Elarion.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

[GenerateDbSets]
[GenerateElarionRoleLeases]
public sealed partial class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options) {
    protected override void OnModelCreating(ModelBuilder modelBuilder) {
        base.OnModelCreating(modelBuilder);
        ConfigureEntities(modelBuilder); // generated, including elarion_role_leases
    }
}

The hand-written model equivalent is modelBuilder.UseElarionRoleLeases(). Apply the resulting EF Core migration before any instance starts competing for a role.

Register one lease per role

Each registration adds a keyed IRoleLease and its heartbeat worker:

builder.Services.AddElarionPostgreSqlRoleLease<AppDbContext>(options => {
    options.RoleName = "maintenance";
});

Duplicate registration of the same role in one process fails at startup. The defaults are sized so the old holder stops acting before a successor may legitimately take over:

Role names are validated against the lease table's 128-character limit during registration. Fixed partitions validate their generated {name}:partition-N roles before adding any services.

OptionDefaultMeaning
LeaseDuration30 sHow long one successful acquisition remains valid; also the crash-failover bound.
RenewInterval10 sHow often holders renew and non-holders try to acquire.
HeldSafetyMargin5 sHow early local IsHeld becomes false before database expiry.
InstanceIdMachine name + random suffixProcess identity written to the lease row.
AdvertisedAddressnullExplicit holder address for proxying; otherwise an IInstanceAddressProvider may supply it.

LeaseDuration must be greater than RenewInterval + HeldSafetyMargin.

Register a fixed role partition

When a stable key should select one of several coarse roles without configuring process count, register a fixed partition. One process may hold several partitions; this does not add automatic balancing:

builder.Services.AddElarionPostgreSqlRolePartition<AppDbContext>(partition => {
    partition.Name = "devices";
    partition.PartitionCount = 16;
});

Resolve the keyed IRolePartition and call Resolve(key) (or Resolve(scope, key)) for the locally cached role/holder/address view. Roles are named devices:partition-0 through devices:partition-15. IRoleLeaseRegistry exposes all configured leases to infrastructure such as role-affine outbox claiming.

Gate every cycle

Resolve the lease by its role name and check IsHeld at the start of every unit of work. IsHeld is local, lock-free state; it does not query PostgreSQL on the hot path.

using Elarion.Abstractions.Coordination;
using Microsoft.Extensions.DependencyInjection;

public sealed class MaintenanceWorker(
    [FromKeyedServices("maintenance")] IRoleLease lease,
    TimeProvider timeProvider)
    : BackgroundService {

    protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
        while (!stoppingToken.IsCancellationRequested) {
            if (lease.IsHeld) {
                await RunOneCycleAsync(stoppingToken);
            }

            await Task.Delay(TimeSpan.FromSeconds(10), timeProvider, stoppingToken);
        }
    }
}

Do not start a permanent leader-only loop in response to a lease-change event. There is deliberately no such event: database outages fail closed, and a holder can lose the role between any two cycles. Gate each call or polling cycle instead.

CurrentHolder and CurrentHolderAddress are diagnostic/routing snapshots refreshed at heartbeat cadence. They may lag a failover by one renewal interval; correctness comes from IsHeld, not from comparing holder strings.

Route HTTP to the holder

Some roles own live state or an ordered stream, so every public instance must accept the route while only the holder may execute it. Elarion.AspNetCore ships a small role-holder proxy for that shape:

builder.Services.AddElarionInstanceAddress();
builder.Services.AddElarionPostgreSqlRoleLease<AppDbContext>(options => {
    options.RoleName = "maintenance";
});

var app = builder.Build();

// Install before routing/auth so a non-holder never starts local execution.
app.UseElarionRoleHolderProxy("maintenance", "/maintenance", "/maintenance-streams");

app.MapMaintenanceEndpoints();

AddElarionInstanceAddress() advertises the server's bound address, replacing wildcard hosts with a non-loopback IPv4 address. Pass an explicit address behind NAT, a reverse proxy, or when nodes use HTTPS between themselves:

builder.Services.AddElarionInstanceAddress("https://app-03.internal:8443");

The proxy forwards at most one hop and supports streaming responses such as SSE. It never retries or queues a request: an unknown/unreachable holder or a failover race returns 503 with Retry-After. The loop guard prevents a forwarded request from bouncing again. When the named lease is not registered (for example in single-instance development), the call installs no middleware.

The prefix list is intentionally the same rule a future ingress or service mesh would own. When external routing can target the holder directly, remove the in-app proxy rather than layering both.

For a key-selected role, use the partition overload before routing:

app.UseElarionPartitionHolderProxy(
    "devices",
    context => context.Request.Headers["X-Device-Id"].FirstOrDefault(),
    "/device-api");

The resolver reads raw path/query/header state. Missing affinity keys return 400; holder failures use the same bounded 503 behavior as the coarse-role proxy.

When the target subsystem uses scoped affinity, pass that same stable scope. Virtual-sharded actors hash their logical actor name (the [Actor(Name = ...)] value, or the class name without Actor) and canonical key together, so actor ingress must use the scoped overload:

app.UseElarionPartitionHolderProxy(
    "actors",
    "Order",
    context => context.Request.Path.Value?.Split('/').LastOrDefault(),
    "/orders");

Actor home is a specialized role

AddElarionPostgreSqlActorHome<TDbContext>() composes the same mechanism under the reserved "actors" role for [Actor(Placement = ActorPlacementMode.SingleHome)] actors. Calls on other instances fail rather than forwarding through the actor runtime; host-owned HTTP prefixes can use UseElarionRoleHolderProxy("actors", ...) to reach a single home, or UseElarionPartitionHolderProxy("actors", actorName, ...) for virtual-sharded ingress. See Actors for the placement and migration boundaries.

Failure behavior

  • Graceful shutdown: the holder releases the row, allowing immediate takeover.
  • Process or node loss: another instance acquires after LeaseDuration expires.
  • Database outage: no instance considers itself holder after its safety window; coordination fails closed.
  • Clock skew: leases use application clocks. Keep node clocks synchronized; this is not a consensus protocol.
  • Two processes on one machine: the random InstanceId suffix keeps their identities distinct.

The full rationale and proxy design are recorded in ADR-0049 and ADR-0050.

On this page