persistent-stm-0.1.0.1: src/PersistentSTM.hs
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MonoLocalBinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE UndecidableInstances #-}
-- | A scheme for adding persistence to Haskell's STM transactions. A @'DBRef'
-- a@ is like a @'TVar' ('Maybe' a)@, except that it exists (or not) in
-- persistent storage as well as in memory.
--
-- The choice of persistent storage is up to the user, and is specified with a
-- 'Persistence'. There is a default implementation called 'filePersistence'
-- that uses files on disk. Note that 'filePersistence' doesn't guarantee
-- transactional atomicity in the presence of sudden termination of the process,
-- such as in a power outage or system crash. Therefore, for serious use,
-- it's recommended that you use a different 'Persistence' implementation based
-- on a storage layer with stronger transactional guarantees.
--
-- For this scheme to work at all, this process must be the only entity to
-- access the persistent storage. You may not even use a single-writer,
-- multiple-reader architecture, because consistency guarantees for reads, as
-- well, depend on all writes happening in the current process.
module PersistentSTM
( DB,
openDB,
closeDB,
withDB,
waitForMaxBacklog,
synchronously,
DBRef,
DBStorable (..),
getDBRef,
readDBRef,
writeDBRef,
deleteDBRef,
Persistence (..),
filePersistence,
)
where
import Control.Concurrent
( forkIO,
newEmptyMVar,
putMVar,
takeMVar,
)
import Control.Concurrent.STM
( STM,
TVar,
atomically,
modifyTVar,
newTVar,
newTVarIO,
readTVar,
retry,
writeTVar,
)
import Control.Exception (bracket)
import Control.Monad (forM_, when)
import Control.Monad.Extra (whileM)
import Data.Binary (Binary)
import qualified Data.Binary as Binary
import Data.Bool (bool)
import qualified Data.ByteString as BS
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LBS
import Data.ByteString.Short (ShortByteString)
import Data.Int (Int16, Int32, Int64, Int8)
import Data.Map (Map)
import qualified Data.Map.Strict as Map
import Data.Proxy (Proxy (..))
import Data.Typeable (TypeRep, Typeable, typeRep)
import Data.Word (Word16, Word32, Word64, Word8)
import qualified Focus
import GHC.Conc (unsafeIOToSTM)
import Numeric.Natural (Natural)
import qualified StmContainers.Map as SMap
import System.Directory (createDirectoryIfMissing, doesFileExist, removeFile)
import System.FileLock (SharedExclusive (..), tryLockFile, unlockFile)
import System.FilePath ((</>))
import System.Mem.Weak (Weak, deRefWeak, mkWeak)
import Unsafe.Coerce (unsafeCoerce)
-- | A type class for things that can be stored in a DBRef. This is similar to
-- a serialization class like 'Binary', but reads have access to the 'DB' and
-- the STM monad, which is important because it allows for one 'DBRef' to be
-- stored inside the value of another. (In this case, 'decode' will call
-- 'getDBRef'.)
class Typeable a => DBStorable a where
decode :: DB -> ByteString -> STM a
default decode :: (Binary a) => DB -> ByteString -> STM a
decode _ = pure . Binary.decode
encode :: a -> ByteString
default encode :: (Binary a) => a -> ByteString
encode = Binary.encode
instance DBStorable ()
instance DBStorable Bool
instance DBStorable Char
instance DBStorable Double
instance DBStorable Float
instance DBStorable Int
instance DBStorable Int8
instance DBStorable Int16
instance DBStorable Int32
instance DBStorable Int64
instance DBStorable Integer
instance DBStorable Natural
instance DBStorable Ordering
instance DBStorable Word
instance DBStorable Word8
instance DBStorable Word16
instance DBStorable Word32
instance DBStorable Word64
instance DBStorable BS.ByteString
instance DBStorable ByteString
instance DBStorable ShortByteString
instance DBStorable a => DBStorable [a] where
encode = Binary.encode . fmap encode
decode db = traverse (decode db) . Binary.decode
-- | Internal state of a 'DBRef'. Loading means that the value is already
-- being loaded from persistent storage in a different thread, so the current
-- transaction can just retry to wait for it to load.
data Possible a = Loading | Missing | Present a
-- | Existential wrapper around 'TVar' that lets 'TVar's of various types be
-- cached.
data SomeTVar = forall a. SomeTVar (TVar (Possible a))
-- | A strategy for persisting values from 'DBRef' to some persistent storage.
-- The 'filePersistence' implementation is provided as a quick way to get
-- started, but note the weaknesses in its documentation.
--
-- A 'Persistence' can read one value at a time, but should be able to atomically
-- write/delete an entire set of keys at once, preferably atomically.
data Persistence = Persistence
{ -- | Read a single value from persistent storage. Return the serialized
-- representation if it exists, and Nothing otherwise.
persistentRead :: String -> IO (Maybe ByteString),
-- | Write (for 'Just' values) or delete (for 'Nothing' values) an entire
-- set of values to persistent storage. The values should ideally be
-- written atomically, and if they are not then the implementation will be
-- vulnerable to inconsistent data and corruption if the process is suddenly
-- terminated.
persistentWrite :: Map String (Maybe ByteString) -> IO (),
-- | Perform any cleanup that is needed after the 'DB' is closed. This can
-- include releasing locks, for example.
persistentFinish :: IO ()
}
-- | A currently open database in which 'DBRef's can be read and written. See
-- 'openDB', 'closeDB', and 'withDB' to manage 'DB' values.
data DB = DB
{ -- | Cached 'TVar's corresponding to 'DBRef's that are already loading or
-- loaded.
dbRefs :: SMap.Map String (TypeRep, Weak SomeTVar),
-- | The last written generation number, used to find out when writes are
-- committed.
dbGeneration :: TVar Natural,
-- | Collection of dirty values that need to be written. Only the
-- 'ByteString' from the value is needed, but keeping the 'TVar' as well
-- ensures that the 'TVar' won't be garbage collected and removed from
-- dbRefs, which guarantees the value won't be read again until after the
-- write is complete. This is needed for consistency.
dbDirty :: TVar (Map String (SomeTVar, Maybe ByteString)),
-- | The persistence that is used for this database.
dbPersistence :: Persistence,
-- | If True, then 'closeDB' has been called, and the no new accesses to the
-- 'DBRef's should be allowed. This also triggers the writer thread to exit
-- as soon as it has finished writing all dirty values.
dbClosing :: TVar Bool,
-- | If True, the writer thread as finished writing all dirty values, and
-- it's okay for the process to exit.
dbClosed :: TVar Bool
}
-- | A reference to persistent data from some 'DB' that can be accessed in 'STM'
-- transaction. @'DBRef' a@ is similar to @'TVar ('Maybe' a)@, except that
-- values exist in persistent storage as well as in memory.
data DBRef a = DBRef DB String (TVar (Possible a))
-- | Only 'DBRef's in the same 'DB' should be compared.
instance Eq (DBRef a) where
DBRef _ k1 _ == DBRef _ k2 _ = k1 == k2
-- | Only 'DBRef's in the same 'DB' should be compared.
instance Ord (DBRef a) where
compare (DBRef _ k1 _) (DBRef _ k2 _) = compare k1 k2
instance Show (DBRef a) where
show (DBRef _ s _) = s
instance DBStorable a => DBStorable (DBRef a) where
decode db bs = getDBRef db (Binary.decode bs)
encode (DBRef _ dbkey _) = Binary.encode dbkey
-- | A simple 'Persistence' that stores data in a directory in the local
-- filesystem. This is an easy way to get started. However, note that because
-- writes are not atomic, your data can be corrupted during a crash or power
-- outage. For this reason, it's recommended that you use a different
-- 'Persistence' for most applications.
filePersistence :: FilePath -> IO Persistence
filePersistence dir = do
createDirectoryIfMissing True dir
tryLockFile (dir </> ".lock") Exclusive >>= \case
Nothing -> error "Directory is already in use"
Just lock ->
return $
Persistence
{ persistentRead = \key -> do
ex <- doesFileExist (dir </> key)
if ex
then Just <$> LBS.fromStrict <$> BS.readFile (dir </> key)
else return Nothing,
persistentWrite = \dirtyMap -> forM_ (Map.toList dirtyMap) $
\(key, mbs) -> case mbs of
Just bs -> BS.writeFile (dir </> key) (LBS.toStrict bs)
Nothing -> removeFile (dir </> key),
persistentFinish = unlockFile lock
}
-- | Opens a 'DB' using the given 'Persistence'. The caller should guarantee
-- that 'closeDB' is called when the 'DB' is no longer needed.
openDB :: Persistence -> IO DB
openDB persistence = do
refs <- SMap.newIO
generation <- newTVarIO 0
dirty <- newTVarIO Map.empty
closing <- newTVarIO False
closed <- newTVarIO False
_ <- forkIO $ do
whileM $ do
(d, c) <- atomically $ do
c <- readTVar closing
d <- readTVar dirty
when (not c && Map.null d) retry
when (not (Map.null d)) $ writeTVar dirty Map.empty
return (d, c)
when (not (Map.null d)) $ persistentWrite persistence (snd <$> d)
atomically $ modifyTVar generation (+ 1)
return (not c)
atomically $ writeTVar closed True
let db =
DB
{ dbRefs = refs,
dbGeneration = generation,
dbDirty = dirty,
dbPersistence = persistence,
dbClosing = closing,
dbClosed = closed
}
return db
-- | Closes a 'DB'. When this call returns, all data will be written to
-- persistent storage, and the program can exit without possibly losing data.
closeDB :: DB -> IO ()
closeDB db = do
atomically $ writeTVar (dbClosing db) True
atomically $ readTVar (dbClosed db) >>= bool retry (return ())
persistentFinish (dbPersistence db)
-- | Runs an action with a 'DB' open. The 'DB' will be closed when the action
-- is finished. The 'DB' value should not be used after the action has
-- returned.
withDB :: Persistence -> (DB -> IO a) -> IO a
withDB persistence f = bracket (openDB persistence) closeDB f
-- | Check that there are at most the given number of queued writes to the
-- database, and retries the transaction if so. Adding this to the beginning of
-- your transactions can help prevent writes from falling too far behind the
-- live data. Prioritizing writes this way can also reduce memory usage,
-- because unreachable 'DBRef's no longer need to be retained once they are
-- written to disk.
waitForMaxBacklog :: DB -> Int -> STM ()
waitForMaxBacklog db maxLen = do
dirty <- readTVar (dbDirty db)
when (Map.size dirty > maxLen) retry
-- | Throws an error if the given 'DB' is closing. This prevents more work from
-- being added to the queue when we're supposed to be waiting for the last
-- writes to flush out.
failIfClosing :: DB -> STM ()
failIfClosing db = do
c <- readTVar (dbClosing db)
when c $ error "DB is closing"
-- | Atomically performs an STM transaction just like 'atomically', but also
-- waits for any changes it might have observed in the 'DB' to be written to
-- persistent storage before returning. This guarantees that a transaction
-- whose results were observed will not be rolled back if the program crashes.
synchronously :: DB -> STM a -> IO a
synchronously db txn = do
(result, gen) <- atomically $ do
result <- txn
gen <- readTVar (dbGeneration db)
dirty <- readTVar (dbDirty db)
if Map.null dirty
then return (result, Nothing)
else return (result, Just (gen + 1))
case gen of
Just n ->
atomically $
readTVar (dbGeneration db) >>= bool retry (return ()) . (>= n)
_ -> return ()
return result
-- | Retrieves a 'DBRef' from a 'DB' for the given key. Throws an exception if
-- the 'DBRef' requested has a different type from a previous time the key was
-- used in this process, or if a serialized value in persistent storage cannot
-- be parsed.
getDBRef :: forall a. DBStorable a => DB -> String -> STM (DBRef a)
getDBRef db key = do
failIfClosing db
SMap.lookup key (dbRefs db) >>= \case
Just (tr, weakRef)
| tr == typeRep (Proxy @a) ->
unsafeIOToSTM (deRefWeak weakRef) >>= \case
Just (SomeTVar ref) -> return (DBRef db key (unsafeCoerce ref))
Nothing -> insert
| otherwise -> error "Type mismatch in DBRef"
Nothing -> insert
where
insert = do
ref <- newTVar Loading
ptr <- unsafeIOToSTM $ mkWeak ref (SomeTVar ref) (Just cleanupKey)
SMap.insert (typeRep (Proxy @a), ptr) key (dbRefs db)
v <- unsafeIOToSTM $ do
mvar <- newEmptyMVar
_ <- forkIO $ putMVar mvar =<< readKey
takeMVar mvar
writeTVar ref v
return (DBRef db key ref)
readKey = do
readResult <- persistentRead (dbPersistence db) key
case readResult of
Just bs -> Present <$> atomically (decode db bs)
Nothing -> return Missing
cleanupKey =
atomically $
SMap.focus
( Focus.updateM
( \(tr, p) ->
unsafeIOToSTM (deRefWeak p) >>= \case
Nothing -> return Nothing
Just _ -> return (Just (tr, p))
)
)
key
(dbRefs db)
-- | Gets the value stored in a 'DBRef'. The value is @'Just' x@ if @x@ was
-- last value stored in the database using this key, or 'Nothing' if there is no
-- value stored in the database.
readDBRef :: DBRef a -> STM (Maybe a)
readDBRef (DBRef db _ ref) = do
failIfClosing db
readTVar ref >>= \case
Loading -> retry
Missing -> return Nothing
Present a -> return (Just a)
-- | Updates the value stored in a 'DBRef'. The update will be persisted to
-- storage soon, but not synchronously.
writeDBRef :: DBStorable a => DBRef a -> a -> STM ()
writeDBRef (DBRef db dbkey ref) a = do
failIfClosing db
writeTVar ref (Present a)
d <- readTVar (dbDirty db)
writeTVar (dbDirty db) (Map.insert dbkey (SomeTVar ref, Just (encode a)) d)
-- | Deletes the value stored in a 'DBRef'. The delete will be persisted to
-- storage soon, but not synchronously.
deleteDBRef :: DBStorable a => DBRef a -> STM ()
deleteDBRef (DBRef db dbkey ref) = do
failIfClosing db
writeTVar ref Missing
d <- readTVar (dbDirty db)
writeTVar (dbDirty db) (Map.insert dbkey (SomeTVar ref, Nothing) d)