Documentation
¶
Overview ¶
Package distlock defines the provider-neutral distributed-lock contract for the GoCell runtime layer. Concrete backend implementations live in adapters/ (currently only adapters/redis).
Design rationale ¶
GoCell's layering rule prohibits runtime/ from importing adapters/, so the Locker / Lock interfaces must live here rather than in adapters/redis. The shape follows PR#177's runtime/outbox.Store precedent exactly.
Resource model ¶
Each call to New() creates one Manager. The Manager's resource footprint per active lock set is:
- 1 manager goroutine: owns the renewal min-heap and all Driver I/O calls
- 0 per-lock goroutines: lockCtx is derived from the caller's ctx, so parent cancellation (including custom causes set via context.WithCancelCause), values, and deadlines propagate automatically via Go's context machinery. No watcher goroutine is needed.
N active locks = 1 manager goroutine + O(N) heap. One goroutine for N locks.
Non-goals ¶
This is an efficiency lock, NOT a correctness lock. It is suitable for avoiding duplicate work (e.g., "only one pod runs a scheduled job"). For correctness-critical paths use application-level conditional writes (e.g., Postgres optimistic locking with row versions). This matches the Redlock paper's own scoping: Redsync / redis/v9 make the same disclaimer.
References ¶
- ref: github.com/go-redsync/redsync mutex.go — Lock/Unlock/Extend shape rejected because GoCell auto-renews; Extend on the contract is backend-specific
- ref: github.com/etcd-io/etcd client/v3/concurrency/mutex.go — CAS-storage shape, not adopted (over-specified for the GoCell use case)
- ref: github.com/hashicorp/consul/api lock.go — lostCh pattern adopted as Lost()
- ref: github.com/temporalio/sdk-go internal/internal_worker.go — stopC signal-by-close idiom
- ref: PR#177 runtime/outbox.Store — identical layering rationale
Index ¶
Constants ¶
const ErrLockTimeout = errcode.ErrDistlockTimeout
ErrLockTimeout is a package-level alias for errcode.ErrDistlockTimeout. It is returned by Acquire when the key is already held by another holder. The canonical definition lives in pkg/errcode for cross-package matching at HTTP handler boundaries; this alias keeps call sites within distlock concise.
Variables ¶
var ( // ErrLockLost is set as the context cause when the manager fails to renew // the lock or the backend reports ownership has been taken by another holder. ErrLockLost = errcode.New(errcode.KindConflict, errcode.ErrDistlockLockLost, "distlock: lock lost") // ErrLockReleased is set as the context cause when release() is called // by the application (normal end-of-critical-section). ErrLockReleased = errcode.New(errcode.KindConflict, errcode.ErrDistlockLockReleased, "distlock: lock released") )
Sentinel errors used as context.Cause values on the lock-derived context. Callers distinguish them via errors.Is(context.Cause(lockCtx), ErrLockLost) or direct == comparison (context.Cause returns the exact pointer stored by state.cancel). *errcode.Error satisfies both: interface equality is pointer- based, so the package-level var pointer serves as a stable identity just like errors.New did.
Note on errors.Is: *errcode.Error has no custom Is(target error) bool method; errors.Is matches by package-level pointer identity. Callers that wrap with fmt.Errorf("%w", ErrLockLost) still work via Unwrap chain traversal. To match by Code regardless of pointer identity, use:
var ec *errcode.Error
if errors.As(err, &ec) && ec.Code == errcode.ErrDistlockLockLost { ... }
Functions ¶
This section is empty.
Types ¶
type Driver ¶
type Driver interface {
// SetNX attempts to acquire the lock for key with the given token and TTL.
// Returns (true, nil) on success, (false, nil) when another holder owns the
// key (not an error — caller interprets false as "busy"), and (false, err)
// on I/O failure.
SetNX(ctx context.Context, key, token string, ttl time.Duration) (acquired bool, err error)
// Renew extends the TTL of an existing lock only if token still matches.
// Returns (true, nil) on success, (false, nil) when the token no longer
// matches (ownership lost — not an I/O error), and (false, err) on I/O failure.
Renew(ctx context.Context, key, token string, ttl time.Duration) (held bool, err error)
// Release deletes the lock key only if token still matches.
// Returns nil on success or when the key is already gone (idempotent).
// Returns a non-nil error only on I/O failure.
Release(ctx context.Context, key, token string) error
}
Driver is the storage-backend contract for distributed locks. Implementations live in adapters/ (e.g. adapters/redis).
Three semantic primitives encapsulate all backend-specific logic (e.g. Lua scripts for Redis), keeping the runtime layer backend-agnostic.
ref: kubernetes/client-go tools/leaderelection/resourcelock/interface.go
— storage primitives shape adopted; GoCell collapses to 3 methods vs k8s 5 because Get/Update/Create/Delete lifecycle is not needed here.
ref: go-redsync/redsync redsync.go — SetNX/Renew/Release semantics
type Locker ¶
type Locker interface {
// Acquire blocks until the lock is granted or ctx is canceled.
//
// On success it returns:
// - lockCtx: a derived context canceled when the lock ends
// - release: must be called to release the lock; idempotent
// - nil error
//
// lockCtx cancel causes:
// - ErrLockReleased — release() was called (normal end-of-critical-section)
// - ErrLockLost — renewal failed or backend reports ownership taken
// - context.Cause(ctx) — parent context was canceled; values, deadline, and
// parent cancellation propagate naturally via Go context machinery.
// context.Cause(lockCtx) returns context.Cause(ctx) when the parent cancels,
// including custom causes set via context.WithCancelCause.
// - context.Canceled — manager forced exit during shutdown drain
//
// lockCtx is derived from ctx: caller-side context values (trace IDs, auth
// claims), deadline, and parent cancellation all propagate automatically.
//
// Do not pass lockCtx to a goroutine whose lifetime should outlive the lock.
// lockCtx is canceled the instant the lock ends.
//
// On failure it returns (nil, nil, err) where err carries ErrLockTimeout when
// another holder owns the key, or ctx.Err() if the parent was canceled.
//
// The lock is auto-renewed by a single shared manager goroutine (not per-lock)
// until release() is called or renewal fails.
// N active locks = 1 manager goroutine + O(N) heap. Zero per-lock goroutines.
//
// release() internally uses context.Background() with WithReleaseTimeout (default
// 5s) as the Driver.Release deadline. It blocks until the I/O completes and
// returns nil on success or a wrapped error on I/O failure. release() is
// idempotent — a second call returns nil without contacting the backend.
Acquire(ctx context.Context, key string, ttl time.Duration) (lockCtx context.Context, release func() error, err error)
// Stats reports observable state of the Locker for health checks and metrics.
Stats() Stats
}
Locker acquires named distributed locks.
Lock-as-Context design ¶
Acquire returns a derived context that is automatically canceled when the lock ends. The cause distinguishes how it ended:
context.Cause(lockCtx) == ErrLockReleased — release() called context.Cause(lockCtx) == ErrLockLost — renewal failed / ownership taken context.Cause(lockCtx) == context.Cause(ctx) — parent context was canceled context.Cause(lockCtx) == context.Canceled (or another error) — manager forced exit
Use context.Cause(lockCtx) (not lockCtx.Err()) to distinguish causes.
This mirrors context.WithDeadline(parent) (Context, CancelFunc) so callers pass lockCtx directly to database calls, HTTP requests, or outbox.Emit — all downstream operations are canceled automatically on lock loss.
ref: golang stdlib context.WithDeadline — API shape adopted directly ref: go-redsync/redsync — per-lock goroutine model replaced by shared manager
func New ¶
New creates a Locker backed by the given Driver.
Returns an error if driver is nil or if any configuration parameter is out of range:
- renewFraction must be in (0, 1)
- driftFactor must be in [0, 1)
- releaseTimeout must be > 0
The returned Locker uses a single shared manager goroutine for all locks. Resource shape:
- 1 manager goroutine (owns the renewal heap and all Driver calls)
- 0 per-lock goroutines — lockCtx is derived from ctx, so parent cancellation propagates automatically via Go context machinery.
N active locks = 1 manager goroutine + O(N) heap.
ref: plan "共享 manager goroutine" section
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager runs a single shared goroutine that owns the renewal heap and calls Driver.Renew for all active locks.
The manager goroutine is the SOLE writer of the heap and locks map, which eliminates data races. External callers communicate exclusively through the events channel.
Lifecycle:
- lazy-started on first Acquire (via lockerImpl.add)
- manager exits when the last lock is removed
- started is closed once the manager enters its main select loop
- drained is closed once the manager exits after the last lock removal
ref: golang.org/x/tools/internal/event — single goroutine dispatch pattern
func (*Manager) Drained ¶
func (m *Manager) Drained() <-chan struct{}
Drained returns a channel that is closed once the manager goroutine exits after the last lock is released.
func (*Manager) RenewNotify ¶
func (m *Manager) RenewNotify() <-chan struct{}
RenewNotify returns a read-only channel that receives a signal after each successful Driver.Renew call. Intended for test synchronization only.
func (*Manager) Snapshot ¶
func (m *Manager) Snapshot() ManagerSnapshot
Snapshot returns a read-only view of current manager state.
type ManagerSnapshot ¶
type ManagerSnapshot struct {
// Locks is the number of active locks currently tracked.
Locks int
}
ManagerSnapshot is a read-only view of the manager's current state. Exported for testing only.
type Option ¶
type Option func(*config)
Option is a functional option for configuring a Locker.
func WithDriftFactor ¶
WithDriftFactor sets the renewal I/O timeout safety margin. The Renew RPC context deadline is set to clock.Now() + ttl × (1 − driftFactor), so the manager gives up on a slow Driver.Renew before the backend key would expire. Does NOT alter the TTL written to the backend.
Recommended range: 0.01–0.05. Higher values make Renew calls fail more often under transient I/O slowness; lower values risk the call outliving the backend TTL on slow networks. Must be in [0, 1). Default: 0.01.
ref: go-redsync/redsync redsync.go driftFactor=0.01
func WithMaxRenewAttempts ¶
WithMaxRenewAttempts sets the maximum number of Driver.Renew attempts per renewal tick before the lock is declared lost. Must be ≥ 1. Default: 3.
Only transient I/O errors (err != nil) are retried; permanent ownership-lost responses (held=false, err=nil) immediately declare the lock lost regardless of this setting.
All retry attempts share the same renewTimeout window derived from the lock TTL and drift factor. New() panics if the final value is < 1.
func WithReleaseTimeout ¶
WithReleaseTimeout sets the context deadline applied to each background Driver.Release call issued by the fire-and-forget release path. If Redis (or another backend) hangs, the Release goroutine will be unblocked after this duration rather than leaking indefinitely.
Default: 5s (conservative; tune down for low-latency backends or up for high-latency ones). Must be > 0; New() panics if the final value is ≤ 0.
func WithRenewFraction ¶
WithRenewFraction sets the fraction of TTL at which the shared manager schedules the next renewal. Must be in (0, 1). Default: 0.5.