mutex

package
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 4, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package mutex implements the on-disk lease store for agentmutex.

Layout under the state root (default ~/.agentmutex):

locks/<encoded-key>/guard        per-key mutation guard (flock target)
locks/<encoded-key>/holder.json  the current lease, if any
locks/<encoded-key>/queue/       FIFO waiter entries

Every mutation (acquire, release, renew, prune) runs while holding the per-key guard, so read-modify-write sequences are serialized across processes without any daemon. holder.json is replaced via temp-file + rename (mode 0600, under a 0700 tree), so guard-free readers (status, list) always see a complete document and the lease token is not world-readable.

Waiter entries are named "<zero-padded-unixnano>-<id>.json", so lexicographic order is arrival order. The id is a public queue identity, distinct from the private lease token. A waiter proves it is alive by touching its entry's mtime on every poll; entries whose mtime is older than WaiterStaleAfter belong to dead waiters and are skipped.

Index

Constants

View Source
const (
	DefaultTTL              = 15 * time.Minute
	DefaultPollInterval     = 1 * time.Second
	DefaultWaiterStaleAfter = 30 * time.Second
	// DefaultWaiterPIDGrace bounds how long a *live* same-host waiter may go
	// without a heartbeat before it is treated as dead anyway. It is generous
	// because a deploy that saturates the box can starve a co-located queued
	// agent of CPU for a while; PID-liveness keeps its FIFO slot until then.
	DefaultWaiterPIDGrace = 5 * time.Minute
)

Defaults. Deploy-sized critical sections run minutes, and waiting agents are inference-bound (they think for seconds to minutes between actions), so generous TTLs and second-scale polling are free.

View Source
const MaxKeyLen = 80

MaxKeyLen bounds key length so encoded directory names stay well under filesystem name limits (255 bytes) even when every byte is %XX-escaped.

Variables

View Source
var ErrNotHeld = errors.New("no lease held for this key")

ErrNotHeld is returned when releasing or renewing a key that has no lease.

Functions

func DecodeKey

func DecodeKey(name string) (string, error)

DecodeKey reverses EncodeKey.

func EncodeKey

func EncodeKey(key string) string

EncodeKey converts a semantic key into a filesystem-safe directory name. Bytes outside [A-Za-z0-9._-] are percent-encoded, so the mapping is reversible and safe on every platform (":" is not legal on Windows). Windows-reserved device names and trailing dots are escaped too.

func NewToken

func NewToken() string

NewToken returns a fresh lease token: 128 bits of randomness, hex-encoded. Only the holder of the token can release or renew the lease.

func ValidateKey

func ValidateKey(key string) error

ValidateKey checks that key is a usable semantic key. Keys are structured namespaces such as "deploy:staging" or "account:12345:balance". They must start with an alphanumeric character and may contain letters, digits and the separators ". _ - : /".

Types

type AcquireOpts

type AcquireOpts struct {
	TTL    time.Duration
	Agent  string
	PID    int
	Host   string
	Reason string
	// WaiterID is this caller's queue identity (see Waiter.ID). When set,
	// FIFO fairness lets us take the lease only if we are the fresh queue
	// head, and our queue entry is removed on success.
	WaiterID string
	// Enqueued indicates we have a queue entry that should be removed if
	// the acquire succeeds.
	Enqueued bool
	// Reclaim skips the FIFO queue-head check: use it to re-establish a
	// lease you were actively holding (e.g. `run` re-taking a key that was
	// force-released or pruned out from under an in-flight command). It
	// still refuses a key currently held by a different live holder.
	Reclaim bool
}

AcquireOpts parameterizes TryAcquire.

type AcquireResult

type AcquireResult struct {
	Acquired bool
	// Holder is our new lease when Acquired, otherwise the current
	// unexpired holder blocking us (nil if the key is free).
	Holder *Holder
	// Blocker is the fresh queue head ahead of us, when the key is free
	// (or expired) but FIFO order says it is not our turn.
	Blocker *Waiter
}

AcquireResult reports one TryAcquire attempt.

type CorruptError

type CorruptError struct {
	Path string
	Err  error
}

CorruptError is returned when a state file exists but cannot be parsed. The store never steals a lease it cannot read; use force-release to clear.

func (*CorruptError) Error

func (e *CorruptError) Error() string

type Holder

type Holder struct {
	Key        string     `json:"key"`
	Token      string     `json:"token,omitempty"`
	Agent      string     `json:"agent"`
	PID        int        `json:"pid"`
	Host       string     `json:"host"`
	Reason     string     `json:"reason,omitempty"`
	AcquiredAt time.Time  `json:"acquired_at"`
	ExpiresAt  time.Time  `json:"expires_at"`
	RenewedAt  *time.Time `json:"renewed_at,omitempty"`
}

Holder is a lease on a semantic key. Token is omitempty so display paths can redact it (the CLI blanks tokens in status/list output).

