Documentation
¶
Overview ¶
Package redlease implements lease-based leader election on Redis with fencing tokens.
One instance holds a Redis lock with a TTL and runs the caller's work while it is leader; if it cannot renew, it steps down so another instance takes over. Unlike a plain Redis lock, every leadership term is assigned a strictly increasing fencing token. Writes routed through the Elector's Fence* helpers are rejected when they carry a token older than the latest applied, so a paused or clock-skewed leader that still believes it holds the lock cannot overwrite newer state.
When you need this ¶
Fencing matters only when a stale leader's write to shared state would be harmful — for example a value that must not regress, a sequence number, or a counter. If the leader does no writes, or writes self-healing last-writer-wins state, a plain lock is enough and you do not need this package.
Correctness boundary ¶
The fence token is generated and stored in Redis. On a single Redis instance this gives a strict, monotonic guarantee. On a replicated deployment (Sentinel or Cluster), Redis replication is asynchronous: a primary can acknowledge the acquire — and the token increment — before it reaches a replica, and a failover to that replica can lose them. In that window the monotonicity the fence relies on can be violated. For strict correctness across failover, source the fencing token from a linearizable store (etcd, ZooKeeper) instead of Redis. This package is the right tool when you run Redis single-instance, or when a brief, rare token regression on failover is acceptable.
Example ¶
This example runs a periodic job on exactly one instance at a time. Several instances call Run with the same Config.Name; only the elected leader executes the job. Each write the leader makes carries its leadership term's fencing token, so a stale leader that has not yet noticed it lost the lock cannot overwrite a newer leader's state.
package main
import (
"context"
"log"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/nijatsdev/redlease"
)
func main() {
rc := goredis.NewClient(&goredis.Options{Addr: "localhost:6379"})
e, err := redlease.New(rc, redlease.Config{
Name: "report-builder",
TTL: 5 * time.Second,
})
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Run blocks until ctx is cancelled. The callback runs only while this
// instance is the leader; its context is cancelled the moment leadership
// is lost. The Fencer carries this term's fencing token — use it for every
// write to shared state.
e.Run(ctx, func(leaderCtx context.Context, f redlease.Fencer) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-leaderCtx.Done():
return
case <-ticker.C:
// Record progress under the fencing token. If a newer leader
// has taken over, applied is false: this term is stale and
// must stop working.
applied, err := f.HSet(leaderCtx, "jobs:report", "status", "running")
if err != nil {
log.Printf("write failed: %v", err)
continue
}
if !applied {
log.Printf("fenced out by a newer leader; stopping")
return
}
}
}
})
}
Output:
Example (DatabaseFencing) ¶
This example fences a write to a resource other than Redis — here a SQL database. redlease enforces the fence for you only on Redis writes; for any other store it gives you the term's fencing token and you enforce it at the resource, atomically with your write. In SQL that means a conditional UPDATE that only applies when the row's stored fence is not newer than your token.
The table is assumed to hold a fence column alongside the value:
CREATE TABLE state (id text PRIMARY KEY, value text, fence bigint NOT NULL DEFAULT 0);
package main
import (
"context"
"database/sql"
"log"
"time"
goredis "github.com/redis/go-redis/v9"
"github.com/nijatsdev/redlease"
)
func main() {
rc := goredis.NewClient(&goredis.Options{Addr: "localhost:6379"})
db, err := sql.Open("postgres", "postgres://localhost/app")
if err != nil {
log.Fatal(err)
}
defer func() { _ = db.Close() }()
e, err := redlease.New(rc, redlease.Config{Name: "report-builder", TTL: 5 * time.Second})
if err != nil {
log.Print(err)
return
}
ctx := context.Background()
e.Run(ctx, func(leaderCtx context.Context, f redlease.Fencer) {
// Take the term's token from the Fencer and enforce it yourself in SQL.
// The UPDATE applies only when our token is at least the stored fence,
// then advances it; a stale leader's lower token matches no row.
const q = `UPDATE state
SET value = $1, fence = $2
WHERE id = $3 AND fence <= $2`
res, err := db.ExecContext(leaderCtx, q, "running", f.Token(), "report")
if err != nil {
log.Printf("write failed: %v", err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
// No row updated: a newer leader has advanced the fence past our
// token. We are stale and must stop working.
log.Printf("fenced out by a newer leader; stopping")
return
}
})
}
Output:
Index ¶
- Constants
- type Config
- type Elector
- func (e *Elector) FenceEval(ctx context.Context, token int64, body string, writeKeys []string, args ...any) (applied bool, err error)
- func (e *Elector) FenceHSet(ctx context.Context, token int64, hashKey, field, value string) (applied bool, err error)
- func (e *Elector) FenceSet(ctx context.Context, token int64, key, value string) (applied bool, err error)
- func (e *Elector) Fencer() (f Fencer, ok bool)
- func (e *Elector) InstanceID() string
- func (e *Elector) IsLeader() bool
- func (e *Elector) Run(ctx context.Context, fn LeaderFunc)
- func (e *Elector) Token() (token int64, ok bool)
- type Fencer
- func (f Fencer) Eval(ctx context.Context, body string, writeKeys []string, args ...any) (applied bool, err error)
- func (f Fencer) HSet(ctx context.Context, hashKey, field, value string) (applied bool, err error)
- func (f Fencer) Set(ctx context.Context, key, value string) (applied bool, err error)
- func (f Fencer) Token() int64
- type LeaderFunc
- type Observer
- type Redis
Examples ¶
Constants ¶
const ( DefaultTTL = 5 * time.Second DefaultRenewInterval = 2 * time.Second DefaultAcquireInterval = 2 * time.Second )
Default timing parameters. Override via Config.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
// Name identifies the lock. All instances contending for the same leadership
// must use the same Name; different Names are independent locks.
Name string
// TTL is how long the lock lives without renewal. A leader that cannot renew
// within TTL loses leadership. Shorter TTL means faster failover but less
// tolerance for pauses. Defaults to DefaultTTL.
TTL time.Duration
// RenewInterval is how often the leader renews the lock. Must be well below
// TTL. Defaults to DefaultRenewInterval.
RenewInterval time.Duration
// AcquireInterval is how often a follower retries acquiring the lock.
// Defaults to DefaultAcquireInterval.
AcquireInterval time.Duration
// InstanceID uniquely identifies this instance; it is the lock's value and
// guards renewal and release so an instance never affects another's lock. If
// empty, a hostname + random suffix is generated.
InstanceID string
// Observer receives lifecycle events. All fields are optional; the library
// emits no output of its own, so callers wire these to their own logger,
// metrics, or tracing as they see fit.
Observer Observer
}
Config configures an Elector. Only Name is required (the client is passed separately to New); zero-valued timing fields fall back to the Default* constants.
type Elector ¶
type Elector struct {
// contains filtered or unexported fields
}
Elector runs leader election for a single lock and mints fencing tokens.
func New ¶
New returns an Elector from cfg. It returns an error if required fields are missing or timing parameters are inconsistent (e.g. RenewInterval >= TTL).
func (*Elector) FenceEval ¶
func (e *Elector) FenceEval(ctx context.Context, token int64, body string, writeKeys []string, args ...any) (applied bool, err error)
FenceEval fences an arbitrary Redis write supplied as a Lua body, for writes the typed helpers do not cover (ZADD, XADD, multi-key updates, and so on). The body runs atomically only when token is current; the fence returns 1 when applied and 0 when fenced out.
The body must not contain its own return statement: FenceEval appends the fence's "return 1", so a return inside body shadows it and makes the result report applied == false even on a write that ran. Just perform the write and let FenceEval supply the return.
Within body, the protected token check is already done. Address your own keys and arguments starting at index 2 — KEYS[1] and ARGV[1] are reserved for the fence (the high-water key and the token). Pass your keys in writeKeys and your arguments in args; they appear as KEYS[2..] and ARGV[2..] in that order.
Example — fence a ZADD:
applied, err := e.FenceEval(ctx, token,
"redis.call('zadd', KEYS[2], ARGV[2], ARGV[3])",
[]string{"myset"}, "1.0", "member")
func (*Elector) FenceHSet ¶
func (e *Elector) FenceHSet(ctx context.Context, token int64, hashKey, field, value string) (applied bool, err error)
FenceHSet performs a fenced HSET of field=value into hashKey: the write is applied only if token is at least the highest token already applied through this elector. It returns true when applied and false when token is stale — a newer leadership term has since written — so the caller can stop emitting derived events. token must be the value passed to the LeaderFunc for the current term. A non-nil error means the write could not be attempted.
func (*Elector) FenceSet ¶
func (e *Elector) FenceSet(ctx context.Context, token int64, key, value string) (applied bool, err error)
FenceSet performs a fenced SET of key=value, applied only if token is current. Semantics match Elector.FenceHSet.
func (*Elector) Fencer ¶ added in v0.3.0
Fencer returns a Fencer bound to the current leadership term and true while this instance is the leader, or a zero Fencer and false otherwise. It is the token-driven-style counterpart to the Fencer a LeaderFunc receives: code outside the callback can take a Fencer and pass it down to its writers.
Like Elector.Token, the returned Fencer is a snapshot; leadership can change immediately after. That is safe because the fence is enforced at write time — a stale token is rejected by the Fencer's methods.
func (*Elector) InstanceID ¶
InstanceID returns this elector's instance identity.
func (*Elector) IsLeader ¶
IsLeader reports whether this instance currently holds leadership. It is advisory only: leadership can be lost the instant after it returns, so never gate a correctness-sensitive write on it. For writes, carry the token from Elector.Token through a Fence* helper, which rejects a stale token at write time.
func (*Elector) Run ¶
func (e *Elector) Run(ctx context.Context, fn LeaderFunc)
Run contends for leadership until ctx is cancelled. Each time this instance wins, it invokes fn (a LeaderFunc) with a Fencer for the term, then steps down and re-contends when leadership is lost. Run blocks until ctx is cancelled and fn (if running) has returned; on the final shutdown step-down the OnSteppedDown observer still fires.
fn may be nil. Then Run just keeps this instance elected — acquiring, renewing, and releasing the lock — while the caller does its leader work from another goroutine, gated on Elector.Token. This is the token-driven style; the callback style and this one are interchangeable, pick whichever fits.
Run must not be called more than once concurrently on the same Elector; the two calls would corrupt shared leadership state. Use one Elector per Run.
func (*Elector) Token ¶
Token returns the current leadership term's fencing token and true while this instance is the leader, or 0 and false otherwise. It is safe to call from any goroutine, so code outside the LeaderFunc can perform fenced writes:
if token, ok := e.Token(); ok {
e.FenceHSet(ctx, token, "state", "key", "value")
}
The returned token is a snapshot; leadership can change immediately after. That is safe because the fence is enforced at write time — a stale token is rejected by the Fence* helpers — so unlike Elector.IsLeader there is no time-of-check-to-time-of-use hazard in acting on it.
type Fencer ¶ added in v0.3.0
type Fencer struct {
// contains filtered or unexported fields
}
Fencer binds a leadership term's fencing token to the Elector that minted it, so a single value carries everything a fenced write needs. It is what a LeaderFunc receives, and what you pass down into the code that writes shared state.
A Fencer is a small value; copy it freely. Its methods are the bound equivalents of the Elector's Fence* helpers: each applies its write only when the bound token is at least the highest already applied, and returns applied == false when the token is stale (a newer term has written), so the caller can stop. A Fencer is valid only for its term; once leadership is lost, its writes are fenced out like any other stale token.
func NewFencer ¶ added in v0.3.0
NewFencer binds an explicit token to e, for callers that already hold a token — for example one persisted from a prior leadership term, or supplied in a test. Most code should instead take the Fencer a LeaderFunc receives or call Elector.Fencer; reach for this only when the token comes from outside the elector. The token is not validated against the elector's current term: the fence is still enforced at write time, so a stale token is rejected there.
func (Fencer) Eval ¶ added in v0.3.0
func (f Fencer) Eval(ctx context.Context, body string, writeKeys []string, args ...any) (applied bool, err error)
Eval fences an arbitrary Lua write. See Elector.FenceEval.
func (Fencer) HSet ¶ added in v0.3.0
HSet performs a fenced HSET of field=value into hashKey. See Elector.FenceHSet for the full semantics.
func (Fencer) Set ¶ added in v0.3.0
Set performs a fenced SET of key=value. See Elector.FenceSet.
type LeaderFunc ¶
LeaderFunc is the work run while an instance is leader. It receives a context cancelled when leadership is lost or Run's context is cancelled, and a Fencer bound to this leadership term — use it for every fenced write, or pass it down to the code that writes shared state. LeaderFunc must return promptly once its context is cancelled; Run does not release the lock or re-contend until it does.
type Observer ¶
type Observer struct {
// OnElected fires when this instance wins a leadership term, with that
// term's fencing token. It fires before the LeaderFunc is invoked.
OnElected func(token int64)
// OnSteppedDown fires when this instance loses or relinquishes leadership,
// after the LeaderFunc has returned.
OnSteppedDown func()
// OnFollower fires when this instance is, for now, a follower: an acquire
// attempt found the lock held by another instance. It fires once per
// transition into the follower role — on the first lost attempt, and again
// only after an intervening leadership term — not on every retry. A consumer
// can use it to learn its initial role at startup without waiting to win.
OnFollower func()
}
Observer is a set of optional callbacks invoked on leadership transitions. A nil field is simply not called. Callbacks run on the Run goroutine and must not block; offload slow work (I/O, network) to a goroutine of your own.
Only role transitions are reported, mirroring the design of k8s.io/client-go/tools/leaderelection. Transient Redis errors during acquire or renewal are handled internally; their only consequence the caller cares about — losing leadership — surfaces through OnSteppedDown. Monitor Redis health through your Redis client, not through this Observer.