mysqllock

package
v1.148.1 Latest Latest
Warning

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

Go to latest
Published: Jul 11, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package mysqllock provides process-distributed mutual exclusion using MySQL's named lock primitives GET_LOCK and RELEASE_LOCK.

Problem

When multiple application instances (or background workers) run concurrently, some operations must execute at most once cluster-wide: scheduled jobs, idempotent migrations, reconciliation tasks, or cache rebuilds. Coordinating this with in-memory mutexes is impossible across processes, and introducing a separate lock service can add operational complexity.

Solution

This package uses MySQL's built-in named locks to provide a simple distributed lock API around an existing database dependency. MySQLLock.Acquire requests a lock by key and returns a ReleaseFunc that must be called to release it.

Usage:

db, err := sql.Open("mysql", dsn)
if err != nil {
	log.Fatal(err)
}
defer db.Close()

locker := mysqllock.New(db)
release, err := locker.Acquire(ctx, "daily-reconciliation", 10*time.Second)
if err != nil {
	if errors.Is(err, mysqllock.ErrTimeout) {
		// Another instance is holding the lock.
		return
	}

	log.Fatal(err)
}
defer func() {
	if err := release(); err != nil {
		log.Printf("failed to release lock: %v", err)
	}
}()

// Perform the critical section while lock is held.

Detecting lock loss

A named lock lives only for as long as its owning MySQL session. If that session is dropped (idle-timeout reaping by MySQL or an intermediary proxy, network failure, server restart), MySQL releases the lock even though the caller still holds a ReleaseFunc. To keep the session active a periodic keep-alive query is run while the lock is held; if that query fails the lock is presumed lost. Each keep-alive attempt is bounded by a per-attempt timeout (WithKeepAlivePingTimeout, default 10s), so a connection that hangs (rather than resets) is also detected as lock loss instead of stalling silently.

Pass WithLostLockHandler to MySQLLock.Acquire to be notified (with an error wrapping ErrLockLost) the moment a specific lock is lost, so the critical section can be aborted:

release, err := locker.Acquire(ctx, key, timeout,
	mysqllock.WithLostLockHandler(func(err error) {
		cancelCriticalSection() // stop work; the lock is no longer held
	}))

Features

  • Single-call acquisition API: MySQLLock.Acquire returns a release closure, so lock lifetime is easy to scope with defer. The returned closure is idempotent and safe to call more than once, including concurrently.
  • Explicit timeout handling: ErrTimeout is returned when GET_LOCK does not acquire the lock within the requested timeout.
  • Input validation: empty or over-long keys yield ErrInvalidKey and non-positive timeouts yield ErrInvalidTimeout, instead of surfacing an opaque server error.
  • Dedicated lock connection: each successful lock acquisition is tied to a dedicated SQL connection, matching MySQL's lock semantics.
  • Connection keep-alive: a periodic query keeps the lock-owning connection active for long-running critical sections; the interval is configurable with WithKeepAliveInterval and each attempt is bounded by WithKeepAlivePingTimeout.
  • Lock-loss notification: a keep-alive failure is reported through the per-acquisition WithLostLockHandler and the instance-wide WithKeepAliveErrorHandler, both receiving an error wrapping ErrLockLost. Handler panics are recovered so a faulty handler cannot crash the process.
  • Bounded release: releasing the lock uses its own timeout (configurable with WithReleaseTimeout) so a wedged connection cannot block the caller forever.
  • Context-aware acquisition: caller context controls acquisition cancellation.
  • Zero external dependencies at runtime: relies only on database/sql and MySQL lock functions.

Behavior Notes

The lock key namespace is per MySQL server instance. Use stable, descriptive keys (for example, "service:job:daily-reconciliation"). Always call the returned ReleaseFunc, ideally with defer, to avoid holding locks longer than intended. MySQL limits lock names to 64 characters; keys outside 1..64 characters are rejected with ErrInvalidKey.

If the returned ReleaseFunc is never called, the keep-alive goroutine and its connection stay alive (and the lock stays held) until the process exits; there is no finalizer backstop, so releasing is the caller's responsibility.

The GET_LOCK timeout is passed to MySQL with sub-second (fractional) precision; MySQL 5.7.5 and later accept fractional timeouts (older servers truncate to whole seconds). The timeout must be positive; a non-positive value is rejected with ErrInvalidTimeout (MySQL would otherwise treat a negative timeout as an effectively unbounded wait). This lock timeout is distinct from the caller context: if ctx is canceled or its deadline is shorter than timeout, acquisition fails with a wrapped context error rather than ErrTimeout, so callers that distinguish "held by another instance" from "my own deadline" should also test for context errors.

