packages feed

rawlock-0.1.2.0: src/Control/RAWLock.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE UndecidableInstances #-}

-- | A Read-Append-Write (RAW) lock
--
-- A RAW lock allows __multiple concurrent readers__, at most __one appender__,
-- which is allowed to run concurrently with the readers, and at most
-- __one writer__, which has exclusive access to the lock.
--
-- The following table summarises which roles are allowed to concurrently
-- access the RAW lock:
--
-- +----------+--------+----------+--------+
-- |          | Reader | Appender | Writer |
-- +==========+========+==========+========+
-- | Reader   |   V    |     V    |    X   |
-- +----------+--------+----------+--------+
-- | Appender |░░░░░░░░|     X    |    X   |
-- +----------+--------+----------+--------+
-- | Writer   |░░░░░░░░|░░░░░░░░░░|    X   |
-- +----------+--------+----------+--------+
--
-- It is important to realise that a RAW lock is intended to control access to
-- a piece of in-memory state that should remain in sync with some other state
-- that can only be modified using side-effects, e.g., the file system. If,
-- for example, you're only maintaining a counter shared by threads, then
-- simply use a 'TVar' or an 'MVar'.
--
-- = Example use case: log files
--
-- A RAW lock is useful, for example, to maintain an in-memory index of log
-- files stored on disk.
--
-- * To read data from a log file, you need \"read\" access to the index to
--   find out the file and offset where the requested piece of data is stored.
--   While holding the RAW lock as a reader, you can perform the IO operation
--   to read the data from the right log file. This can safely happen
--   concurrently with other read operations.
--
-- * To append data to the current log file, you need \"append\" access to the
--   index so you can append an entry to the index and even to add a new log
--   file to the index when necessary. While holding the RAW lock as an
--   appender, you can perform the IO operation to append the piece of data to
--   the current log file and, if necessary start a new log file. Only one
--   append can happen concurrently. However, reads can safely happen
--   concurrently with appends. Note that the in-memory index is only updated
--   /after/ writing to disk.
--
-- * To remove the oldest log files, you need \"write\" access to the index,
--   so you can remove files from the index. While holding the RAW lock as a
--   writer, you can perform the IO operations to delete the oldest log files.
--   No other operations can run concurrently with this operation: concurrent
--   reads might try to read from deleted files and a concurrent append could
--   try to append to a deleted file.
--
-- = Analogy: Chicken coop
--
-- Think of readers as chickens, the appender as the rooster, and the writer
-- as the fox. All of them want access to the chicken coop, i.e., the state
-- protected by the RAW lock.
--
-- We can allow multiple chickens (readers) together in the chicken coop, they
-- get along (reasonably) fine. We can also let one rooster (appender) in, but
-- not more than one, otherwise he would start fighting with the other rooster
-- (conflict with the other appender). We can only let the fox in when all
-- chickens and the rooster (if present) have left the chicken coop, otherwise
-- the fox would eat them (conflict with the appender and invalidate the
-- results of readers, e.g, closing resources readers try to access).
--
-- = Usage
--
-- To use the lock, use any of the three following operations:
--
-- * 'withReadAccess'
-- * 'withAppendAccess'
-- * 'withWriteAccess'
--
-- If the standard bracketing the above three operations use doesn't suffice,
-- use the following three acquire-release pairs:
--
-- * 'unsafeAcquireReadAccess'   & 'unsafeReleaseReadAccess'
-- * 'unsafeAcquireAppendAccess' & 'unsafeReleaseAppendAccess'
-- * 'unsafeAcquireWriteAccess'  & 'unsafeReleaseWriteAccess'
--
-- NOTE: an acquire __must__ be followed by the corresponding release,
-- otherwise the correctness of the lock is not guaranteed and a dead-lock can
-- happen.
--
-- NOTE: nested locking of the same lock is not allowed, as you might be
-- blocked on yourself.
--
-- = Notes
--
--  * Only use a RAW lock when it is safe to concurrently read and append.
--
--  * We do not guarantee fairness. Once the lock is released, all waiting
--    actors will race for the access.
--
--  * The state @st@ is always evaluated to WHNF and is subject to the
--    'NoThunks' check when enabled.
--
--  * All public functions are exception-safe.
module Control.RAWLock
  ( -- * API
    RAWLock
  , new
  , poison
  , read
  , withAppendAccess
  , withReadAccess
  , withWriteAccess

    -- * Unsafe API
    -- $unsafe-api
  , unsafeAcquireAppendAccess
  , unsafeAcquireReadAccess
  , unsafeAcquireWriteAccess
  , unsafeReleaseAppendAccess
  , unsafeReleaseReadAccess
  , unsafeReleaseWriteAccess
  ) where

import Control.Concurrent.Class.MonadMVar.Strict
import Control.Concurrent.Class.MonadSTM.Strict
import Control.Monad.Class.MonadThrow
import GHC.Generics
import GHC.Stack (CallStack, HasCallStack, callStack)
import NoThunks.Class
import Prelude hiding (read)

