Articles · Production Operations ·030
Fenced Leadership & Cluster Membership
Runnable sample on GitHub
Sample:
samples/030-fenced-leadership-and-membership· Subsystem: 2.13 · Prerequisites: 023 — Distributed Coordination, 022 — Resiliency
Overview
Article 023 elects a leader the simple way: a connection-bound
ILeaderElector holds a PostgreSQL advisory lock for as long as its connection lives, and the database
drops that lock when the process dies. That is crash-safe and dependency-free, but it has a blind spot —
it only fails over when a leader dies, not when a leader is alive but stalled (paused in a GC, stuck
on a blocked I/O, frozen by a long pause). The lock is still held, so no one else can take over, yet no
work is happening.
This article covers the renewable-lease companion: fenced leadership plus cluster membership,
both in Nuvora.Nexus.Relay.Core.Coordination.
IFencedLeaderElector(LeaseLeaderElector) — leader election backed by a renewable lease that must be re-affirmed before its TTL elapses. A stalled leader misses a renewal, the lease lapses, and another node takes over proactively. Each takeover carries a monotonically-increasing fencing token the work is handed, so a delayed old leader can be fenced out by downstream writers.ILeaseStore(InMemoryLeaseStore) — the renewable lease the elector sits on: acquire-or-renew keyed by role, with a TTL and a fencing token.INodeRegistry(InMemoryNodeRegistry) — heartbeat-based cluster membership: which instances are currently live, for observability and smarter rebalancing.
Where 023’s lock and election use PostgreSQL as the arbiter, this sample uses the in-memory implementations and a controllable clock — no database, no Docker — so the contracts are visible and the time-dependent behaviour (expiry, takeover, liveness) is exact.
Why this exists
The connection-bound election in 023 answers “the leader’s process died” perfectly: the server drops the lock, someone else wins it. But the worse failure in practice is the leader that is technically up and not making progress — a 40-second stop-the-world GC, a thread wedged on a socket that will eventually time out, a container the orchestrator hasn’t killed yet. Its connection is still open, so its lock is still held, so no failover happens. The singleton job just… stops, silently, until someone pages.
A renewable lease turns “still alive” into “still working”: leadership is only valid while the leader keeps proving it by renewing. Miss the renewals and you lose the lease — proactive failover, not death-triggered failover.
That introduces a new hazard, though: the split-brain window. The old leader could resume mid-write just as the new leader takes over, and now two leaders briefly believe they’re in charge. The fencing token closes it. Each ownership change bumps a monotonic counter; the work is handed its token and stamps it on downstream writes; the downstream store remembers the highest token it has seen and rejects anything lower. The resumed old leader’s writes carry a stale token and are refused — it is fenced out. (This is the pattern from Martin Kleppmann’s “How to do distributed locking.”)
Finally, once you have multiple instances coordinating through leases, you usually want to see them —
which is what INodeRegistry provides: a cheap heartbeat membership view without a peer-to-peer gossip
protocol.
When to use this
- A singleton background worker where a double-run is genuinely unsafe and you can’t make it idempotent — use fenced election so a stalled leader is taken over and its late writes are fenced.
- Work that writes to a fencing-aware store (one that can compare and reject by token) — the fencing token only protects you if something downstream actually checks it.
- Cluster membership/observability —
INodeRegistryto answer “which instances are live right now” for dashboards or rebalancing decisions.
When not to use this
- When idempotent work + connection-bound election is enough. If a rare double-run is harmless, the
simpler
ILeaderElectorfrom 023 has no renewal traffic and no lease table. Default to it. - For per-request work. Same as 023 — normal command handling is already safe under concurrency
(optimistic concurrency,
FOR UPDATE SKIP LOCKED). Don’t put a lease around it. - As a transaction or a general mutex. A lease is coarse-grained single-active-owner coordination, not a substitute for a database transaction.
- With the in-memory store across processes.
InMemoryLeaseStore/InMemoryNodeRegistryare single-process (great for tests and single-node use). Multi-node leadership needs a durable, shared store (e.g. the PostgreSQL-backed one).
Concepts
Lease + TTL + renewal. ILeaseStore.TryAcquireOrRenewAsync(role, ownerId, ttl) is one call that does
three things depending on state: it acquires if the lease is free or expired, renews (extends the
expiry) if ownerId already holds it and it’s still live, or returns LeaseResult.NotAcquired if a
different, non-expired owner holds it. The holder must call it again before ttl elapses; if it stops
(stalls or crashes), the lease lapses and is up for grabs. The clock is injected, so “after the TTL it
lapses” is a deterministic test, not a sleep.
Fencing-token monotonicity. A LeaseResult carries a FencingToken (a long). The rule the store
enforces: the token increases on every change of ownership and never on a same-holder renewal. So the
first holder gets 1; renewing keeps it 1; a takeover after expiry gets 2, the next takeover 3, and
so on — strictly increasing, monotone. Downstream writers track the highest token they’ve accepted and
reject any write stamped lower. A stalled-then-resumed old leader still carries its old (lower) token, so
its late writes are rejected — fenced out.
Node-registry heartbeats. INodeRegistry.HeartbeatAsync(nodeId) records “this node is alive as of
now”; GetLiveNodesAsync() returns the nodes whose most recent heartbeat is within the liveness window.
A node that stops heartbeating drops out once its last heartbeat is older than the TTL. This is
membership-by-liveness — no gossip, no consensus — backed by the same injectable clock so the lapse is
testable.
Architecture
flowchart TD
subgraph Cluster
A[Instance A] -->|TryAcquireOrRenew 'reports'| LS[(Lease store<br/>role → owner, token, expiresAt)]
B[Instance B] -->|contend / renew| LS
end
LS -->|Acquired token=1| Lead[Instance A = leader<br/>runs work fencingToken=1<br/>renews every RenewInterval]
LS -->|NotAcquired → back off + retry| B
A -.stalls: misses renewals → lease lapses after LeaseTtl.-> Over[Instance B takes over<br/>token=2 strictly higher]
Over -->|writes stamped token=2| DS[Downstream store<br/>tracks highest token seen]
A -.resumes, writes token=1.-> DS
DS -->|token 1 < 2 → reject| Fenced[stale leader fenced out]
subgraph Membership
A -->|HeartbeatAsync| NR[(Node registry)]
B -->|HeartbeatAsync| NR
NR -->|GetLiveNodesAsync| View[live nodes within the TTL]
end
Building it step by step
The sample is a set of focused, pure unit tests over the in-memory implementations — the clearest way to see each contract.
1. A renewable lease (deterministic time)
var clock = new ManualTimeProvider(); // controllable; no wall-clock sleeps
var store = new InMemoryLeaseStore(clock);
var a = await store.TryAcquireOrRenewAsync("reports", "node-a", TimeSpan.FromSeconds(30));
a.Acquired.Should().BeTrue();
a.FencingToken.Should().Be(1); // first holder → token 1
// node-a stalls and never renews; advance past the TTL and node-b takes over.
clock.Advance(TimeSpan.FromSeconds(31));
var b = await store.TryAcquireOrRenewAsync("reports", "node-b", TimeSpan.FromSeconds(30));
b.FencingToken.Should().BeGreaterThan(a.FencingToken); // 2 > 1 → fences a resumed node-a out
TryAcquireOrRenewAsync returns a LeaseResult(bool Acquired, long FencingToken, DateTimeOffset ExpiresAt); a same-holder renewal keeps the token, a takeover after expiry bumps it.
2. Fenced leader election
var elector = new LeaseLeaderElector(store, new LeaseLeaderOptions
{
OwnerId = "node-a",
LeaseTtl = TimeSpan.FromSeconds(30),
RenewInterval = TimeSpan.FromSeconds(10), // renew well inside the TTL
});
await elector.RunAsync("reports", async (fencingToken, leaderCt) =>
{
// runs on one node, renewed while it runs; if this node stalls past the TTL another takes over with a
// higher token. Stamp downstream writes with fencingToken so a stale leader's writes are rejected.
while (!leaderCt.IsCancellationRequested)
{
await GenerateReportsAsync(fencingToken, leaderCt);
await Task.Delay(TimeSpan.FromMinutes(5), leaderCt);
}
}, stoppingToken);
A second LeaseLeaderElector contending for the same role over the same store keeps losing while the
leader renews — it does not run concurrently.
3. Cluster membership by heartbeat
var registry = new InMemoryNodeRegistry(livenessTtl: TimeSpan.FromSeconds(30), clock);
await registry.HeartbeatAsync("node-a");
(await registry.GetLiveNodesAsync()).Should().Contain("node-a");
clock.Advance(TimeSpan.FromSeconds(31)); // node-a stops heartbeating
(await registry.GetLiveNodesAsync()).Should().BeEmpty(); // dropped once older than the TTL
Complete source code
| File | Shows | DB? |
|---|---|---|
LeaseStoreTests.cs |
acquire, renew (same token), contention, takeover with a higher fencing token | No |
FencedLeaderElectionTests.cs |
elected work runs with a token; a second instance does not run concurrently | No |
NodeRegistryTests.cs |
heartbeat liveness, lapse after the TTL, refresh, sorted live set | No |
ManualTimeProvider.cs |
controllable clock for deterministic expiry/liveness | No |
Running the example
dotnet test samples/030-fenced-leadership-and-membership/Coordination.Fenced.Tests
Needs only the .NET 10 SDK — there is no database or broker. (Compare 023, whose distributed-lock tests spin up PostgreSQL via Testcontainers; the fenced primitives here are exercised entirely in-memory.)
Testing
Everything is pure and deterministic. InMemoryLeaseStore and InMemoryNodeRegistry take an optional
TimeProvider; the tests inject a ManualTimeProvider and call Advance(...) instead of sleeping, so a
“after 30s the lease lapses and node-b takes over with token 2” assertion runs in microseconds and never
flakes — the same testability principle as the resiliency primitives in 022:
never bake DateTimeOffset.UtcNow into time-dependent code; take a TimeProvider. The fenced-elector
tests do run the renew/retry loop on the real clock (it’s a background loop), but with millisecond
intervals and bounded waits, so a regression fails fast rather than hanging.
Production considerations
- Size
LeaseTtlagainstRenewIntervalwith margin. Renew well inside the TTL (the defaults are a 30s TTL renewed every 10s — three chances to renew before lapse). Too tight and a brief hiccup costs you leadership unnecessarily; too loose and a real stall takes too long to fail over.RenewIntervalshould be a small fraction ofLeaseTtl. - Account for clock skew. TTL/expiry are compared against a clock. With a shared durable store, the store’s clock is the arbiter, which avoids per-node skew; if expiry is computed per node, skew can expire a lease early or late. Keep TTLs comfortably larger than your worst expected skew.
- Idempotent vs. fenced — pick deliberately. Fencing only protects writes that pass through something that checks the token. If your downstream can’t compare-and-reject by token, you don’t actually have fencing — you have a lease, and your work still needs to be idempotent (the at-least-one reality of 023 applies in the brief overlap). Use the fencing token and keep the work safe to repeat.
- Heartbeat membership is eventually-consistent.
GetLiveNodesAsynccan briefly list a node that just died (until its TTL) or omit one that just started (until its first heartbeat). Treat it as a hint for rebalancing/observability, not as a strongly-consistent roster. - The in-memory store is single-process. It’s for tests and single-node use. Multi-node leadership needs the durable, shared store; the in-memory one will happily let every process think it’s the leader.
Common mistakes
RenewIntervaltoo close toLeaseTtl. One missed renewal then drops leadership. Leave headroom.- Trusting the fencing token without enforcing it. Handing the work a token and then never checking it downstream is decorative. The protection lives in the downstream compare-and-reject.
- Assuming the lease makes the work exactly-once. Failover has an overlap window; design for at-least-one and let the fencing token reject the loser’s late writes.
- Using
InMemoryLeaseStorein a multi-node deployment. Each process has its own dictionary, so each one elects itself. Use a shared durable store across processes. - Forgetting to honour the leadership token.
RunAsynccancels the token you’re handed when the lease is lost; long-running work must observe it (pass it toTask.Delay, loop onIsCancellationRequested) so a fenced leader stops promptly.
Tradeoffs
- Renewable lease vs. connection-bound lock. The connection-bound
ILeaderElector(023) is the simplest correct option — crash-safe, no renewal traffic, no lease table — but blind to a stalled leader and only at-least-one under partitions. The renewable lease adds proactive failover and fencing at the cost of renewal traffic, a lease table, and TTL tuning. Default to the simple one with idempotent work; reach for the lease when a double-run is genuinely unsafe. - TTL responsiveness vs. stability. A short TTL fails over fast but is twitchy under transient hiccups; a long TTL is stable but slow to recover. There’s no free lunch — pick for your tolerance.
- Fencing strength vs. downstream complexity. Fencing tokens are airtight if every protected write path checks them — which means threading a token through your storage layer. That’s real work; weigh it against simply making the work idempotent.
- Heartbeat membership vs. real consensus. Heartbeat liveness is cheap and dependency-free but only eventually consistent and can’t decide split-brain. A consensus system (Raft/ZooKeeper) gives a stronger roster at far higher operational cost.
Alternatives
- etcd / ZooKeeper / Consul leases — purpose-built lease + watch primitives with strong consistency
and built-in fencing-style revisions (etcd’s
mod_revision, ZooKeeper’szxid). Worth it at large scale or when you already run them; overkill if your database can hold the lease. - Kubernetes leases (
coordination.k8s.io/Lease) — exactly this pattern at the platform level: renewable, TTL’d, used by controllers to elect a leader pod. Great for electing a pod; complementary to in-process leases for finer-grained roles within a process. - Redis (Redlock) leases — popular, but Redlock’s correctness for fencing is famously debated; Kleppmann’s critique is the canonical read. Fine for best-effort; prefer a lease on your transactional store (or a real coordinator) for “must not double-write.”
Lessons from production systems
- The incident that motivates fencing is the stalled leader, not the dead one: the box is up, the lock is held, and the singleton job has silently stopped for minutes. A renewable lease turns that into an automatic, prompt failover.
- The split-brain write is real and subtle: the old leader resumes from a long GC pause after the new one took over and writes stale data. Fencing tokens — and a downstream that rejects lower tokens — are the well-understood fix; teams that skip the downstream check think they have fencing and don’t.
- “Exactly once” remains a myth across a failover boundary. The teams that sleep well combined a fencing token with idempotent work and stopped chasing it — exactly the lesson from 023, one layer up.
Should you use this?
| If you… | Then… |
|---|---|
| run a singleton job where a double-run is harmless | no — use connection-bound election (023) + idempotent work |
| run a singleton job where a stalled leader must be taken over | yes — fenced election (renewable lease) |
| write to a store that can reject by token | yes — stamp the fencing token and enforce it downstream |
| just need “which instances are live?” | yes — INodeRegistry heartbeat membership |
| already run etcd/ZooKeeper at scale | consider their leases; the DB-backed lease is the no-new-dependency default |
Next steps
- 023 — Distributed Coordination: the connection-bound
ILeaderElector, distributed locks, and partitioning this article builds on. - 025 — Reference Architecture: coordination in a full service — leader-elected outbox/scheduler and partitioned consumers, where fenced election and membership fit.