// Package lockfile handles pid file based locking. // While a sync.Mutex helps against concurrency issues within a single process, // this package is designed to help against concurrency issues between cooperating processes // or serializing multiple invocations of the same process. You can also combine sync.Mutex // with Lockfile in order to serialize an action between different goroutines in a single program // and also multiple invocations of this program. package lockfile import ( "errors" "fmt" "io" "io/ioutil" "os" "path/filepath" "strconv" "time" ) type Lockfile interface { GetOwner() (*os.Process, error) ChangeOwner(int) error Unlock() error TryLock() error TryLockExpire(int64) error TryLockExpireWithRetry(int64) error ShouldRetry(error) bool } type lockfileImpl struct{ lockPath string expirePath string } // TemporaryError is a type of error where a retry after a random amount of sleep should help to mitigate it. type TemporaryError string func (t TemporaryError) Error() string { return string(t) } // Temporary returns always true. // It exists, so you can detect it via // if te, ok := err.(interface{ Temporary() bool }); ok { // fmt.Println("I am a temporary error situation, so wait and retry") // } func (t TemporaryError) Temporary() bool { return true } // Various errors returned by this package var ( ErrBusy = TemporaryError("Locked by other process") // If you get this, retry after a short sleep might help ErrNotExist = TemporaryError("Lockfile created, but doesn't exist") // If you get this, retry after a short sleep might help ErrNeedAbsPath = errors.New("Lockfiles must be given as absolute path names") ErrInvalidPid = errors.New("Lockfile contains invalid pid for system") ErrDeadOwner = errors.New("Lockfile contains pid of process not existent on this system anymore") ErrRogueDeletion = errors.New("Lockfile owned by me has been removed unexpectedly") ) // Assign method to global variables to allow unittest to override var getUnixTime = getUnixTimeFunc // New describes a new filename located at the given absolute path. func New(path string) (Lockfile, error) { if !filepath.IsAbs(path) { return lockfileImpl {"", ""}, ErrNeedAbsPath } return lockfileImpl {path, path + ".expire"}, nil } // GetOwner returns who owns the lockfile. func (l lockfileImpl) GetOwner() (*os.Process, error) { // Ok, see, if we have a stale lockfile here content, err := ioutil.ReadFile(l.lockPath) if err != nil { return nil, err } // try hard for pids. If no pid, the lockfile is junk anyway and we delete it. pid, err := scanPidLine(content) if err != nil { return nil, err } return getProcess(pid) } // ChangeOwner changes the pid in the lock file but only if the current pid owns the lockfile func (l lockfileImpl) ChangeOwner(pid int) error { proc, err := l.GetOwner() // Only allow change pid if current process owns the lock switch err { default: // Other errors -> defensively fail and let caller handle this return err case nil: if proc.Pid != os.Getpid() { return ErrBusy } } // Make sure the process for the pid alive _, err = getProcess(pid) if err != nil { return err } return ioutil.WriteFile(l.lockPath, []byte(strconv.Itoa(pid)+"\n"), 0600) } // Unlock a lock again, if we owned it. Returns any error that happened during release of lock. func (l lockfileImpl) Unlock() error { proc, err := l.GetOwner() switch err { case ErrInvalidPid, ErrDeadOwner: return ErrRogueDeletion case nil: if proc.Pid == os.Getpid() { // we really own it, so let's remove it. l.cleanUpLock() return nil } // Not owned by me, so don't delete it. return ErrRogueDeletion default: // This is an application error or system error. // So give a better error for logging here. if os.IsNotExist(err) { return ErrRogueDeletion } // Other errors -> defensively fail and let caller handle this return err } } // TryLock tries to own the lock. // It Returns nil, if successful and and error describing the reason, it didn't work out. // Please note, that existing lockfiles containing pids of dead processes // and lockfiles containing no pid at all are simply deleted. // If a expirelock is already owned by the process, the lock is chanced into a regular pid lock func (l lockfileImpl) TryLock() error { // This has been checked by New already. If we trigger here, // the caller didn't use New and re-implemented it's functionality badly. // So panic, that he might find this easily during testing. if !filepath.IsAbs(l.lockPath) { panic(ErrNeedAbsPath) } tmplock, cleanup, err := makePidFile(l.lockPath, os.Getpid()) if err != nil { return err } defer cleanup() // EEXIST and similar error codes, caught by os.IsExist, are intentionally ignored, // as it means that someone was faster creating this link // and ignoring this kind of error is part of the algorithm. // Then we will probably fail the pid owner check later, if this process is still alive. // We cannot ignore ALL errors, since failure to support hard links, disk full // as well as many other errors can happen to a filesystem operation // and we really want to abort on those. if err := os.Link(tmplock, l.lockPath); err != nil { if !os.IsExist(err) { return err } } fiTmp, err := os.Lstat(tmplock) if err != nil { return err } fiLock, err := os.Lstat(l.lockPath) if err != nil { // tell user that a retry would be a good idea if os.IsNotExist(err) { return ErrNotExist } return err } // Success if os.SameFile(fiTmp, fiLock) && !l.isLockExpired() { return nil } proc, err := l.GetOwner() switch err { default: // Other errors -> defensively fail and let caller handle this return err case nil: if proc.Pid != os.Getpid() { if !l.isLockExpired() { return ErrBusy } } case ErrDeadOwner, ErrInvalidPid: // cases we can fix below } l.cleanUpLock() // now that the stale lockfile is gone, let's recurse return l.TryLock() } // TryLockExpire tries to own the lock while creating a expiration file as well. // If expiration file already exists it updates the expiration with func (l lockfileImpl) TryLockExpire(minutes int64) error { err := l.TryLock() // Unable to lock if err != nil { return err } err = ioutil.WriteFile(l.expirePath, []byte(strconv.FormatInt(getUnixTime() + minutes * 60, 10) + "\n"), 0600) if err != nil { l.cleanUpLock() } return err } // TryLockExpireWithRetry tries to own the lock while creating a expiration file as well. // This function retries when an error(except ErrBusy) thrown by TryLockExpire func (l lockfileImpl) TryLockExpireWithRetry(minutes int64) (err error) { noOfRetries := 2 for retryCount := 1; retryCount <= noOfRetries; retryCount++ { err = l.TryLockExpire(minutes) if l.ShouldRetry(err) { time.Sleep(100 * time.Millisecond) err = l.TryLockExpire(minutes) if l.ShouldRetry(err) { l.cleanUpLock() } else { return err } } else { return err } } return err } func (l lockfileImpl) cleanUpLock() { _ = os.Remove(l.lockPath) _ = os.Remove(l.expirePath) } func (l lockfileImpl) isLockExpired() bool { var expireTime int64 timeNow := getUnixTime() content, err := ioutil.ReadFile(l.expirePath) if err != nil { // If the expire does not exist, the lock does not expire if os.IsNotExist(err) { return false } return true } // Any errors scanning timestamp is assumed as expiration if _, err := fmt.Sscanln(string(content), &expireTime); err != nil { return true } return expireTime < timeNow } func (l lockfileImpl) ShouldRetry(err error) bool { if err == nil || err == ErrBusy { return false } return true } func scanPidLine(content []byte) (int, error) { if len(content) == 0 { return 0, ErrInvalidPid } var pid int if _, err := fmt.Sscanln(string(content), &pid); err != nil { return 0, ErrInvalidPid } if pid <= 0 { return 0, ErrInvalidPid } return pid, nil } func makePidFile(name string, pid int) (tmpname string, cleanup func(), err error) { tmplock, err := ioutil.TempFile(filepath.Dir(name), filepath.Base(name)+".") if err != nil { return "", nil, err } cleanup = func() { _ = tmplock.Close() _ = os.Remove(tmplock.Name()) } if _, err := io.WriteString(tmplock, fmt.Sprintf("%d\n", pid)); err != nil { cleanup() // Do cleanup here, so call doesn't have to. return "", nil, err } return tmplock.Name(), cleanup, nil } func getProcess(pid int) (proc *os.Process, err error){ running, err := isRunning(pid) if err != nil { return nil, err } if running { proc, err := os.FindProcess(pid) if err != nil { return nil, err } return proc, nil } return nil, ErrDeadOwner } func getUnixTimeFunc() int64 { return time.Now().Unix() }