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
- Variables
- func DecodeKey(name string) (string, error)
- func EncodeKey(key string) string
- func NewToken() string
- func ValidateKey(key string) error
- type AcquireOpts
- type AcquireResult
- type CorruptError
- type Holder
- type KeyStatus
- type NotHolderError
- type PruneResult
- type Store
- func (s *Store) Dequeue(entryPath string) error
- func (s *Store) Enqueue(key string, w Waiter) (string, error)
- func (s *Store) Heartbeat(entryPath string, w Waiter) error
- func (s *Store) List() ([]KeyStatus, error)
- func (s *Store) Prune() (*PruneResult, error)
- func (s *Store) Release(key, token string, force bool) (*Holder, error)
- func (s *Store) Renew(key, token string, ttl time.Duration) (*Holder, error)
- func (s *Store) Status(key string) (*KeyStatus, error)
- func (s *Store) TryAcquire(key, token string, o AcquireOpts) (*AcquireResult, error)
- type Waiter
Constants ¶
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.
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 ¶
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 EncodeKey ¶
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 ¶
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 ¶
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).
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 ¶
Open opens (creating if needed) the store at root. An empty root falls back to $AGENTMUTEX_DIR, then ~/.agentmutex.
func (*Store) Dequeue ¶
Dequeue removes a waiter entry. Missing entries are fine (the acquire that succeeded already removed it).
func (*Store) Enqueue ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.