packages feed

nova-nix-0.8.0.0: src/Nix/Store/Lock.hs

-- | Per-store-path locking: mutual exclusion across delete, materialize,
-- and register.
--
-- == Why store paths lock
--
-- Two processes sharing a store can interleave substitution of one path:
-- process A removes the stale destination, process B materializes and
-- registers, then A materializes over B's tree - a valid database row
-- pointing at deleted or torn bytes.  Upstream C++ Nix
-- (@src\/libstore\/pathlocks.cc@, and the substitution path in
-- @local-store.cc@) prevents this with an exclusive lock on a
-- @\<store-path\>.lock@ file held across the whole
-- delete-materialize-register sequence, re-checking validity under the
-- lock so a waiter adopts the winner's finished work instead of redoing
-- it.
--
-- == Why filelock, not base's file locks
--
-- The lock is taken on a raw descriptor through the @filelock@
-- library (@flock@ on POSIX, @LockFileEx@ on Windows; CC0-licensed,
-- so nothing encumbers this package's Apache-2.0 distribution).
-- Base's 'GHC.IO.Handle.Lock' cannot express this lock: GHC's handle
-- registry forbids a second in-process writable handle on one file,
-- while Linux's open-file-description locks refuse an exclusive lock
-- on a read-only descriptor, so any 'System.IO.Handle' design must
-- pick between spurious in-process open failures and @EBADF@ at lock
-- time.  @filelock@'s descriptor lives outside the registry, opened
-- write-access and created atomically; the @flock@ lock attaches to
-- the open description, so it excludes other holders in this process
-- and any other, and releases when the description closes, which also
-- covers a crashed holder.  @flock@'s guarantee is for local
-- filesystems - the store's single-machine model.
--
-- == Lock files persist
--
-- Upstream deletes a lock file once its holder finishes, which opens the
-- deleted-lock-file hazard: a waiter blocked on the old file can acquire
-- it just after deletion and then hold a lock no later process can see;
-- upstream closes the hazard by writing a marker byte before deleting
-- and having every acquirer re-check the file it locked.  Here lock
-- files are never deleted: the file a waiter blocked on is always the
-- file the next holder locks, the marker dance disappears, and Windows -
-- where deleting a file another process holds open fails anyway - needs
-- no separate path.  The cost is one empty @\<store-path\>.lock@ per
-- substituted path left beside it in the store directory; the files are
-- inert debris, invisible to path queries (only exact store-path
-- basenames resolve).
module Nix.Store.Lock
  ( -- * Held locks
    PathLock,
    acquirePathLock,
    tryAcquirePathLock,
    releasePathLock,
    withPathLock,
    withLockFile,

    -- * Naming
    pathLockFilePath,
    lockFileSuffix,
  )
where

import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar)
import Control.Exception (bracket)
import Nix.Store.Path (StoreDir, StorePath, storePathToFilePath)
import System.FileLock (FileLock, SharedExclusive (Exclusive), lockFile, tryLockFile, unlockFile)
import System.IO (hPutStrLn, stderr)

-- | An exclusive lock held on one store path: the lock file's path and
-- the descriptor whose OS lock is the exclusion.  The descriptor sits
-- behind an 'MVar' so release is idempotent and thread-safe: a bracket
-- and an explicit release can both fire on one lock without a
-- double-close.
data PathLock = PathLock !FilePath !(MVar (Maybe FileLock))

-- | Identity is the lock file; the descriptor is process-local plumbing.
instance Eq PathLock where
  PathLock leftFile _ == PathLock rightFile _ = leftFile == rightFile

instance Show PathLock where
  show (PathLock lockedFile _) = "PathLock " <> show lockedFile

-- | Upstream's lock-file naming convention: the lock for a store path
-- lives beside it, under the path's own name plus this suffix.
lockFileSuffix :: FilePath
lockFileSuffix = ".lock"

-- | The lock file guarding one store path.
pathLockFilePath :: StoreDir -> StorePath -> FilePath
pathLockFilePath dir sp = storePathToFilePath dir sp <> lockFileSuffix

-- | Take the exclusive lock on a store path, blocking until granted.
-- Blocking is upstream's behavior on a busy path lock, announced the
-- same way ('waitingForLockMessage') so a stalled substitution names
-- what it is waiting for.  The non-blocking probe runs first, so the
-- contended case announces itself before the wait begins; the lock
-- file is created if absent, atomically at the open.
acquirePathLock :: StoreDir -> StorePath -> IO PathLock
acquirePathLock dir sp = acquireLockFile (pathLockFilePath dir sp)

-- | Take the exclusive lock on a lock file named directly, blocking
-- until granted.  The raw form exists for the delete path: deletion
-- accepts basenames the current store-path name rules reject, so their
-- lock files cannot be named through 'StorePath' - yet they must be the
-- very files substituters of the same path contend on.
acquireLockFile :: FilePath -> IO PathLock
acquireLockFile lockPath = do
  probe <- tryLockFile lockPath Exclusive
  held <- case probe of
    Just granted -> pure granted
    Nothing -> do
      hPutStrLn stderr (waitingForLockMessage lockPath)
      lockFile lockPath Exclusive
  heldRef <- newMVar (Just held)
  pure (PathLock lockPath heldRef)

-- | Upstream's log line for a busy path lock.
waitingForLockMessage :: FilePath -> String
waitingForLockMessage lockPath = "waiting for lock on '" <> lockPath <> "'..."

-- | Take the exclusive lock only if it is free: 'Nothing' when another
-- holder - this process's or another's - already has it.
tryAcquirePathLock :: StoreDir -> StorePath -> IO (Maybe PathLock)
tryAcquirePathLock dir sp = do
  let lockPath = pathLockFilePath dir sp
  probe <- tryLockFile lockPath Exclusive
  case probe of
    Nothing -> pure Nothing
    Just granted -> do
      heldRef <- newMVar (Just granted)
      pure (Just (PathLock lockPath heldRef))

-- | Release a held lock.  Closing the descriptor releases the OS lock
-- under every backend; the lock file stays - never deleted, see the
-- module header.  Idempotent: a second release finds the descriptor
-- already surrendered and does nothing.
releasePathLock :: PathLock -> IO ()
releasePathLock (PathLock _ heldRef) = modifyMVar_ heldRef surrender
  where
    surrender Nothing = pure Nothing
    surrender (Just held) = do
      unlockFile held
      pure Nothing

-- | Run an action holding a path's lock, released on every exit.
withPathLock :: StoreDir -> StorePath -> (PathLock -> IO a) -> IO a
withPathLock dir sp = bracket (acquirePathLock dir sp) releasePathLock

-- | Run an action holding a directly named lock file's lock, released
-- on every exit.  See 'acquireLockFile' for why the raw form exists.
withLockFile :: FilePath -> (PathLock -> IO a) -> IO a
withLockFile lockPath = bracket (acquireLockFile lockPath) releasePathLock