locks

package
v1.99.0 Latest Latest
Warning

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

Go to latest
Published: Jun 20, 2026 License: GPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package locks is the single cross-process write-coordination abstraction for yarilo. One Locker API, one TAB-delimited wire protocol, two backends behind it: embedded (in-memory, Unix socket) for standalone; remote (mTLS TCP, Redis-backed) for backend deployments. See docs/DEPLOYMENT.md §yarilo-locks for the design.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrBusy is returned by Lock when the resource is already held.
	// The returned Lock value carries the current owner in its Owner field.
	ErrBusy = errors.New("locks: resource is busy")

	// ErrNotFound is returned by Unlock when the lock ID is unknown
	// (already expired, released, or never existed).
	ErrNotFound = errors.New("locks: lock not found")

	// ErrExpired is returned by Renew when the lock has been reclaimed by
	// the TTL sweeper before the renewal arrived.
	ErrExpired = errors.New("locks: lock expired")

	// ErrClosed is returned when the client is used after Close.
	ErrClosed = errors.New("locks: client closed")

	// ErrProtocol indicates a malformed wire-protocol frame.
	ErrProtocol = errors.New("locks: protocol violation")
)

Errors returned by Locker. Wrap with errors.Is at call sites.

Functions

func ACLListKey added in v1.39.0

func ACLListKey(home string) string

ACLListKey returns the lock key for the per-namespace yarilo-acl-list global index — one file per namespace-root that mirrors which mailboxes have explicit ACLs. Scoped by home directory rather than username so two users accessing the same shared-namespace root serialise their index updates against each other. Independent of MailboxKey: per-mailbox ACL writes take MailboxKey, then briefly take ACLListKey to splice their change into the index.

func DeliverKey

func DeliverKey(user, folder string) string

DeliverKey returns the lock key for serialising delivery into a mailbox. Acquire last. LMTP delivery uses this in addition to MailboxKey when the fan-out path needs to serialise per-recipient writes against IMAP STORE.

func IndexKey

func IndexKey(user string) string

IndexKey returns the lock key for the per-user index. Acquire first in any multi-resource sequence.

func ListenTLS

func ListenTLS(addr string, tlsCfg *tls.Config) (net.Listener, error)

ListenTLS opens a TCP listener wrapped in mTLS using the supplied config.

func ListenUnix

func ListenUnix(path string) (net.Listener, error)

ListenUnix opens a Unix socket listener. Caller is responsible for removing any stale socket at path before calling.

func MailboxKey

func MailboxKey(user, folder string) string

MailboxKey returns the lock key for a specific mailbox folder. Acquire after IndexKey, before DeliverKey.

func MdboxMapKey added in v1.30.0

func MdboxMapKey(user string) string

MdboxMapKey returns the lock key for the user-wide mdbox map (dovecot.map.index). The mdbox driver acquires this BEFORE any MailboxKey when allocating map_uids / file_ids; this is the strict map-then-folder order documented in docs/DOVECOT-STORAGE-COMPLIANCE.md §4.2.

func SieveScriptsKey

func SieveScriptsKey(user string) string

SieveScriptsKey returns the lock key for a user's Sieve script directory. Independent of mailbox keys — Sieve updates do not interleave with mail writes.

func SubscriptionsKey added in v1.14.0

func SubscriptionsKey(user string) string

SubscriptionsKey returns the lock key for a user's mailbox-subscription file (LIST-EXTENDED §SELECT SUBSCRIBED / RETURN SUBSCRIBED). Independent of mailbox keys — subscribe is metadata, never racing with mail writes.

func WithLock

func WithLock(ctx context.Context, l Locker, resource, owner string, ttl, renewEvery time.Duration, fn func(context.Context) error) error

WithLock acquires a lock on resource, starts a background renew loop, runs fn while the lock is held, and releases on exit. The renew cadence should be a small fraction of ttl (typically ttl/3).

fn receives a context that is cancelled if renewal fails — callers must honour the context and abort partially-completed writes when it fires.

Errors from Lock or Unlock propagate as-is. Errors from fn are returned after the lock is released. Errors from Renew abort fn (via context) and surface as the function's return value.

Types

type Backend