func (*Holder) ExpiredAt

func (h *Holder) ExpiredAt(now time.Time) bool

ExpiredAt reports whether the lease is expired as of now.

type KeyStatus

type KeyStatus struct {
	Key string `json:"key"`
	// State is "held", "expired", "free", "corrupt" (holder.json is
	// unparseable) or "unreadable" (an I/O error prevented reading it).
	State   string   `json:"state"`
	Holder  *Holder  `json:"holder,omitempty"`
	Waiters []Waiter `json:"waiters"`
	// Error carries the underlying message when State is "unreadable".
	Error string `json:"error,omitempty"`
}

KeyStatus is a read-only snapshot of one key.

type NotHolderError

type NotHolderError struct{ Holder *Holder }

NotHolderError is returned when the presented token does not match the current lease. It carries the actual holder for diagnostics.

func (*NotHolderError) Error

func (e *NotHolderError) Error() string

type PruneResult

type PruneResult struct {
	ExpiredLeases []string `json:"expired_leases"`
	StaleWaiters  int      `json:"stale_waiters"`
	// Errors holds per-key failures; Prune keeps going past them so one
	// wedged key cannot hide cleanup of all the others.
	Errors []string `json:"errors,omitempty"`
}

PruneResult reports what Prune removed.

type Store

type Store struct {
	Root             string
	WaiterStaleAfter time.Duration
	WaiterPIDGrace   time.Duration
	// contains filtered or unexported fields
}

Store is an on-disk lease store. Safe for concurrent use by any number of processes on the same machine. (Not across hosts: the guard is flock(2) and waiter liveness compares this host's clock against entry mtimes.)

func Open

func Open(root string) (*Store, error)

Open opens (creating if needed) the store at root. An empty root falls back to $AGENTMUTEX_DIR, then ~/.agentmutex.

func (*Store) Dequeue

func (s *Store) Dequeue(entryPath string) error

Dequeue removes a waiter entry. Missing entries are fine (the acquire that succeeded already removed it).

func (*Store) Enqueue

func (s *Store) Enqueue(key string, w Waiter) (string, error)

Enqueue registers w as a waiter for key and returns the entry path, which the caller uses for Heartbeat and Dequeue. Creating a uniquely-named file is atomic, so no guard is needed.

func (*Store) Heartbeat

func (s *Store) Heartbeat(entryPath string, w Waiter) error

Heartbeat proves the waiter behind entryPath is still alive by touching its mtime. If the entry vanished (e.g. an aggressive prune), it is re-created so the waiter keeps its queue position.

func (*Store) List

func (s *Store) List() ([]KeyStatus, error)

List returns snapshots of every key the store knows about, sorted by key. Keys whose state cannot be read are still reported (State "unreadable") rather than dropped, so a wedged lock never looks like it does not exist.

func (*Store) Prune

func (s *Store) Prune() (*PruneResult, error)

Prune removes expired leases, stale waiter entries and orphaned temp files. It never removes lock directories: an open guard file descriptor in another process must stay valid, and empty directories are free.

func (*Store) Release

func (s *Store) Release(key, token string, force bool) (*Holder, error)

Release drops the lease for key. Without force, token must match the current lease. Returns the released holder (nil if force-releasing corrupt state).

func (*Store) Renew

func (s *Store) Renew(key, token string, ttl time.Duration) (*Holder, error)

Renew extends the lease for key by ttl from now. Token must match. A lease that expired but has not been displaced yet is revived — better to keep a live agent's lease than to invite a collision.

func (*Store) Status

func (s *Store) Status(key string) (*KeyStatus, error)

Status returns a read-only snapshot of key. It takes no guard: holder.json is replaced atomically, so the read is consistent.

func (*Store) TryAcquire

func (s *Store) TryAcquire(key, token string, o AcquireOpts) (*AcquireResult, error)

TryAcquire makes one attempt to take the lease. It never blocks (beyond the millisecond-scale guard). Pessimistic waiting is the caller's poll loop around this.

type Waiter

type Waiter struct {
	ID         string    `json:"id"`
	Agent      string    `json:"agent"`
	PID        int       `json:"pid"`
	Host       string    `json:"host"`
	Reason     string    `json:"reason,omitempty"`
	EnqueuedAt time.Time `json:"enqueued_at"`

	// Fresh and LastSeen are computed from the entry's mtime on read; the
	// on-disk "fresh" field is written true and ignored when reading.
	Fresh    bool      `json:"fresh"`
	LastSeen time.Time `json:"last_seen,omitzero"`
}

Waiter is a queued acquire attempt.

ID is a public queue identity, deliberately distinct from the private lease token: a waiter's entry (name and body) is visible to any local process, so putting the lease token here would hand out the credential that release/renew require. FIFO ordering keys on ID, never on the token.

Jump to

Keyboard shortcuts

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