Files
BlackBean/pigo/internal/dream/lock.go
T
2026-08-14 23:41:57 +08:00

148 lines
4.6 KiB
Go

package dream
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
)
// ErrLocked is returned by AcquireLock when a live (non-stale) lock is already
// held by another process. Callers detect it (via errors.Is) to exit "skipped"
// rather than treating it as a real failure. It is deliberately distinct from
// the I/O errors AcquireLock may also return.
var ErrLocked = errors.New("dream: consolidation already running")
// DefaultStaleAfter is how long after a lock's started_at the lock is considered
// abandoned (e.g. the holder crashed) and may be taken over. See spec §5.4.
var DefaultStaleAfter = 30 * time.Minute
// lockInfo is the JSON body persisted in the lock file.
type lockInfo struct {
PID int `json:"pid"`
StartedAt time.Time `json:"started_at"`
}
// Lock represents an acquired dream single-instance lock backed by the file
// <memoryRoot>/global/dream/dream.lock. It guarantees at most one consolidation
// runs at a time across processes, with crash-safe stale takeover.
type Lock struct {
path string
released bool
}
// lockPath is the lock file location under the memory root. It is a separate
// file from state.json and never touches it.
func lockPath(memoryRoot string) string {
return filepath.Join(memoryRoot, "global", "dream", "dream.lock")
}
// AcquireLock attempts to acquire the dream single-instance lock under
// memoryRoot. On success it returns a *Lock the caller must Release (typically
// via defer). If a live lock is already held it returns ErrLocked. If the
// existing lock is stale (its started_at is older than now-DefaultStaleAfter) or
// malformed/unparseable, it is treated as abandoned and taken over. Any other
// error (permissions, unexpected I/O) is returned as-is so the caller can
// distinguish it from the ErrLocked "skipped" case.
func AcquireLock(memoryRoot string) (*Lock, error) {
dir := filepath.Join(memoryRoot, "global", "dream")
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
path := lockPath(memoryRoot)
// First attempt: atomic exclusive create.
l, err := createLock(path)
if err == nil {
return l, nil
}
if !os.IsExist(err) {
// Real I/O error (permissions, etc.), not a contention signal.
return nil, err
}
// A lock file already exists. Decide whether it is stale and takeable.
if !staleLock(path, time.Now()) {
return nil, ErrLocked
}
// Stale (or malformed) lock: take it over. Removing then re-creating with
// O_EXCL keeps the create atomic. A racing process that recreates the file
// between our Remove and create will cause our create to fail with EEXIST;
// we surface that as ErrLocked (the other process won the race).
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return nil, err
}
l, err = createLock(path)
if err != nil {
if os.IsExist(err) {
return nil, ErrLocked
}
return nil, err
}
return l, nil
}
// createLock atomically creates the lock file with O_EXCL and writes the current
// pid + start time as JSON. On EEXIST it returns an error for which os.IsExist
// is true.
func createLock(path string) (*Lock, error) {
f, err := os.OpenFile(path, os.O_EXCL|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, err
}
data, err := json.Marshal(lockInfo{PID: os.Getpid(), StartedAt: time.Now().UTC()})
if err != nil {
f.Close()
os.Remove(path)
return nil, err
}
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(path)
return nil, err
}
if err := f.Close(); err != nil {
os.Remove(path)
return nil, err
}
return &Lock{path: path}, nil
}
// staleLock reports whether the lock file at path is stale (takeable) as of now.
// A lock is stale when its started_at is older than now-DefaultStaleAfter. A
// missing, unreadable, or malformed/unparseable lock file is also treated as
// stale so a corrupt lock never wedges dream permanently.
func staleLock(path string, now time.Time) bool {
data, err := os.ReadFile(path)
if err != nil {
// Missing or unreadable: treat as takeable.
return true
}
var info lockInfo
if err := json.Unmarshal(data, &info); err != nil {
// Malformed lock body: treat as stale.
return true
}
if info.StartedAt.IsZero() {
// No usable timestamp: treat as stale.
return true
}
return now.Sub(info.StartedAt) > DefaultStaleAfter
}
// Release removes the lock file. It is safe to call in a defer and safe to
// double-call: a second call (or a call after the file was already removed) is a
// no-op and never panics. A missing file is not an error.
func (l *Lock) Release() error {
if l == nil || l.released {
return nil
}
l.released = true
if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}