// Package datadir guards exclusive access to the directory holding // every SQLite database webhooker writes. // // Two processes sharing a DATA_DIR each open the same per-webhook // event databases and each run delivery recovery over the same rows, // so every pending delivery goes out twice. SQLite's own locking does // not prevent that: both writers are serialised correctly and both // deliver. The only thing that prevents it is refusing to be the // second process. // // The lock lives here rather than in the server's fx graph so that any // entry point which touches DATA_DIR — the server, or a CLI // subcommand that must not operate on a live deployment's data — takes // it the same way. package datadir import ( "errors" "fmt" "os" "path/filepath" "github.com/gofrs/flock" ) // LockFileName is the advisory lock file created inside DATA_DIR. Its // contents are never read: the lock is the flock(2) held on the open // descriptor, not the file's existence, so a leftover file from a // process that was killed with SIGKILL blocks nothing. const LockFileName = "webhooker.lock" // dirPerm is the mode Acquire creates DATA_DIR with. It matches what // internal/database uses, since whichever runs first creates it. const dirPerm = 0o750 // ErrLocked reports that another live process holds the data // directory. Callers that need to know whether a deployment is running // — rather than merely failing to start — test for this with // errors.Is. var ErrLocked = errors.New( "data directory is already in use by another instance", ) // ErrNoDir reports that Acquire was given an empty directory. var ErrNoDir = errors.New("no data directory given") // Lock is a held exclusive advisory lock on a data directory. It is // valid only while the process that took it lives: the kernel drops it // when the descriptor closes, whether that is Release, a normal exit, // or a SIGKILL. type Lock struct { dir string file *flock.Flock } // Acquire takes the exclusive advisory lock on dir, creating dir if it // does not exist. It never waits: if another process holds the lock it // returns an error wrapping ErrLocked and naming dir. // // The returned Lock must be held for as long as the caller intends to // use dir. func Acquire(dir string) (*Lock, error) { if dir == "" { return nil, ErrNoDir } err := os.MkdirAll(dir, dirPerm) if err != nil { return nil, fmt.Errorf( "creating data directory %s: %w", dir, err, ) } path := filepath.Join(dir, LockFileName) fl := flock.New(path) held, err := fl.TryLock() if err != nil { return nil, fmt.Errorf( "locking data directory %s: %w", dir, err, ) } if !held { // TryLock leaves the descriptor open when it fails to take // the lock, so it has to be closed explicitly. _ = fl.Close() return nil, fmt.Errorf( "%w: %s (%s). Only one webhooker may use a data "+ "directory: two both run delivery recovery over the "+ "same rows and both deliver", ErrLocked, dir, path, ) } return &Lock{dir: dir, file: fl}, nil } // Dir returns the locked directory. func (l *Lock) Dir() string { return l.dir } // Path returns the lock file backing the lock. func (l *Lock) Path() string { return l.file.Path() } // Release drops the lock and closes the descriptor. It is safe to call // more than once. // // The lock file is deliberately left on disk. Unlinking it would let // the next process create and lock a fresh inode while a third still // holds the old one, which is the one outcome this package exists to // prevent. func (l *Lock) Release() error { err := l.file.Unlock() if err != nil { return fmt.Errorf( "releasing lock on data directory %s: %w", l.dir, err, ) } return nil }