Documentation
¶
Overview ¶
Package leaderelection elects a single leader among processes that share nothing but a database.
It exists for a deployment the built-in Kubernetes lease cannot serve: replicas of one service spread across separate clusters with no network path between them. The only thing they have in common is a Postgres or MySQL instance, so that is where the election happens.
Safety ¶
The guarantee is that at most one participant considers itself leader at any instant, and it survives network partitions, database outages, connection pooler restarts and abrupt process death. It rests on three choices:
Every timestamp comes from the database. Participants in different clusters have unrelated clocks, so a lease expiry expressed as an absolute time would mean different things to different participants. Each statement returns both the expiry and the database's current time, and only their difference -- a duration -- is ever used.
That duration is then measured against the local monotonic clock, starting from the moment the request was sent rather than when the reply arrived. The database began counting somewhere in between, so the resulting local deadline is always earlier than the true one.
A watchdog enforces that deadline independently of the renewal loop. This is what makes an unreachable database safe rather than merely inconvenient: a renewal that hangs forever cannot delay the step-down, because nothing about stepping down waits on the database. Uncertainty always resolves to "not leader" -- if this process cannot prove it still holds the lease, it stops acting as though it does, and it does so before the lease could possibly be handed to anyone else.
Fencing ¶
Stepping down promptly bounds how long a stalled leader can act, but it cannot unsend a write already in flight. Systems that must be protected from that -- an object store, a queue, another database -- need a fencing token.
Every term is handed a Lease.Fence, through Callbacks.OnStartedLeading and Elector.Fence. Across the whole election those tokens are strictly increasing: no two terms anywhere ever share one, whether they belong to the same participant or to different ones. Attach the token to external writes and have the receiving system reject anything stamped with one older than the highest it has seen.
Example ¶
The common shape: a callback that runs for the whole term and stops when its context is cancelled.
package main
import (
"context"
"fmt"
"github.com/brualan/leaderelection"
"github.com/brualan/leaderelection/memstore"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
elector, err := leaderelection.New(leaderelection.Config{
Store: memstore.New(),
Name: "invoice-reconciler",
Callbacks: leaderelection.Callbacks{
OnStartedLeading: func(ctx context.Context, fence int64) {
fmt.Println("leading with fence", fence)
close(done)
<-ctx.Done()
},
},
})
if err != nil {
panic(err)
}
go elector.Run(ctx)
<-done
}
Output: leading with fence 1
Index ¶
Examples ¶
Constants ¶
const ( DefaultLeaseDuration = 15 * time.Second DefaultRenewInterval = 5 * time.Second DefaultRetryPeriod = 3 * time.Second DefaultOperationTimeout = 3 * time.Second // DefaultSafetyMarginFraction is the fraction of LeaseDuration reserved as // the safety margin when none is configured, subject to // MinSafetyMargin. DefaultSafetyMarginFraction = 10 // MinSafetyMargin is the floor for a defaulted safety margin. MinSafetyMargin = time.Second )
Defaults for Config. They are tuned for participants that reach a shared database across cluster boundaries, where round trips are tens of milliseconds rather than sub-millisecond.
Variables ¶
var ( // ErrNoLease is returned by Store.Inspect when no record exists for the // requested election. It means "nobody has ever been leader here", not // "the lease is free" -- although both are claimable. ErrNoLease = errors.New("leaderelection: no lease record") // ErrLeaseLost is returned by Store.Renew when the guarded update matched // no row. The caller is not the leader any more, and must stop acting as // one before doing anything else. ErrLeaseLost = errors.New("leaderelection: lease lost") // ErrLeaseExpiredLocally is recorded in [Status.LastError] when a leader // stepped down because it could not confirm its lease in time. // // It does not mean the lease was taken away -- nobody may have taken it, // and the database may be perfectly healthy on the other side of a broken // network path. It means this process ran out of evidence that it was // still the leader, which is the only situation in which it is allowed to // keep acting as one. ErrLeaseExpiredLocally = errors.New("leaderelection: lease expired before it could be renewed") // ErrAlreadyRunning is returned by Elector.Run when the elector is already // running. An Elector drives a single participant in a single election and // cannot be shared between goroutines by running it twice. ErrAlreadyRunning = errors.New("leaderelection: elector is already running") )
Functions ¶
func GenerateIdentity ¶
GenerateIdentity builds an identity for this process: hostname, PID and a random suffix, joined by hyphens.
The random suffix is not decoration. Leader election is only safe if a restarted process cannot renew the lease that its own predecessor held: the predecessor may still be alive, blocked in a syscall or a stop-the-world pause, and still convinced it is the leader. Distinguishing incarnations by identity is what forces the successor to wait out the lease instead.
Identity generation fails only if the system's randomness source does, in which case the caller must not fall back to a predictable value.
Types ¶
type Callbacks ¶
type Callbacks struct {
// OnStartedLeading runs for the duration of a leadership term, in its own
// goroutine. Its context is cancelled the instant the Elector stops
// believing it is the leader -- including when the database has merely
// become unreachable -- so leader-only work must respect it promptly.
//
// fence is the token for this term. Use it to guard writes to systems that
// cannot participate in the election themselves.
OnStartedLeading func(ctx context.Context, fence int64)
// OnStoppedLeading runs after a term ends, once the OnStartedLeading
// goroutine has returned. Run does not proceed until it returns.
OnStoppedLeading func()
// OnNewLeader runs when a new term is observed: a different identity
// holding the lease, or the same identity holding it under a new fence.
// The identity may be this participant's own.
//
// It is advisory: use it for logging and for routing requests towards the
// leader, never to decide whether this process is the leader.
OnNewLeader func(identity string, fence int64)
}
Callbacks are invoked as leadership changes. All of them are optional, and all of them are called from the goroutine running Elector.Run except OnStartedLeading, which gets its own goroutine.
Callbacks must not call back into the Elector that invoked them.
type Config ¶
type Config struct {
// Store is the database-backed lease store. Required.
Store Store
// Name identifies the election. Participants contending for the same
// leadership must use the same Name; unrelated elections use different
// ones and may share a table. Required.
Name string
// Identity distinguishes this participant from every other one. When
// empty, a value combining hostname, PID and random bytes is generated.
//
// Identity must be unique across restarts. Reusing a stable identity -- a
// StatefulSet pod name, for instance -- lets a restarted process renew a
// lease that its own stalled predecessor may still believe it holds.
Identity string
// Payload is opaque data published alongside the lease, readable by
// followers through [Elector.Status]. A leader typically advertises the
// address at which other clusters can reach it. Optional.
Payload []byte
// LeaseDuration is how long a lease stays valid after a successful
// acquire or renew. It sets the worst-case failover time when a leader
// disappears without releasing. Defaults to DefaultLeaseDuration.
LeaseDuration time.Duration
// RenewInterval is how often the leader extends its lease. Defaults to
// DefaultRenewInterval.
RenewInterval time.Duration
// RetryPeriod is how often a follower polls for a claimable lease.
// Defaults to DefaultRetryPeriod. Actual delays are jittered.
RetryPeriod time.Duration
// OperationTimeout bounds a single database call. It must be small
// relative to LeaseDuration: a call that hangs for longer than the lease
// would otherwise be indistinguishable from a healthy one.
// Defaults to DefaultOperationTimeout.
OperationTimeout time.Duration
// SafetyMargin is how long before the lease's local expiry the Elector
// gives up leadership. It absorbs the difference in rate between this
// process's clock and the database's, plus the time the application needs
// to notice its context was cancelled.
//
// Defaults to LeaseDuration/DefaultSafetyMarginFraction, floored at
// MinSafetyMargin.
SafetyMargin time.Duration
// DisableRelease keeps the Elector from releasing its lease when Run
// returns. Releasing lets a successor take over immediately instead of
// waiting out LeaseDuration, so leaving this false is almost always right.
DisableRelease bool
// Logger receives structured logs about elections and failures. Defaults
// to a logger that discards everything.
Logger *slog.Logger
// Callbacks are invoked as leadership changes. Optional.
Callbacks Callbacks
// contains filtered or unexported fields
}
Config describes one participant in one election.
func (Config) Validate ¶
Validate reports whether the configuration is usable, applying no defaults. New calls it after defaulting, so callers rarely need it directly.
Example ¶
New refuses a configuration whose timings leave a leader no room to lose a single renewal, because such a configuration flaps under exactly the conditions the election exists to survive.
package main
import (
"fmt"
"time"
"github.com/brualan/leaderelection"
"github.com/brualan/leaderelection/memstore"
)
func main() {
err := leaderelection.Config{
Store: memstore.New(),
Name: "too-tight",
Identity: "a",
LeaseDuration: 10 * time.Second,
RenewInterval: 5 * time.Second,
RetryPeriod: time.Second,
OperationTimeout: 3 * time.Second,
SafetyMargin: time.Second,
}.Validate()
fmt.Println(err)
}
Output: leaderelection: invalid config: RenewInterval: a lost renewal needs RenewInterval+2*OperationTimeout+retry (12.25s) but only LeaseDuration-SafetyMargin (9s) is available
type ConfigError ¶
type ConfigError struct {
// Field names the offending configuration field.
Field string
// Reason explains what is wrong with it.
Reason string
}
ConfigError reports an invalid Config. It is returned by New and by Config.Validate.
func (*ConfigError) Error ¶
func (e *ConfigError) Error() string
type Elector ¶
type Elector struct {
// contains filtered or unexported fields
}
Elector runs one participant in one election. Use New to build one and Elector.Run to drive it.
func (*Elector) Fence ¶
Fence returns the fence token of the current term, and whether there is one. Use it to stamp writes to systems outside the election.
Example ¶
A fence token is what protects a system that cannot take part in the election. Stamp every write with it, and have the receiving side refuse anything older than the highest token it has seen.
package main
import (
"context"
"fmt"
"time"
"github.com/brualan/leaderelection"
"github.com/brualan/leaderelection/memstore"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
elector, err := leaderelection.New(leaderelection.Config{
Store: memstore.New(),
Name: "shipping",
})
if err != nil {
panic(err)
}
go elector.Run(ctx)
for {
if fence, ok := elector.Fence(); ok {
fmt.Println("would write with fence", fence)
break
}
time.Sleep(time.Millisecond)
}
}
Output: would write with fence 1
func (*Elector) IsLeader ¶
IsLeader reports whether this participant may act as leader right now.
It is safe to call from any goroutine and is the authoritative answer: it re-checks the term deadline on every call rather than trusting a flag, so a descheduled watchdog goroutine cannot widen the window during which a stale leader believes itself current. When in any doubt it returns false.
func (*Elector) Run ¶
Run participates in the election until ctx is cancelled.
It blocks. It returns nil on a clean shutdown, which makes it well behaved inside an errgroup, and ErrAlreadyRunning if this Elector is already running. Database failures are never returned: they are transient by assumption, reported through Status.LastError, and handled by giving up leadership rather than by giving up.
On return, leadership has been fully relinquished: the OnStartedLeading context is cancelled, that callback has returned, OnStoppedLeading has run, and the lease has been released unless Config.DisableRelease is set.
func (*Elector) Status ¶
Status returns a snapshot of the elector's current state.
Example ¶
Followers can read what the leader published, which across clusters is often the only channel that exists.
package main
import (
"context"
"fmt"
"time"
"github.com/brualan/leaderelection"
"github.com/brualan/leaderelection/memstore"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
store := memstore.New()
leader, err := leaderelection.New(leaderelection.Config{
Store: store,
Name: "gateway",
Identity: "eu-west",
Payload: []byte("https://leader.eu-west.internal:8443"),
})
if err != nil {
panic(err)
}
go leader.Run(ctx)
// Wait for the term to start before the follower joins, so that the
// follower's first read already sees a leader rather than an empty
// election it would have to poll again for.
for !leader.IsLeader() {
time.Sleep(time.Millisecond)
}
follower, err := leaderelection.New(leaderelection.Config{
Store: store,
Name: "gateway",
Identity: "us-east",
})
if err != nil {
panic(err)
}
go follower.Run(ctx)
for {
if status := follower.Status(); status.Leader == "eu-west" {
fmt.Printf("leader %s reachable at %s\n", status.Leader, status.LeaderPayload)
break
}
time.Sleep(time.Millisecond)
}
}
Output: leader eu-west reachable at https://leader.eu-west.internal:8443
type Lease ¶
type Lease struct {
// Name identifies the election this record belongs to.
Name string
// Holder is the identity of the participant currently holding the lease.
Holder string
// Fence is a monotonically increasing token. It is incremented every time
// a new leadership term begins -- either because the holder changed or
// because an expired lease was reclaimed, even by its previous holder.
//
// Fence tokens are the only safe way to guard side effects in external
// systems against a stalled former leader. See the package documentation.
Fence int64
// Payload is opaque data published by the holder, for example the address
// at which the leader can be reached from other clusters. It may be nil.
Payload []byte
// AcquiredAt is when the current term began, on the database clock.
AcquiredAt time.Time
// RenewedAt is when the holder last extended the lease, on the database
// clock.
RenewedAt time.Time
// ExpiresAt is when the lease becomes claimable by others, on the database
// clock.
ExpiresAt time.Time
// Now is the database clock at the instant this record was read. It is
// captured by the same statement that produced the other fields, so
// comparisons against it are free of clock skew.
Now time.Time
}
Lease is the state of a single leader-election record as reported by the database.
Every timestamp in a Lease is generated by the database server, never by the client. Participants may run in different Kubernetes clusters whose clocks are not synchronised with each other, so absolute client timestamps are meaningless for comparison. Only durations derived from a single Lease -- primarily Lease.TTL -- are safe to reason about, because both endpoints of the subtraction come from the same clock read on the same server.
func (Lease) Expired ¶
Expired reports whether the lease was already claimable at the instant it was read.
func (Lease) HeldBy ¶
HeldBy reports whether identity holds this lease and the lease is still valid.
type Status ¶
type Status struct {
// IsLeader reports whether this participant may act as leader right now.
IsLeader bool
// Fence is the token for the current term, valid only when IsLeader.
Fence int64
// Identity is this participant's identity.
Identity string
// Leader is the identity last observed holding the lease. It is empty
// before the first successful read, and may name a participant whose
// lease has since expired.
Leader string
// LeaderFence is the fence token observed alongside Leader.
LeaderFence int64
// LeaderPayload is the payload published by the observed leader.
LeaderPayload []byte
// ObservedAt is the local time of the last successful database read.
ObservedAt time.Time
// ValidUntil is the local instant at which this participant must stop
// acting as leader unless it renews first. It is zero when not leading.
//
// It is derived from the database's own view of the lease, translated into
// local monotonic time, and is deliberately earlier than the database's
// expiry.
ValidUntil time.Time
// LastError is the most recent failure, cleared by the next success. A
// non-nil LastError while IsLeader is true means renewal is struggling
// and leadership is at risk.
LastError error
}
Status is a snapshot of what an Elector currently believes.
Everything in a Status except IsLeader is advisory and may already be stale when it is read. IsLeader is the only field with a safety guarantee behind it, and it is evaluated at the moment Elector.Status is called.
type Store ¶
type Store interface {
// Acquire attempts to start or continue a leadership term for identity,
// setting the lease to expire ttl after the database's current time.
//
// It succeeds -- takes over -- when the record does not exist, when the
// existing lease has expired, or when identity already holds it. Otherwise
// the existing lease is left untouched.
//
// Acquire returns the resulting record in either case, so that the caller
// can learn who the leader is without a second round trip. Losing the race
// is not an error; callers check the result with [Lease.HeldBy]. A non-nil
// error means the outcome is unknown.
Acquire(ctx context.Context, name, identity string, ttl time.Duration, payload []byte) (Lease, error)
// Renew extends a lease the caller believes it holds. The update is
// guarded by name, identity and fence together, and applies only while the
// lease has not yet expired on the database clock.
//
// Renew returns ErrLeaseLost if the guard does not match, which means the
// caller is no longer the leader and must stop acting as one immediately.
Renew(ctx context.Context, name, identity string, fence int64, ttl time.Duration, payload []byte) (Lease, error)
// Release voluntarily ends a leadership term so that the next election can
// happen without waiting for expiry. It is guarded by identity and fence.
//
// Releasing a lease the caller no longer holds is not an error: the
// intended end state -- this caller is not the leader -- already holds.
Release(ctx context.Context, name, identity string, fence int64) error
// Inspect reads the current record without modifying it. It returns
// ErrNoLease if no record exists for name.
//
// Followers use Inspect rather than Acquire so that a large fleet of
// non-leaders does not generate a write per poll.
Inspect(ctx context.Context, name string) (Lease, error)
}
Store is the persistence contract an Elector needs. Implementations are provided by the github.com/brualan/leaderelection/postgres and github.com/brualan/leaderelection/mysql modules.
Every method must be safe for concurrent use. Every method must derive all timestamps -- including the returned Lease.Now -- from the database server within a single statement or transaction, so that the caller can compute a skew-free TTL.
Implementations must not rely on session-scoped database state (session advisory locks, LISTEN/NOTIFY, temporary tables, SET). Doing so breaks under connection poolers such as PgBouncer in transaction pooling mode, which is a supported deployment for this library.
The storetest package provides an acceptance suite that every implementation is expected to pass.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
assert
Package assert holds the handful of test helpers the core module uses.
|
Package assert holds the handful of test helpers the core module uses. |
|
backoff
Package backoff computes retry delays with jitter.
|
Package backoff computes retry delays with jitter. |
|
clock
Package clock abstracts the passage of time so that the election loop can be driven deterministically by tests.
|
Package clock abstracts the passage of time so that the election loop can be driven deterministically by tests. |
|
Package memstore is an in-process implementation of leaderelection.Store.
|
Package memstore is an in-process implementation of leaderelection.Store. |
|
postgres
module
|
|
|
Package storetest is the acceptance suite every leaderelection.Store implementation must pass.
|
Package storetest is the acceptance suite every leaderelection.Store implementation must pass. |