webapp-0.0.1: Web/App/FileCache.hs
{-# LANGUAGE TupleSections #-}
{-|
Module : Web.App.FileCache
Copyright : (c) Nathaniel Symer, 2015
License : MIT
Maintainer : nate@symer.io
Stability : experimental
Portability : POSIX
Memory-map style file cache. Watches a directory and updates the cache as changes
to files occur. Stores GZIP compressed representations of files and MD5 sums of the
uncompressed representations of the cached files.
-}
module Web.App.FileCache
(
-- * Types
-- * Data Structures
FileCache(..),
-- * Type Aliases
Entry,
FileTransform,
-- * Creation & Destruction
newFileCache,
teardownFileCache,
-- * Cache Operations
lookup,
register,
register',
register'',
refresh,
invalidate
)
where
import System.FSNotify
import System.Directory
import System.FilePath
import qualified Data.HashTable.IO as HT
import qualified Data.ByteString.Lazy.Char8 as BL
import qualified Data.ByteString.Char8 as B
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Base16 as B16
import Control.Monad
import Control.Concurrent
import Control.Exception (try, displayException, IOException)
import qualified Codec.Compression.GZip as GZIP (compress)
import qualified Crypto.Hash.MD5 as MD5 (hash)
import Prelude hiding (lookup)
type HashTable k v = HT.CuckooHashTable k v
-- |Takes the raw file contents and returns either an error (String)
-- or the transformed files contents
type FileTransform = (ByteString -> Either String ByteString)
-- |Either an error message (Left) or a tuple (Right)
-- containing the GZIP'd file contents (fst) and the
-- MD5 sum of the uncompressed file contents (snd)
type Entry = Either String (ByteString, ByteString)
-- |A data structure representing a file cache. Most people
-- shouldn't need to modify this structure.
data FileCache = FileCache {
fileCacheBasePath :: FilePath, -- ^ 'FileCache''s base path. All 'FilePath' keys in 'fileCacheHashTable' are relative to this path.
fileCacheHashTable :: MVar (HashTable FilePath (Entry, FileTransform)), -- ^ See 'fileCachePath'
fileCacheFSMonitor :: WatchManager -- ^ FSMonitor structure that watches 'fileCacheBasePath'
}
-- |Creates a new 'FileCache'
newFileCache :: FilePath -- ^ Base path of the 'FileCache' to be created
-> IO FileCache -- ^ The resulting 'FileCache'
newFileCache path@('/':_) = do -- create a file cache based on an absolute path
fc <- FileCache (addTrailingPathSeparator path) <$> (HT.new >>= newMVar) <*> startManager
startWatching fc
return fc
where
startWatching fc@(FileCache bp _ m) = void $ watchTree m bp (const True) (f fc)
f _ (Added _ _) = return ()
f fc@(FileCache bp _ _) (Modified pth _) = refresh fc (makeRelative bp pth)
f fc@(FileCache bp _ _) (Removed pth _) = invalidate fc (makeRelative bp pth)
newFileCache path = mkAbsPath path >>= newFileCache -- resolve a relative path to an absolute path
where mkAbsPath p = (</>) <$> getCurrentDirectory <*> pure p
-- |Frees resources associated with a 'FileCache'
teardownFileCache :: FileCache -> IO ()
teardownFileCache = stopManager . fileCacheFSMonitor
-- |Lookup an entry in the cache
lookup :: FileCache -- ^ 'FileCache' to be used
-> FilePath -- ^ 'FilePath' to look up
-> IO (Maybe Entry) -- ^ 'Entry' for the provided 'FilePath'
lookup (FileCache _ mht _) k = withMVar mht $ \ht -> fmap fst <$> HT.lookup ht k
-- |Register a 'FilePath' and 'FileTransform' with the cache
register :: FileCache -- ^ 'FileCache' to register in
-> Int -- ^ 'Entry' timeout. A timeout of 0 indicates the entry should never expire
-> FilePath -- ^ 'FilePath' to register
-> FileTransform -- ^ 'FileTransform' for the 'Entry' to be generated
-> IO Entry -- ^ Entry for 'FilePath'
register fc@(FileCache bp mht _) timeout k f = do
v <- xformedReadFileE f (bp </> k)
withMVar mht $ \ht -> HT.insert ht k (v, f)
when (timeout > 0) $ void $ forkIO $ a
return v
where
a = do
threadDelay timeout
invalidate fc k
-- |'register' with a zero timeout
register' :: FileCache -> FilePath -> FileTransform -> IO Entry
register' fc k f = register fc 0 k f
-- |'register' with a zero timeout and no @FileTransform@
register'' :: FileCache -> FilePath -> IO Entry
register'' fc k = register fc 0 k Right
-- |Force reload an entry in the cache
refresh :: FileCache -- ^ 'FileCache' to be used
-> FilePath -- ^ 'FilePath' to reload
-> IO ()
refresh (FileCache bp mht _) k = withMVar mht f
where
f ht = HT.lookup ht k >>= g ht . fmap snd
g ht (Just xform) = xformedReadFileE xform (bp </> k) >>= HT.insert ht k . (,xform)
g _ Nothing = return ()
-- @invalidate@
-- invalidate an entry in the cache
-- fc -> the cache
-- k -> the key used; Should be a path relative to the FileCache's base path
-- |Invalidate an entry in a 'FileCache'
invalidate :: FileCache -- ^ 'FileCache' to be used
-> FilePath -- ^ 'FilePath' to invalidate
-> IO ()
invalidate (FileCache _ mht _) k = withMVar mht (flip HT.delete k)
{- Internal -}
readFileE :: FilePath -> IO (Either String ByteString)
readFileE fp = (either (Left . displayException) Right) <$> readF
where
readF :: IO (Either IOException ByteString)
readF = try $ B.readFile fp
-- returns (contents, md5)
xformedReadFileE :: FileTransform -> FilePath -> IO Entry
xformedReadFileE f fp = readFileE fp >>= return . either Left (buildEntry . f)
where
buildEntry = either Left (\cnts -> Right (compress cnts, md5 cnts))
compress = BL.toStrict . GZIP.compress . BL.fromStrict
md5 = B16.encode . MD5.hash