arena-0.1: src/Database/Arena.hs
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Database.Arena
(
ArenaLocation(..)
, ArenaT
, Arena
, ArenaDB
, runArenaT
, runArena
, startArena, initArena
, addData
, accessData
) where
import Control.Applicative
import Control.Concurrent.MVar
import Control.Monad
import Control.Monad.Reader
import Control.Monad.Trans
import Data.Bytes.Get
import Data.Bytes.Put
import Data.Bytes.Serial
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as BSL
import Data.Digest.CRC32
import Data.Either
import Data.Foldable
import Data.IORef
import Data.List
import Data.Maybe
import Data.Semigroup
import qualified Data.Set as Set
import Data.String
import Data.Typeable (cast)
import qualified Data.Vector.Persistent as PV
import Data.Word
import GHC.IO.Device
import GHC.IO.Exception
import qualified GHC.IO.FD as FD
import GHC.IO.Handle
import qualified GHC.IO.Handle.FD as FD
import GHC.IO.Handle.Internals
import GHC.IO.Handle.Types
import Safe
import System.Directory
import System.FilePath
import System.IO
import System.IO.Error
import System.Posix.IO (handleToFd)
import System.Posix.Types (Fd (Fd))
import System.Posix.Unistd (fileSynchroniseDataOnly)
-- | The base directory for the arena files to be stored under.
newtype ArenaLocation = ArenaLocation { getArenaLocation :: FilePath }
deriving (Eq, Ord, Read, Show, IsString)
type ArenaID = Word32
type JournalHash = Word32
data JournalFrame =
JF {
jLength :: Word32
-- ^ The amount of data stored in the frame.
, jHash :: JournalHash
-- ^ A hash of the data stored in the frame so we can detect write corruption.
-- This is sufficient for the entire frame as all other elements are fixed
-- size and directly impact the data's hash.
, jData :: BS.ByteString
-- ^ The user's serialized data.
}
mkJournal :: Serial a => a -> JournalFrame
mkJournal a = JF (fromIntegral . BS.length $ d) (crc32 d) d
where d = runPutS . serialize $ a
instance Serial JournalFrame where
serialize (JF l h d) = serialize l *> serialize h *> putByteString d
deserialize = do
l <- deserialize
h <- deserialize
d <- getByteString . fromIntegral $ l
if h == crc32 d
then return $ JF l h d
else fail "Journal Frame failed CRC check---Also, uh, this method shouldn't be in Monad"
data OpenJournal a b =
OJ {
ojArenaID :: ArenaID
-- ^ The current datablock generation, starting from zero and strictly monotonic.
, ojHandle :: Handle
-- ^ The journal file, opened and positioned for writing
, ojSummary :: Option b
-- ^ The current summary, if any values have been stored in the journal
, ojVals :: PV.Vector a
-- ^ The values currently held in the journal on disk, in a 'PV.Vector' (instead of a list) to
-- improve GC performance, and provide fast snoc to avoid the use of reverse.
}
readDataFile :: (MonadIO m, Serial a) => ArenaLocation -> ArenaID -> m [a]
readDataFile l ai = liftIO $ do
d <- BSL.readFile (dataFile l ai)
return $ runGetL (many deserialize) d
syncHandle :: Handle -> IO ()
syncHandle h = do
hFlush h
unsafeSyncHandle h
where
unsafeSyncHandle h@(DuplexHandle {}) =
ioError (ioeSetErrorString (mkIOError IllegalOperation
"unsafeSyncHandle" (Just h) Nothing)
"unsafeSyncHandle only works on file descriptors")
unsafeSyncHandle h@(FileHandle _ m) = do
withMVar m $ \h_@Handle__{haType=_,..} -> do
case cast haDevice of
Nothing -> ioError (ioeSetErrorString (mkIOError IllegalOperation
"unsafeSyncHandle" (Just h) Nothing)
("handle is not a file descriptor"))
Just fd -> do
flushWriteBuffer h_
fileSynchroniseDataOnly . Fd . FD.fdFD $ fd
withFileSync :: FilePath -> (Handle -> IO r) -> IO r
withFileSync fp f = liftIO $ withFile fp WriteMode go
where go h = f h <* syncHandle h
data ArenaDB summary finalized d = ArenaDB {
adSummarize :: d -> summary
-- ^ Convert the user's data to an instance of the summary semigroup
, adFinalize :: summary -> finalized
-- ^ Removal of data only used for the merge operation or determining fullness,
-- changing the summary to a version used purely for the datablock indexing.
, adArenaFull :: summary -> Bool
-- ^ Determine if we've reached a datablock boundary.
, adArenaLocation :: ArenaLocation
-- ^ The location on the filesystem in which we store the data and journal.
, adDataRef :: IORef [(finalized, IO [d])]
-- ^ The list of datablocks, seen as their summary and accessors, for read operations.
, adCurrentJournal :: MVar (OpenJournal d summary)
-- ^ The journal for writing new data, in an 'MVar' so we can lock it for consistency when
-- doing writes or data access.
}
-- | * @s@: The summary semigroup for the journal data.
-- * @f@: The finalized summary associated with a datablock.
-- * @d@: The user's datatype stored in the arena.
newtype ArenaT s f d m a = ArenaT { unArenaT :: ReaderT (ArenaDB s f d) m a }
deriving (Functor, Applicative, Monad, MonadTrans, MonadReader (ArenaDB s f d), MonadIO)
runArenaT :: ArenaDB s f d -> ArenaT s f d m a -> m a
runArenaT ad = flip runReaderT ad . unArenaT
type Arena s f d a = ArenaT s f d IO a
runArena :: ArenaDB s f d -> Arena s f d a -> IO a
runArena = runArenaT
summarize :: Monad m => d -> ArenaT s f d m s
summarize d = asks adSummarize <*> pure d
finalize :: Monad m => s -> ArenaT s f d m f
finalize s = asks adFinalize <*> pure s
journalDir, dataDir, tempJournal :: ArenaLocation -> FilePath
journalDir (ArenaLocation fp) = fp </> "journal"
dataDir (ArenaLocation fp) = fp </> "data"
tempJournal al = journalDir al </> "temp"
journalFile, dataFile, dataSummaryFile :: ArenaLocation -> ArenaID -> FilePath
journalFile al i = journalDir al </> show i
dataFile al i = dataDir al </> addExtension (show i) "data"
dataSummaryFile al i = dataDir al </> addExtension (show i) "header"
journalDir', dataDir', tempJournal' :: Monad m => ArenaT s f d m FilePath
journalDir' = asks (journalDir . adArenaLocation)
dataDir' = asks (dataDir . adArenaLocation)
tempJournal' = asks (tempJournal . adArenaLocation)
journalFile', dataFile', dataSummaryFile' :: Monad m => ArenaID -> ArenaT s f d m FilePath
journalFile' ai = asks (journalFile . adArenaLocation) <*> pure ai
dataFile' ai = asks (dataFile . adArenaLocation) <*> pure ai
dataSummaryFile' ai = asks (dataSummaryFile . adArenaLocation) <*> pure ai
data TheFiles = TheFiles {
theJournalDir
, theDataDir
, theTempJournal
, theJournalFile
, theDataFile
, theDataSummaryFile :: FilePath
} deriving (Eq, Ord, Read, Show)
theFiles :: Monad m => Maybe ArenaID -> ArenaT s f d m TheFiles
theFiles Nothing = theFiles (Just $ -1)
theFiles (Just ai) =
TheFiles <$> journalDir' <*> dataDir' <*> tempJournal'
<*> journalFile' ai <*> dataFile' ai <*> dataSummaryFile' ai
-- | Setup the directory structure for Arena.
-- This is performed implicitly by 'startArena'.
initArena :: ArenaLocation -> IO ()
initArena al = do
createDirectoryIfMissing True . journalDir $ al
createDirectoryIfMissing True . dataDir $ al
-- | Launch an 'ArenaDB', using the given summarizing, finalizing, and block policy functions,
-- at the given 'ArenaLocation'.
--
-- __NB:__ Two 'ArenaDB's must not be run concurrently with a shared 'ArenaLocation'.
-- Data loss from the journal is likely to result.
startArena
:: (Serial d, Serial f, Semigroup s)
=> (d -> s) -> (s -> f) -> (s -> Bool) -> ArenaLocation -> IO (ArenaDB s f d)
startArena adSummarize adFinalize adArenaFull adArenaLocation = do
initArena adArenaLocation
runArena fakeConf $ do
adCurrentJournal <- internArenas >>= cleanJournal >>= liftIO . newMVar
adDataRef <- readAllData >>= liftIO . newIORef
return ArenaDB {..}
where fakeConf = ArenaDB { adCurrentJournal = error "uninitialized current journal"
, adDataRef = error "uninitialized data ref"
, .. }
readJournalFile :: (Serial d, MonadIO m) => ArenaID -> ArenaT s f d m (PV.Vector d)
readJournalFile ai = do
d <- journalFile' ai >>= liftIO . BSL.readFile
return . PV.fromList . map (head . rights . pure . runGetS deserialize . jData) . runGetL (many deserialize) $ d
cleanJournal :: (MonadIO m, Serial d, Semigroup s) => ArenaID -> ArenaT s f d m (OpenJournal d s)
cleanJournal ai = do
TheFiles {..} <- theFiles (Just ai)
liftIO $ doesFileExist theTempJournal >>= (`when` removeFile theTempJournal)
je <- liftIO $ doesFileExist theJournalFile
case je of
False -> do
jh <- liftIO $ openFile theJournalFile WriteMode
return $ OJ ai jh (Option Nothing) mempty
True -> do
as <- readJournalFile ai
liftIO $ withFileSync theTempJournal $ \h ->
mapM_ (BS.hPutStr h . runPutS . serialize . mkJournal) as
liftIO $ renameFile theTempJournal theJournalFile
jh <- liftIO $ openFile theJournalFile AppendMode
as' <- if null as
then return $ Option Nothing
else Option . Just <$> regenerateSummary as
return $ OJ ai jh as' as
readArenaIDs :: MonadIO m => FilePath -> m [ArenaID]
readArenaIDs dir = liftIO go
where go = mapMaybe readMay <$> listDirectory dir
internArenas :: (MonadIO m, Serial d, Serial f, Semigroup s) => ArenaT s f d m ArenaID
internArenas = do
js <- journalDir' >>= readArenaIDs
if null js
then return 0
else do
let latest = maximum js
old = delete latest js
mapM_ internArenaFile old
return latest
regenerateSummary :: (Semigroup s, Monad m) => PV.Vector d -> ArenaT s f d m s
regenerateSummary ds = foldl1 (<>) <$> traverse summarize ds
internArenaFile :: (MonadIO m, Serial f, Serial d, Semigroup s) => ArenaID -> ArenaT s f d m ()
internArenaFile ai = do
TheFiles {..} <- theFiles (Just ai)
as <- readJournalFile ai
fas <- regenerateSummary as >>= finalize
liftIO $ do withFileSync theDataFile $ \h -> mapM_ (BS.hPutStr h . runPutS . serialize) as
withFileSync theDataSummaryFile $ \h -> BS.hPutStr h . runPutS . serialize $ fas
removeFile theJournalFile
readDataFile' :: (MonadIO m, Serial d) => ArenaID -> ArenaT s f d m (IO [d])
readDataFile' ai = asks (readDataFile . adArenaLocation) <*> pure ai
readAllData :: (MonadIO m, Serial f, Serial d) => ArenaT s f d m [(f, IO [d])]
readAllData = do
TheFiles {..} <- theFiles Nothing
ds <- Set.fromList . mapMaybe (readMay . dropExtension) <$> liftIO (listDirectory theDataDir)
forM (Set.toList ds) $ \d -> do
dsf <- dataSummaryFile' d
c <- runGetL deserialize <$> liftIO (BSL.readFile dsf)
rdf <- readDataFile' d
return (c, rdf)
-- | Access an atomic snapshot of the 'ArenaDB' as a list of summaries and accessors
-- for their associated data. One datablock is the journal, and thus does not satisfy
-- the block policy.
--
-- The returned IO actions in the list provide access to a state consistent with the time
-- when the list of accessors was returned, but do not hold more then the journal at that
-- time's contents in memory.
accessData :: MonadIO m => ArenaT s f d m [(f, IO [d])]
accessData = do
ArenaDB {..} <- ask
liftIO . withMVar adCurrentJournal $ \(OJ _ _ s as) -> do
d <- readIORef adDataRef
return $ if null as
then d
else (adFinalize . fromJust . getOption $ s, return . toList $ as) : d
-- | Durably insert a piece of data into the 'ArenaDB'.
-- This funtion returns after the data is sync'd to disk.
addData :: (MonadIO m, Serial d, Serial f, Semigroup s) => d -> ArenaT s f d m ()
addData d = do
conf@ArenaDB {..} <- ask
liftIO . modifyMVar_ adCurrentJournal $ \(OJ ai h s ds) -> do
BS.hPutStr h . runPutS . serialize . mkJournal $ d
let s' = s <> (pure . adSummarize $ d)
syncHandle h
case adArenaFull . fromJust . getOption $ s' of
False -> return $ OJ ai h s' (ds `PV.snoc` d)
True -> do
hClose h
let (ArenaT rdr) = internArenaFile ai -- :: Arena s f d ()
runReaderT rdr conf
atomicModifyIORef' adDataRef $ \ods ->
((adFinalize . fromJust . getOption $ s', readDataFile adArenaLocation ai):ods, ())
let ai' = ai + 1
h' <- openFile (journalFile adArenaLocation ai') WriteMode
return $ OJ ai' h' mempty mempty