72 lines
2.1 KiB
Go
72 lines
2.1 KiB
Go
// Package lockfile provides a simple file-based exclusive lock primitive.
|
|
// It is used to serialize writes to ctask metadata files across cooperating
|
|
// ctask processes running in the same workspace.
|
|
package lockfile
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// ErrTimeout is returned when Acquire cannot obtain the lock within its timeout.
|
|
var ErrTimeout = errors.New("lockfile: acquire timeout")
|
|
|
|
// Acquire attempts to create lockPath exclusively. If the file already exists
|
|
// and is older than staleAfter, it is removed and creation is retried.
|
|
// Retries with short backoff for up to timeout. On success, returns a
|
|
// release function that removes the lock file.
|
|
func Acquire(lockPath string, timeout, staleAfter time.Duration) (func(), error) {
|
|
if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil {
|
|
return nil, fmt.Errorf("preparing lock dir: %w", err)
|
|
}
|
|
|
|
deadline := time.Now().Add(timeout)
|
|
backoff := 25 * time.Millisecond
|
|
|
|
for {
|
|
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
|
if err == nil {
|
|
f.Close()
|
|
return func() { os.Remove(lockPath) }, nil
|
|
}
|
|
if !errors.Is(err, os.ErrExist) {
|
|
return nil, fmt.Errorf("creating lock: %w", err)
|
|
}
|
|
|
|
if info, statErr := os.Stat(lockPath); statErr == nil {
|
|
if time.Since(info.ModTime()) > staleAfter {
|
|
os.Remove(lockPath)
|
|
continue
|
|
}
|
|
}
|
|
|
|
if time.Now().After(deadline) {
|
|
return nil, ErrTimeout
|
|
}
|
|
time.Sleep(backoff)
|
|
if backoff < 200*time.Millisecond {
|
|
backoff *= 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithLock acquires the lock at lockPath, runs fn while holding it, and
|
|
// releases the lock. If the lock cannot be acquired within timeout, fn is
|
|
// NOT called and (skipped=true, err=nil) is returned — this matches the
|
|
// ctask "warn and skip, never hang" contract for metadata writes.
|
|
// Any error returned by fn is surfaced to the caller with skipped=false.
|
|
func WithLock(lockPath string, timeout, staleAfter time.Duration, fn func() error) (skipped bool, err error) {
|
|
release, err := Acquire(lockPath, timeout, staleAfter)
|
|
if err != nil {
|
|
if errors.Is(err, ErrTimeout) {
|
|
return true, nil
|
|
}
|
|
return false, err
|
|
}
|
|
defer release()
|
|
return false, fn()
|
|
}
|