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 ¶
- Variables
- func ACLListKey(home string) string
- func DeliverKey(user, folder string) string
- func IndexKey(user string) string
- func ListenTLS(addr string, tlsCfg *tls.Config) (net.Listener, error)
- func ListenUnix(path string) (net.Listener, error)
- func MailboxKey(user, folder string) string
- func MdboxMapKey(user string) string
- func SieveScriptsKey(user string) string
- func SubscriptionsKey(user string) string
- func WithLock(ctx context.Context, l Locker, resource, owner string, ...) error
- type Backend
- type Client
- func (c *Client) Close() error
- func (c *Client) Emit(ctx context.Context, resource string, t EventType, payload string) error
- func (c *Client) HoldsResource(resource string) bool
- func (c *Client) IncrementCounter(ctx context.Context, key string, delta int64) (int64, error)
- func (c *Client) Lock(ctx context.Context, resource, owner string, ttl time.Duration) (Lock, error)
- func (c *Client) Renew(ctx context.Context, lockID string, ttl time.Duration) error
- func (c *Client) Subscribe(ctx context.Context, resource string) (<-chan Event, error)
- func (c *Client) Unlock(ctx context.Context, lockID string) error
- type Dialer
- type Event
- type EventType
- type Lock
- type Locker
- type MemoryBackend
- func (b *MemoryBackend) Acquire(_ context.Context, resource, owner string, ttl time.Duration) (string, string, error)
- func (b *MemoryBackend) Close() error
- func (b *MemoryBackend) IncrementCounter(_ context.Context, key string, delta int64) (int64, error)
- func (b *MemoryBackend) Publish(_ context.Context, resource string, t EventType, payload string) error
- func (b *MemoryBackend) Release(_ context.Context, lockID string) error
- func (b *MemoryBackend) Renew(_ context.Context, lockID string, ttl time.Duration) error
- func (b *MemoryBackend) Subscribe(_ context.Context, resource string) (<-chan Event, func(), error)
- type MemoryBackendOption
- type Metrics
- type RedisBackend
- func (b *RedisBackend) Acquire(ctx context.Context, resource, owner string, ttl time.Duration) (string, string, error)
- func (b *RedisBackend) Close() error
- func (b *RedisBackend) IncrementCounter(ctx context.Context, key string, delta int64) (int64, error)
- func (b *RedisBackend) Publish(ctx context.Context, resource string, t EventType, payload string) error
- func (b *RedisBackend) Release(ctx context.Context, lockID string) error
- func (b *RedisBackend) Renew(ctx context.Context, lockID string, ttl time.Duration) error
- func (b *RedisBackend) Subscribe(ctx context.Context, resource string) (<-chan Event, func(), error)
- type RedisOption
- type Server
Constants ¶
This section is empty.
Variables ¶
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
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 ¶
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 ¶
IndexKey returns the lock key for the per-user index. Acquire first in any multi-resource sequence.
func ListenUnix ¶
ListenUnix opens a Unix socket listener. Caller is responsible for removing any stale socket at path before calling.
func MailboxKey ¶
MailboxKey returns the lock key for a specific mailbox folder. Acquire after IndexKey, before DeliverKey.
func MdboxMapKey ¶ added in v1.30.0
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 ¶
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
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 ¶
NewClient opens a control connection and performs the version handshake. The returned Client owns the connection — call Close to release it.
func (*Client) HoldsResource ¶ added in v1.12.0
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
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.
type Dialer ¶
Dialer opens a fresh connection to the locks server. Used for both the control conn at NewClient time and per-subscription conns.
type Lock ¶
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
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.
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.
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 ¶
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.