type Backend interface {
	// Acquire attempts to take the lock. Returns the new lock ID on success.
	// On contention, returns ErrBusy with currentOwner populated.
	Acquire(ctx context.Context, resource, owner string, ttl time.Duration) (lockID string, currentOwner string, err error)

	// Release deletes the lock. Returns ErrNotFound if the lock does not
	// exist anymore (expired or already released).
	Release(ctx context.Context, lockID string) error

	// Renew extends the TTL. Returns ErrExpired if the lock no longer exists.
	Renew(ctx context.Context, lockID string, ttl time.Duration) error

	// Publish broadcasts an event to all subscribers of resource.
	Publish(ctx context.Context, resource string, t EventType, payload string) error

	// Subscribe returns a channel for all events on resource. The returned
	// cancel function must be called to release backend resources.
	Subscribe(ctx context.Context, resource string) (<-chan Event, func(), error)

	// IncrementCounter atomically adds delta to the persistent counter
	// at key and returns the new value. Missing keys are auto-initialised
	// to 0 before applying delta. Counter state is independent of the
	// lock state: same backend, separate key namespace, no TTL.
	IncrementCounter(ctx context.Context, key string, delta int64) (int64, error)

	// Close shuts the backend down — closes Redis pool, stops sweepers, etc.
	Close() error
}

Backend is the server-side state interface. MemoryBackend (in-memory map) and RedisBackend (Redis Lua SET NX EX) implement it. Server is wire+I/O only — all state lives here.

type Client

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

Client is the Locker implementation talking the TAB-delimited wire protocol. One control connection per Client, shared across goroutines via mu. SUBSCRIBE opens a new dedicated connection (returned to caller via the channel).

Owner convention: callers should pass "<process>/<pid>/<sessionID>" so the BUSY response identifies the contending peer in logs. Not enforced.

func NewClient

func NewClient(ctx context.Context, dial Dialer) (*Client, error)

NewClient opens a control connection and performs the version handshake. The returned Client owns the connection — call Close to release it.

func (*Client) Close

func (c *Client) Close() error

Close implements Locker. Idempotent.

func (*Client) Emit

func (c *Client) Emit(ctx context.Context, resource string, t EventType, payload string) error

Emit implements Locker.

func (*Client) HoldsResource added in v1.12.0

func (c *Client) HoldsResource(resource string) bool

HoldsResource implements Locker. Cheap RLock — safe to call from inside any roundtrip without risk of recursion. Returns true only when the CALLING goroutine itself has Acquired this resource; concurrent goroutines on the same client see false and go through normal Acquire.

func (*Client) IncrementCounter added in v1.35.0

func (c *Client) IncrementCounter(ctx context.Context, key string, delta int64) (int64, error)

IncrementCounter implements Locker. Atomically adds delta to the counter at key and returns the post-increment value. Sent over the shared control conn, so concurrent callers serialise through c.mu — the backend itself is still race-free for cross-process callers because the underlying store (Redis INCRBY or MemoryBackend mutex) is atomic.

func (*Client) Lock

func (c *Client) Lock(ctx context.Context, resource, owner string, ttl time.Duration) (Lock, error)

Lock implements Locker.

func (*Client) Renew

func (c *Client) Renew(ctx context.Context, lockID string, ttl time.Duration) error

Renew implements Locker.

func (*Client) Subscribe

func (c *Client) Subscribe(ctx context.Context, resource string) (<-chan Event, error)

Subscribe implements Locker. Opens a dedicated connection for the subscription so it does not block the control conn. The returned channel is closed when ctx is cancelled, the conn drops, or the server closes.

func (*Client) Unlock

func (c *Client) Unlock(ctx context.Context, lockID string) error

Unlock implements Locker.

type Dialer

type Dialer func(ctx context.Context) (net.Conn, error)

Dialer opens a fresh connection to the locks server. Used for both the control conn at NewClient time and per-subscription conns.

func DialTCP added in v1.43.1

func DialTCP(addr string) Dialer

DialTCP returns a Dialer for remote mode over plain TCP (no TLS).

func DialTLS

func DialTLS(addr string, tlsCfg *tls.Config) Dialer

DialTLS returns a Dialer for remote mode (mTLS TCP).

func DialUnix

func DialUnix(path string) Dialer

DialUnix returns a Dialer for embedded mode (Unix socket).

type Event

type Event struct {
	Resource string
	Type     EventType
	Payload  string
}

Event is a server-emitted notification published on a resource channel.

type EventType

type EventType string

EventType classifies an EVENT emitted by the server.

const (
	EventChanged   EventType = "changed"   // mailbox contents changed
	EventDelivered EventType = "delivered" // new message arrived (LMTP)
	EventExpunged  EventType = "expunged"  // message removed
)

Built-in event types. Callers may emit custom string types in addition.

type Lock

type Lock struct {
	ID        string
	Resource  string
	Owner     string
	ExpiresAt time.Time
}

Lock represents a successfully acquired lock. Owners renew before ExpiresAt or the lock is reclaimed by the TTL sweeper.

func Acquire added in v1.5.0

func Acquire(ctx context.Context, l Locker, resource, owner string, ttl time.Duration) (Lock, error)

Acquire is Lock with blocking semantics: retries on ErrBusy with exponential backoff (1ms → 100ms cap, small jitter) until ctx is cancelled or the lock is taken. Use this when the caller wants "wait until available" — typical for storage write paths where contention is short and bounded.

Returns the acquired Lock on success, or the last underlying error (context cancellation or non-busy failure) otherwise.

type Locker

type Locker interface {
	// Lock acquires an exclusive lock on resource for owner. ttl bounds the
	// hold time so a crashed owner cannot deadlock the system; callers must
	// Renew before expiry.
	//
	// Returns ErrBusy with the current owner captured in the returned Lock's
	// Owner field if the resource is already locked.
	Lock(ctx context.Context, resource, owner string, ttl time.Duration) (Lock, error)

	// Unlock releases a previously acquired lock by ID. Returns ErrNotFound
	// if the lock has already expired or been released.
	Unlock(ctx context.Context, lockID string) error

	// Renew extends the TTL of an existing lock. Returns ErrExpired if the
	// lock has already been reclaimed by the TTL sweeper.
	Renew(ctx context.Context, lockID string, ttl time.Duration) error

	// Subscribe returns a channel that receives every EVENT for resource.
	// The channel is closed when ctx is cancelled or the underlying
	// connection drops.
	Subscribe(ctx context.Context, resource string) (<-chan Event, error)

	// Emit publishes an event on resource. Used by writers (LMTP delivery,
	// IMAP APPEND/EXPUNGE) to notify IDLE subscribers.
	Emit(ctx context.Context, resource string, t EventType, payload string) error

	// HoldsResource reports whether this client currently holds an active
	// lock on resource. Storage backends consult it to skip re-acquiring
	// a lock they already hold via an outer scope — without this, a batch
	// operation (POP3 QUIT, IMAP-side multi-message Expunge) that takes
	// one outer X lock and then calls per-message storage methods would
	// deadlock on its own owner (yarilo-locks is non-reentrant by design).
	HoldsResource(resource string) bool

	// IncrementCounter atomically adds delta to the persistent counter
	// at key and returns the post-increment value. Used for shared
	// monotonic counters that must survive process restart and stay
	// race-free across pods — e.g. mdbox next_file_id allocation.
	//
	// Counters are independent of lock state: they share no key space
	// with Lock resources and are not affected by TTL sweeping.
	// First reference auto-initialises the counter to 0 before
	// applying delta.
	IncrementCounter(ctx context.Context, key string, delta int64) (int64, error)

	// Close terminates the underlying transport.
	Close() error
}

Locker is the single client-side API for cross-process write coordination. All session processes that mutate shared mailbox/index files go through this interface. Implementations talk the same TAB-delimited wire protocol over either a Unix socket (embedded) or mTLS TCP (remote).

type MemoryBackend

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

MemoryBackend is the in-memory state backend used by embedded mode (standalone deployments). State is ephemeral: on process crash all locks are lost, which is acceptable for standalone where every session process restarts with the pod.

func NewMemoryBackend

func NewMemoryBackend(opts ...MemoryBackendOption) *MemoryBackend

NewMemoryBackend returns a fresh in-memory backend and starts its background TTL sweeper. Call Close to stop the sweeper and release subscriber goroutines.

func (*MemoryBackend) Acquire

func (b *MemoryBackend) Acquire(_ context.Context, resource, owner string, ttl time.Duration) (string, string, error)

Acquire implements Backend.

func (*MemoryBackend) Close

func (b *MemoryBackend) Close() error

Close implements Backend. Idempotent.

func (*MemoryBackend) IncrementCounter added in v1.35.0

func (b *MemoryBackend) IncrementCounter(_ context.Context, key string, delta int64) (int64, error)

IncrementCounter implements Backend. Auto-initialises missing keys to 0 before applying delta. Counter state is independent of the lock state and is not touched by the TTL sweeper.

func (*MemoryBackend) Publish

func (b *MemoryBackend) Publish(_ context.Context, resource string, t EventType, payload string) error

Publish implements Backend.

func (*MemoryBackend) Release

func (b *MemoryBackend) Release(_ context.Context, lockID string) error

Release implements Backend.

func (*MemoryBackend) Renew

func (b *MemoryBackend) Renew(_ context.Context, lockID string, ttl time.Duration) error

Renew implements Backend.

func (*MemoryBackend) Subscribe

func (b *MemoryBackend) Subscribe(_ context.Context, resource string) (<-chan Event, func(), error)

Subscribe implements Backend.

type MemoryBackendOption

type MemoryBackendOption func(*MemoryBackend)

MemoryBackendOption tunes the backend at construction time.

func WithNow

func WithNow(now func() time.Time) MemoryBackendOption

WithNow overrides the time source. Tests use this to drive deterministic expirations without sleeping.

func WithSweepInterval

func WithSweepInterval(d time.Duration) MemoryBackendOption

WithSweepInterval overrides the default TTL sweep cadence (100 ms). Tests typically override this to a short value (1 ms) so expirations land inside test deadlines.

type Metrics

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

Metrics is the Prometheus metric set emitted by Server. The mode label is fixed at construction ("embedded" or "remote") so dashboards can split behaviour without explosion-prone per-resource labels.

func NewMetrics

func NewMetrics(r prometheus.Registerer, mode string) *Metrics

NewMetrics constructs and registers the metric set on r. If r is nil the default registry is used. mode is one of "embedded" | "remote" and tags every series; pass "" to fall back to "unknown".

type RedisBackend

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

RedisBackend stores lock state in Redis. Used by remote mode in backend deployments where multiple yarilo-locks replicas must share a state view for HA. Atomic acquisition via SET NX EX, atomic ownership-checked release/renew via Lua to avoid lost-update races between concurrent owners.

func NewRedisBackend

func NewRedisBackend(rdb redis.UniversalClient, opts ...RedisOption) *RedisBackend

NewRedisBackend wraps a redis client. The caller owns the client lifecycle outside of Close, which closes the wrapped client.

func (*RedisBackend) Acquire

func (b *RedisBackend) Acquire(ctx context.Context, resource, owner string, ttl time.Duration) (string, string, error)

Acquire implements Backend.

func (*RedisBackend) Close

func (b *RedisBackend) Close() error

Close implements Backend. Closes the wrapped Redis client.

func (*RedisBackend) IncrementCounter added in v1.35.0

func (b *RedisBackend) IncrementCounter(ctx context.Context, key string, delta int64) (int64, error)

IncrementCounter implements Backend via INCRBY. Missing keys are initialised to 0 by Redis automatically before applying delta. Counter keys live under "<keyPrefix>counter:<key>" so they never collide with lock state keys.

func (*RedisBackend) Publish

func (b *RedisBackend) Publish(ctx context.Context, resource string, t EventType, payload string) error

Publish implements Backend.

func (*RedisBackend) Release

func (b *RedisBackend) Release(ctx context.Context, lockID string) error

Release implements Backend.

func (*RedisBackend) Renew

func (b *RedisBackend) Renew(ctx context.Context, lockID string, ttl time.Duration) error

Renew implements Backend.

func (*RedisBackend) Subscribe

func (b *RedisBackend) Subscribe(ctx context.Context, resource string) (<-chan Event, func(), error)

Subscribe implements Backend. The cancel function must be called to release the underlying Redis subscription goroutine.

type RedisOption

type RedisOption func(*RedisBackend)

RedisOption tunes the backend at construction time.

func WithChannelPrefix

func WithChannelPrefix(p string) RedisOption

WithChannelPrefix overrides the Redis channel prefix used for events. Default: "yarilo:events:". Channels take the form "<prefix><resource>".

func WithKeyPrefix

func WithKeyPrefix(p string) RedisOption

WithKeyPrefix overrides the Redis key prefix used for lock state. Default: "yarilo:locks:". Keys take the form "<prefix><lockID>".

type Server

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

Server is the wire-level locks server. It is transport-agnostic — call Serve with any net.Listener (Unix socket for embedded, TLS-wrapped TCP for remote). All state lives in the supplied Backend.

func NewServer

func NewServer(backend Backend, logger *slog.Logger, metrics *Metrics) *Server

NewServer constructs a Server around a Backend. The Backend's lifecycle is owned by the caller — Server.Close does not call backend.Close.

A nil metrics argument means "no metrics" — the Metrics methods are nil-safe. Tests and benchmarks that spin up many servers should pass nil to avoid duplicate-registration panics on prometheus.DefaultRegisterer.

func (*Server) Close

func (s *Server) Close()

Close stops accepting new connections and waits for in-flight handlers to drain. Idempotent.

func (*Server) Serve

func (s *Server) Serve(ctx context.Context, ln net.Listener) error

Serve accepts connections on ln until ctx is cancelled or ln.Accept errors terminally. ln is closed by Serve before returning.

Jump to

Keyboard shortcuts

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