-- | Any non-negative number of readers
newtype Readers = Readers Word
  deriving newtype (Eq, Ord, Enum, Num, NoThunks)
  deriving stock Show

-- | Any non-negative number of writers
newtype Writers = Writers Word
  deriving newtype (Eq, Ord, Enum, Num, NoThunks)
  deriving stock Show

-- | Any non-negative number of appenders
newtype Appenders = Appenders Word
  deriving newtype (Eq, Ord, Enum, Num, NoThunks)
  deriving stock Show

data RAWState = RAWState
  { waitingReaders :: !Readers
  , waitingAppenders :: !Appenders
  , waitingWriters :: !Writers
  }
  deriving (Show, Generic, NoThunks)

noWriters :: Poisonable RAWState -> Bool
noWriters (Healthy (RAWState _ _ w)) = w == 0
noWriters _ = True

onlyWriters :: Poisonable RAWState -> Bool
onlyWriters (Healthy (RAWState r a _)) = r == 0 && a == 0
onlyWriters _ = True

pushReader :: Poisonable RAWState -> Poisonable RAWState
pushReader = fmap (\(RAWState r a w) -> RAWState (r + 1) a w)

pushAppender :: Poisonable RAWState -> Poisonable RAWState
pushAppender = fmap (\(RAWState r a w) -> RAWState r (a + 1) w)

pushWriter :: Poisonable RAWState -> Poisonable RAWState
pushWriter = fmap (\(RAWState r a w) -> RAWState r a (w + 1))

popReader :: Poisonable RAWState -> Poisonable RAWState
popReader = fmap (\(RAWState r a w) -> RAWState (r - 1) a w)

popAppender :: Poisonable RAWState -> Poisonable RAWState
popAppender = fmap (\(RAWState r a w) -> RAWState r (a - 1) w)

popWriter :: Poisonable RAWState -> Poisonable RAWState
popWriter = fmap (\(RAWState r a w) -> RAWState r a (w - 1))

-- | Data that can be replaced with an exception that should be thrown when
-- found.
data Poisonable st
  = Healthy !st
  | Poisoned !(AllowThunk SomeException)
  deriving (Generic, NoThunks, Functor)

data RAWLock m st = RAWLock
  { resource :: !(StrictTMVar m (Poisonable st))
  , appender :: !(StrictMVar m ())
  , queues :: !(StrictTVar m (Poisonable RAWState))
  }
  deriving Generic

deriving instance
  ( NoThunks (StrictTMVar m (Poisonable st))
  , NoThunks (StrictMVar m ())
  , NoThunks (StrictTVar m (Poisonable RAWState))
  ) =>
  NoThunks (RAWLock m st)

new ::
  ( MonadMVar m
  , MonadLabelledSTM m
  ) =>
  st ->
  m (RAWLock m st)
new !st = do
  s <- newTMVarIO (Healthy st)
  atomically $ labelTMVar s "state"
  a <- newMVar ()
  q <- newTVarIO (Healthy emptyRAWState)
  atomically $ labelTVar q "queues"
  pure $ RAWLock s a q

read :: (MonadSTM m, MonadThrow (STM m)) => RAWLock m st -> STM m st
read (RAWLock var _ _) = readTMVar var >>= throwPoisoned

-- | When a lock is poisoned all subsequent access to it is overridden by the
-- poison. This means that the current actor that holds the lock will free it,
-- and any other concurrent actors will be able to release their access,
-- possibly rising the poison exception in the process.
--
-- There is no need (although it is harmless) to release again the current
-- actor once it has poisoned the lock.
poison ::
  (Exception e, MonadMVar m, MonadSTM m, MonadThrow (STM m), HasCallStack) =>
  RAWLock m st ->
  (CallStack -> e) ->
  m (Maybe st)
poison (RAWLock var apm q) mkExc = do
  st <-
    atomically $
      tryReadTMVar var >>= \case
        -- Keep original exception
        Just (Poisoned (AllowThunk exc)) -> throwIO exc
        Just (Healthy st) -> do
          writeTMVar var (Poisoned (AllowThunk (toException (mkExc callStack))))
          writeTVar q (Poisoned (AllowThunk (toException (mkExc callStack))))
          pure (Just st)
        Nothing -> do
          writeTMVar var (Poisoned (AllowThunk (toException (mkExc callStack))))
          writeTVar q (Poisoned (AllowThunk (toException (mkExc callStack))))
          pure Nothing
  _ <- tryPutMVar apm ()
  pure st

-- | Create an initial, empty, unlocked 'RAWState': no readers, no appender,
-- no writer (waiting).
emptyRAWState :: RAWState
emptyRAWState = RAWState 0 0 0

-- | Acquire the 'RAWLock' as a reader.
--
-- Will block when there is a writer or when a writer is waiting to take the
-- lock.
withReadAccess ::
  (MonadSTM m, MonadCatch m, MonadThrow (STM m)) =>
  RAWLock m st ->
  (st -> m a) ->
  m a
withReadAccess lock =
  bracket
    (atomically (unsafeAcquireReadAccess lock))
    (const (atomically (unsafeReleaseReadAccess lock)))

