lease

package
v1.0.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package lease is a named, process-level singleton lock over Postgres advisory locks: at most one replica at a time runs a given job, every other replica waits as a hot standby and takes over when the holder exits, crashes or loses its database connection.

What it is for

A background job that is NOT safe to run on two replicas at once (a sweeper that reads a value and writes a derived one, a reconciler that talks to a billing provider) cannot be scaled by raising the replica count. Without a lease the choices are one replica and an outage on every rollout, or two replicas and a race. Jobs that already tolerate concurrency (queue consumers on SELECT ... FOR UPDATE SKIP LOCKED, durable JetStream consumers, Temporal workers, idempotent set-based sweeps) need no lease and should not take one.

Why advisory locks

Every forge service already has a database, so the lease needs no new infrastructure, no schema and no Kubernetes RBAC, and behaves identically under `forge env up`, docker compose and a cluster. A SESSION-level advisory lock is released by Postgres the instant the holder's connection ends, so a crashed replica needs no TTL to expire.

The connection must be session-capable

The lock lives on one dedicated connection, so the *sql.DB given to New must hand back the same server session for the life of a connection. That excludes a transaction-pooling PgBouncer (the lock would be taken on a backend the client stops talking to, and silently never released). Point it at the direct database or a session-mode pooler entry, and give it its own pool: one open connection per Locker is all it uses.

Overlap is bounded, not impossible

Postgres will not grant a held lock to a second session, so two replicas are never leaders while the first holder's connection is alive. If the holder's connection is lost the server drops the lock, a standby can take it at once, and the old holder only learns of it at its next heartbeat. The jobs run under a lease therefore see at most Options.HeartbeatInterval plus Options.QueryTimeout of overlap after a partition (the next check, and the time it may take to give up on a silent network), the same kind of guarantee as a Kubernetes Lease, and must stay correct (if inefficient) under that window.

A slow reply is not a lost session

Every query on the session runs under Options.QueryTimeout, not the heartbeat interval. A query that outlives its deadline cannot be abandoned politely: the driver closes the connection, the server drops the session and every lock on it, so a timeout does not merely report a lost lease, it causes one. A deadline as short as the heartbeat interval turned local scheduling delay (a CPU-starved process, a stalled pod, a slow round trip) into dropped leases on a perfectly healthy session. How OFTEN to check and how LONG to wait for an answer are separate questions with separate knobs.

Key space

A lease name is hashed to the 64-bit advisory-lock key (see Key). Other code sharing the database may use advisory locks of its own; the hash is namespaced so a collision needs a 64-bit coincidence.

Index

Constants

View Source
const (

	// DefaultRetryInterval is how often a standby polls for a lease it does
	// not hold. It bounds failover after a graceful release.
	DefaultRetryInterval = 5 * time.Second

	// DefaultHeartbeatInterval is how often the holder re-checks that its
	// session still owns its locks.
	DefaultHeartbeatInterval = 5 * time.Second

	// DefaultQueryTimeout is how long one query on the lease session may take
	// before the session is declared lost. It is deliberately generous: a
	// query that times out closes the connection and drops every lease on it,
	// so it must only fire on a network or server that has stopped answering,
	// never on a slow reply.
	DefaultQueryTimeout = 10 * time.Second
)

Variables

View Source
var ErrAlreadyRunning = errors.New("lease: this lease name is already running on this Locker")

ErrAlreadyRunning is returned by Locker.Run when this Locker is already running the same lease name. Postgres advisory locks are re-entrant within a session, so without this check two in-process callers would both believe they lead.

Functions

func Key

func Key(name string) int64

Key returns the 64-bit advisory-lock key a lease name maps to, for debugging against pg_locks (classid is the high 32 bits, objid the low 32).

Types

type Locker

type Locker struct {
	// contains filtered or unexported fields
}

Locker contends for leases on one dedicated database session. Create one per process and share it between every job that needs a lease: all of them ride a single connection, so the cost is one session however many jobs.

func New

func New(db *sql.DB, opt Options) *Locker

New returns a Locker over db, which must be session-capable (see the package doc). The Locker does not own db; Close releases only its leases.

func (*Locker) Close

func (l *Locker) Close() error

Close drops the session, which releases every lease it holds, and makes every Run (running or later) return nil. It is for tests and orderly teardown; a normal process just cancels the contexts it passed to Run.

func (*Locker) Held

func (l *Locker) Held() []string

Held returns the lease names this Locker currently holds, sorted.

func (*Locker) Run

func (l *Locker) Run(ctx context.Context, name string, fn func(ctx context.Context) error) error

Run contends for the lease name and, while it holds it, runs fn.

fn receives a context that is cancelled when the lease is lost or ctx ends; it must return promptly then. Run's behaviour on fn's return:

  • ctx is done: Run returns nil.
  • the lease was lost (heartbeat failure) and fn returned: Run goes back to contending and calls fn again when it next wins. fn must therefore be restartable.
  • fn returned an error: Run releases the lease and returns the error.
  • fn returned nil with the lease still held: the job is finished (for example a worker that disables itself). Run releases the lease and returns nil rather than spin re-acquiring it.

The lease is always released (pg_advisory_unlock) after fn has returned, never before, so a graceful handover never overlaps the outgoing leader.

type Options

type Options struct {
	// RetryInterval is the standby poll interval (default
	// [DefaultRetryInterval]). Each wait is jittered by up to 20% so replicas
	// that start together do not poll in lockstep.
	RetryInterval time.Duration
	// HeartbeatInterval is the holder's session check interval (default
	// [DefaultHeartbeatInterval]). A check that fails loses every lease held
	// on the session.
	HeartbeatInterval time.Duration
	// QueryTimeout bounds every query on the session, the heartbeat's
	// included (default [DefaultQueryTimeout]). It is independent of
	// HeartbeatInterval: a short interval checks often, it does not demand a
	// faster answer. A query that times out loses every lease on the session
	// (see the package doc), so keep it well above the worst round trip a
	// healthy database and a busy process can produce.
	QueryTimeout time.Duration
	// Logger receives acquire / lose / session events (default slog.Default()).
	Logger *slog.Logger
}

Options tunes a Locker. The zero value is production-ready.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL