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.
e.Run(ctx, func(leaderCtx context.Context, token int64) {
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 := e.FenceHSet(leaderCtx, token, "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, token int64) {
// 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, so its
// write is rejected at the database — the same guarantee FenceHSet gives
// for Redis, enforced here in SQL.
const q = `UPDATE state
SET value = $1, fence = $2
WHERE id = $3 AND fence <= $2`
res, err := db.ExecContext(leaderCtx, q, "running", 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) 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 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; it must not return a value (the fence returns 1 when applied, 0 when fenced out).
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) 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 the term's fencing token, 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.
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 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 the fencing token for this leadership term. Stamp every fenced write with token. 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()
}
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 leadership 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.