packages feed

disk-bytes-0.1.0.0: src/System/Mem/Disk/Sqlite.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Implementation of an Sqlite-based 'Disk'.
module System.Mem.Disk.Sqlite where

import Control.Concurrent
    ( forkIO, killThread, ThreadId )
import Control.Exception
    ( bracket )
import Control.Monad
    ( forever, when )
import Data.ByteString
    ( ByteString )
import Data.Int
    ( Int64 )
import Data.IORef

import qualified Control.Concurrent.STM as STM
import qualified Data.Text as T
import qualified Database.SQLite3 as Sql
import qualified System.IO.Error as Sys
import qualified System.Directory as Sys

import qualified System.Mem.Disk.DiskApi as DiskApi

{-----------------------------------------------------------------------------
    File system helpers
------------------------------------------------------------------------------}
withFile :: FilePath -> IO a -> IO a
withFile path action = bracket
    (throwIfAlreadyExists path)
    (\_ -> Sys.removeFile path)
    (\_ -> action)

-- | Throw an 'IOError' that indicates that the file already exists.
throwIfAlreadyExists :: FilePath -> IO ()
throwIfAlreadyExists path = do
    b <- Sys.doesFileExist path
    when b $ Sys.ioError $ Sys.mkIOError
        Sys.alreadyExistsErrorType "Creating 'Disk'" Nothing (Just path)

{-----------------------------------------------------------------------------
    Disk
------------------------------------------------------------------------------}
data Disk = Disk
    { path :: FilePath
    , chan :: STM.TChan (Cmd STM.TMVar)
    , counter :: IORef Int64
    }

-- | Obtain the size of the 'Disk' in bytes.
-- Here, this is the file size.
getDiskSize_ :: Disk -> IO Integer
getDiskSize_ = Sys.getFileSize . path

-- | Create a new file and use it for storing
-- t'System.Mem.Disk.DiskBytes'.
--
-- Throw an error if the file already exists,
-- delete the file after use.
withDiskSqlite :: FilePath -> (DiskApi.Disk -> IO a) -> IO a
withDiskSqlite path action =
    withFile path $
    withDatabase path $ \db ->
    withSql db $ \sql ->
    withThread sql $ \chan -> do
        counter <- newIORef 0
        action $ mkDiskApi $ Disk{path,chan,counter}
  where
    withDatabase path = bracket (Sql.open $ T.pack path) Sql.close

mkDiskApi :: Disk -> DiskApi.Disk
mkDiskApi disk = DiskApi.Disk
    { DiskApi.put = put_ disk
    , DiskApi.get = get_ disk
    , DiskApi.delete = delete_ disk
    , DiskApi.getDiskSize = getDiskSize_ disk
    }

{-----------------------------------------------------------------------------
    Disk operations
------------------------------------------------------------------------------}
-- | Operations to be performed on the database
data Cmd cont
    = Put !Int64 ByteString
    | Get !Int64 (cont ByteString)
    | Delete !Int64

put_ :: Disk -> ByteString -> IO Int64
put_ Disk{chan,counter} bytes = do
    index <- atomicModifyIORef' counter (\x -> (x+1,x))
    STM.atomically $ STM.writeTChan chan $ Put index bytes
    pure index

get_ :: Disk -> Int64 -> IO ByteString
get_ Disk{chan} index = do
    k <- STM.newEmptyTMVarIO
    STM.atomically $ STM.writeTChan chan $ Get index k
    STM.atomically $ STM.takeTMVar k

delete_ :: Disk -> Int64 -> IO ()
delete_ Disk{chan} index =
    STM.atomically $ STM.writeTChan chan $ Delete index

{-----------------------------------------------------------------------------
    Worker Thread
------------------------------------------------------------------------------}
-- | Worker thread for sequencing SQL commands.
withThread :: SqlCmds -> (STM.TChan (Cmd STM.TMVar) -> IO a) -> IO a
withThread sql action =
    bracket (mkDatabaseThread sql) (killThread . fst) (\(_,c) -> action c)

mkDatabaseThread :: SqlCmds -> IO (ThreadId, STM.TChan (Cmd STM.TMVar))
mkDatabaseThread sql = do
    chan <- STM.newTChanIO
    threadId <- forkIO $ forever $
        cmdSql sql =<< STM.atomically (STM.readTChan chan)
    pure (threadId, chan)

{-----------------------------------------------------------------------------
    Sql
------------------------------------------------------------------------------}
data SqlCmds = SqlCmds
    { sput_ :: Sql.Statement
    , sget_ :: Sql.Statement
    , sdelete_ :: Sql.Statement
    }

withSql :: Sql.Database -> (SqlCmds -> IO a) -> IO a
withSql db = bracket (initSql db) finalizeSql

initSql :: Sql.Database -> IO SqlCmds
initSql db = do
    Sql.exec db "CREATE TABLE db ( ix INTEGER PRIMARY KEY, bytes BLOB );"
    sput_ <- Sql.prepare db "INSERT INTO db VALUES (?1,?2);"
    sget_ <- Sql.prepare db "SELECT bytes FROM db WHERE ix = ?1;"
    sdelete_ <- Sql.prepare db "DELETE FROM db WHERE ix = ?1;"
    pure $ SqlCmds{sput_,sget_,sdelete_}

finalizeSql :: SqlCmds -> IO ()
finalizeSql SqlCmds{sput_,sget_,sdelete_} = do
    mapM_ Sql.finalize [sput_, sget_, sdelete_]

cmdSql :: SqlCmds -> Cmd STM.TMVar -> IO ()
cmdSql SqlCmds{sput_,sget_,sdelete_} cmd = case cmd of
    Put index bytes -> do
        let s = sput_
        Sql.bind s [Sql.SQLInteger index, Sql.SQLBlob bytes]
        _ <- Sql.stepNoCB s
        reset s

    Get index k -> do
        let s = sget_
        Sql.bind s [Sql.SQLInteger index]
        Sql.Row <- Sql.stepNoCB s
        bytes <- Sql.columnBlob s 0
        reset s
        STM.atomically $ STM.putTMVar k bytes

    Delete index -> do
        let s = sdelete_
        Sql.bind s [Sql.SQLInteger index]
        Sql.Done <- Sql.stepNoCB s
        reset s

  where
    reset s = Sql.reset s >> Sql.clearBindings s