-- | Acquire the 'RAWLock' as a writer.
--
-- Will block when there is another writer, readers or appenders.
withWriteAccess ::
  (MonadSTM m, MonadCatch m, MonadThrow (STM m)) =>
  RAWLock m st ->
  (st -> m (a, st)) ->
  m a
withWriteAccess lock f =
  fst . fst
    <$> generalBracket
      (unsafeAcquireWriteAccess lock)
      ( \orig -> \case
          ExitCaseSuccess (_, st) -> unsafeReleaseWriteAccess lock st
          _ -> unsafeReleaseWriteAccess lock orig
      )
      f

-- | Acquire the 'RAWLock' as an appender.
--
-- Will block when there is a writer or when there is another appender.
withAppendAccess ::
  (MonadThrow (STM m), MonadSTM m, MonadCatch m, MonadMVar m) =>
  RAWLock m st ->
  (st -> m (a, st)) ->
  m a
withAppendAccess lock f = do
  fst . fst
    <$> generalBracket
      (unsafeAcquireAppendAccess lock)
      ( \orig -> \case
          ExitCaseSuccess (_, st) -> unsafeReleaseAppendAccess lock st
          _ -> unsafeReleaseAppendAccess lock orig
      )
      f

{-------------------------------------------------------------------------------
  Unsafe API
-------------------------------------------------------------------------------}

throwPoisoned :: MonadThrow m => Poisonable st -> m st
throwPoisoned (Healthy st) = pure st
throwPoisoned (Poisoned (AllowThunk exc)) = throwIO exc

-- $unsafe-api
--
-- These functions are unsafe in the sense that they do not guard against
-- exceptions, meaning that if you don't take care and ensure exception safety,
-- you might make the RAWLock unusable.
--
-- To be safe, you should ensure that every @unsafeAcquireXAccess@ is paired with
-- @unsafeReleaseXAccess@, __even in the presence of exceptions__.
--
-- Note that for writing and appending, you should restore the original value in
-- presence of an exception!

unsafeAcquireReadAccess ::
  (MonadThrow (STM m), MonadSTM m) =>
  RAWLock m st ->
  STM m st
unsafeAcquireReadAccess (RAWLock var _ qs) = do
  -- wait until there are no writers
  readTVar qs >>= check . noWriters
  -- queue myself
  modifyTVar qs pushReader
  -- read the state
  throwPoisoned =<< readTMVar var

unsafeReleaseReadAccess :: MonadSTM m => RAWLock m st -> STM m ()
unsafeReleaseReadAccess (RAWLock _ _ qs) =
  -- unqueue myself
  modifyTVar qs popReader

unsafeAcquireWriteAccess ::
  (MonadThrow (STM m), MonadCatch m, MonadSTM m) =>
  RAWLock m st ->
  m st
unsafeAcquireWriteAccess (RAWLock var _ qs) = do
  -- queue myself if there are no other writers
  atomically $ do
    -- wait until there are no writers
    readTVar qs >>= check . noWriters
    -- queue myself
    modifyTVar qs pushWriter
  atomically
    ( do
        -- wait until there are no readers (and as I queued myself above, I'm the
        -- only waiting writer)
        readTVar qs >>= check . onlyWriters
        -- acquire the state
        throwPoisoned =<< takeTMVar var
    )
    `onException` atomically (modifyTVar qs popWriter)

unsafeReleaseWriteAccess ::
  (MonadThrow (STM m), MonadSTM m) =>
  RAWLock m st ->
  st ->
  m ()
unsafeReleaseWriteAccess (RAWLock var _ qs) !st =
  atomically $ do
    -- write the new state
    tryReadTMVar var >>= \case
      Nothing -> putTMVar var (Healthy st)
      Just (Poisoned (AllowThunk exc)) -> throwIO exc
      Just Healthy{} -> error "Double put"
    -- unqueue myself
    modifyTVar qs popWriter

unsafeAcquireAppendAccess ::
  (MonadThrow (STM m), MonadCatch m, MonadMVar m, MonadSTM m) =>
  RAWLock m st ->
  m st
unsafeAcquireAppendAccess (RAWLock var apm qs) = do
  atomically $ do
    -- wait until there are no writers
    readTVar qs >>= check . noWriters
    -- queue myself
    modifyTVar qs pushAppender
  ( do
      -- lock the append access
      takeMVar apm
      -- acquire the state
      atomically (readTMVar var >>= throwPoisoned) `onException` putMVar apm ()
    )
    `onException` atomically (modifyTVar qs popAppender)

unsafeReleaseAppendAccess ::
  (MonadThrow (STM m), MonadMVar m, MonadSTM m) =>
  RAWLock m st ->
  st ->
  m ()
unsafeReleaseAppendAccess (RAWLock var apm qs) !st = do
  atomically $ do
    -- write the new state
    tryReadTMVar var >>= \case
      Just (Poisoned (AllowThunk exc)) -> throwIO exc
      _ -> writeTMVar var (Healthy st)
    -- unqueue myself
    modifyTVar qs popAppender
  -- release the append access
  putMVar apm ()