Because a released connection is returned to the pool rather than closed, the explicit RELEASE_LOCK is required to free the named lock. In the rare case where RELEASE_LOCK fails on an otherwise-healthy connection, that connection can return to the pool still holding the lock; the same is true if the caller context is canceled at the instant GET_LOCK grants the lock (the acquire path issues a time-bounded best-effort RELEASE_LOCK to mitigate this without delaying the canceled acquisition, but cannot guarantee it if the connection is already unusable). Releasing also relies on the driver leaving the connection usable after canceling an in-flight keep-alive query. Configuring db.SetConnMaxLifetime bounds how long any such leaked lock can persist.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrTimeout indicates that GET_LOCK did not acquire the lock before timeout.
	ErrTimeout = errors.New("mysqllock: acquire lock timeout")

	// ErrFailed indicates a non-timeout lock acquisition failure.
	ErrFailed = errors.New("mysqllock: failed to acquire a lock")

	// ErrNilDB indicates that Acquire was called on a lock manager built with a
	// nil *sql.DB.
	ErrNilDB = errors.New("mysqllock: nil database handle")

	// ErrInvalidKey indicates that the lock key is empty or exceeds MySQL's
	// 64-character named-lock limit.
	ErrInvalidKey = errors.New("mysqllock: invalid lock key")

	// ErrInvalidTimeout indicates that the acquisition timeout is not positive.
	ErrInvalidTimeout = errors.New("mysqllock: non-positive timeout")

	// ErrLockLost indicates that the lock was lost before it was explicitly
	// released, for example because the keep-alive connection failed or
	// RELEASE_LOCK reported the lock was no longer held by this session.
	ErrLockLost = errors.New("mysqllock: lock lost")
)

Functions

This section is empty.

Types

type AcquireOption

type AcquireOption func(*acquireConfig)

AcquireOption configures a single MySQLLock.Acquire call.

func WithLostLockHandler

func WithLostLockHandler(handler func(error)) AcquireOption

WithLostLockHandler sets a handler invoked when this specific lock is lost while held (the keep-alive query failed). The handler receives an error wrapping ErrLockLost and naming the lock key, giving the caller a chance to abort the critical section.

The handler is called from the keep-alive goroutine, so it must be safe for concurrent use and should not block for long. A nil handler is ignored, and a panic in the handler is recovered rather than crashing the process. The handler may call the ReleaseFunc returned by MySQLLock.Acquire without deadlocking, though the usual pattern is to signal the critical section to stop and let its own deferred release run.

type MySQLLock

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

MySQLLock acquires and releases MySQL named locks through a sql.DB pool.

Create instances with New.

func New

func New(db *sql.DB, opts ...Option) *MySQLLock

New constructs distributed lock manager using MySQL named locks on provided database connection.

func (*MySQLLock) Acquire

func (l *MySQLLock) Acquire(ctx context.Context, key string, timeout time.Duration, opts ...AcquireOption) (ReleaseFunc, error)

Acquire acquires named lock with timeout, returning an idempotent release function and starting keep-alive management for the lock-owning connection.

It returns ErrInvalidKey or ErrInvalidTimeout for invalid input, ErrTimeout if the lock is not acquired within timeout, and ErrFailed for other acquisition failures. Per-acquisition behavior (such as lock-loss notification via WithLostLockHandler) is configured through opts.

type Option

type Option func(*MySQLLock)

Option configures a MySQLLock at construction time.

Options are additive; passing none preserves the historical default behavior.

func WithKeepAliveErrorHandler

func WithKeepAliveErrorHandler(handler func(error)) Option

WithKeepAliveErrorHandler sets a handler that receives errors produced by the keep-alive goroutine (failures of the periodic keep-alive query) while a lock is held. The error wraps ErrLockLost and names the affected lock key.

The handler is called from the keep-alive goroutine, so it must be safe for concurrent use and should not block for long. A nil handler is ignored, and a panic in the handler is recovered rather than crashing the process. For aborting a specific critical section prefer the per-acquisition WithLostLockHandler.

func WithKeepAliveInterval

func WithKeepAliveInterval(interval time.Duration) Option

WithKeepAliveInterval sets the period between keep-alive queries that keep the lock-owning connection active. Use a value shorter than the shortest idle timeout in the connection path (MySQL wait_timeout or any intervening proxy). A non-positive interval is ignored and the default (30s) is kept.

func WithKeepAlivePingTimeout

func WithKeepAlivePingTimeout(timeout time.Duration) Option

WithKeepAlivePingTimeout bounds how long a single keep-alive query may run before it is treated as a keep-alive failure (and thus a lost lock). This lets a hung connection be detected instead of stalling the keep-alive goroutine indefinitely. Choose a value smaller than the keep-alive interval. A non-positive timeout is ignored and the default (10s) is kept.

func WithReleaseTimeout

func WithReleaseTimeout(timeout time.Duration) Option

WithReleaseTimeout bounds how long the RELEASE_LOCK query invoked by the ReleaseFunc may run, so a wedged connection cannot block the caller indefinitely. A non-positive timeout is ignored and the default (10s) is kept.

type ReleaseFunc

type ReleaseFunc func() error

ReleaseFunc releases an acquired lock and its associated SQL connection.

It is returned by MySQLLock.Acquire. Callers should invoke it, typically using defer immediately after successful acquisition. It is idempotent and safe for concurrent use: the first call performs the release and subsequent calls return that same result.

Jump to

Keyboard shortcuts

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