packages feed

haskell-docs-cli-1.0.0.0: src/Data/Cache.hs

{-# LANGUAGE NumericUnderscores #-}
-- | Store results of named computations.
module Data.Cache
  ( create
  , cached
  , enforce
  , Store(..)
  , EvictionPolicy(..)
  , Cache
  , MaxBytes(..)
  , MaxAgeDays(..)
  )
  where

import Control.Exception (try, throwIO, fromException, SomeAsyncException(..))
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad (void)
import Data.ByteString.Lazy (ByteString)
import Data.Time.Clock (UTCTime(..))
import Data.Traversable (for)
import Data.Foldable (traverse_)
import Data.List (isPrefixOf, find, intercalate, sortOn)
import Data.Maybe (mapMaybe, fromMaybe)
import Control.Concurrent.MVar (MVar)
import Data.Map.Strict (Map)
import System.FilePath.Posix ((</>))
import System.Directory (listDirectory, removeFile, getFileSize)
import Text.Read (readMaybe)

import qualified Data.Time.Clock as Time
import qualified Data.Time.Calendar as Time
import qualified Data.ByteString.Lazy as ByteString
import qualified Control.Concurrent.MVar as MVar
import qualified Data.Map.Strict as Map
import qualified Data.Hashable as Hashable

data Cache = Cache
  { cache_eviction :: EvictionPolicy
  , cache_inFlight :: MVar (Map Hash (UTCTime, MVar ByteString))
  }

newtype Store = Store FilePath
newtype Hash = Hash Int
  deriving newtype (Show, Eq, Ord)

data EvictionPolicy
  = Evict MaxBytes MaxAgeDays Store
  | NoStorage

data MaxBytes   = MaxBytes Integer | NoMaxBytes
data MaxAgeDays = MaxAgeDays Int | NoMaxAge

data Entry = Entry
  { entry_hash :: Hash
  , entry_time :: UTCTime
  }
  deriving (Show, Eq)

newtype SerialisedEntry = SerialisedEntry String
  deriving Show

create :: MonadIO m => EvictionPolicy -> m Cache
create policy = Cache policy <$> liftIO (MVar.newMVar mempty)

-- | Try to get result from cache. If not present, run computation.
cached :: MonadIO m => Cache -> String -> m ByteString -> m ByteString
cached cache name act = do
  now <- liftIO Time.getCurrentTime
  let entry = toEntry name now
  mcontent <- retrieve cache entry
  case mcontent of
    Just content -> return content
    Nothing -> do
      var <- liftIO MVar.newEmptyMVar
      liftIO
        $ MVar.modifyMVar_ (cache_inFlight cache)
        $ return . Map.insert (entry_hash entry) (now, var)
      content <- act
      liftIO $ MVar.putMVar var content
      save cache entry content
      return content

enforce :: MonadIO m => EvictionPolicy -> m ()
enforce = \case
  NoStorage -> return ()
  Evict maxSize maxAge store -> liftIO $ do
    serialised <- readEntriesFrom store
    let entries = sortOn entry_time $ mapMaybe deserialise serialised
    oversize <- overLimit store maxSize entries
    overage  <- overAge maxAge entries
    traverse_ (remove store) $ oversize ++ overage
  where
    overLimit :: Store -> MaxBytes -> [Entry] -> IO [Entry]
    overLimit _ _ [] = return []
    overLimit _ NoMaxBytes _ = return []
    overLimit store (MaxBytes bytes) (entry:rest) = do
      s <- fromMaybe 0 <$> size store entry
      let remaining = bytes - s
      if remaining >= 0
        then overLimit store (MaxBytes remaining) rest
        else (entry:) <$> overLimit store (MaxBytes bytes) rest

    overAge :: MaxAgeDays -> [Entry] -> IO [Entry]
    overAge NoMaxAge _ = return []
    overAge (MaxAgeDays days) entries = do
      now <- Time.getCurrentTime
      let threshold = Time.addUTCTime (-Time.nominalDay * fromIntegral days) now
      return $ filter (olderThan threshold) entries

    olderThan :: UTCTime -> Entry -> Bool
    olderThan threshold (Entry _ time) =
      time < threshold

    remove :: Store -> Entry -> IO ()
    remove store = void . trySync . removeFile . location store

    size :: Store -> Entry -> IO (Maybe Integer)
    size store = trySync. getFileSize . location store

-- | Catch synchronous exceptions and return Nothing
-- Asynchronous exceptions will kill the action
trySync :: IO a -> IO (Maybe a)
trySync act = do
  res <- try act
  case res of
    Left e | Just (SomeAsyncException _) <- fromException e -> throwIO e
           | otherwise -> return Nothing
    Right r -> return $ Just r

location :: Store -> Entry -> FilePath
location store = fileName store . serialise

fileName :: Store -> SerialisedEntry -> FilePath
fileName (Store path) (SerialisedEntry s) = path </> s

storePath :: Cache -> Maybe Store
storePath Cache{..} =
  case cache_eviction of
    Evict _ _ store -> Just store
    NoStorage       -> Nothing

save :: MonadIO m => Cache -> Entry -> ByteString -> m ()
save cache entry content
  | Just store <- storePath cache
  = liftIO $ ByteString.writeFile (location store entry) content
  | otherwise
  = return ()

toEntry :: String -> UTCTime -> Entry
toEntry name time = Entry
  { entry_hash = hash
  , entry_time = time
  }
  where
    hash = Hash $ abs $ Hashable.hash name

serialise :: Entry -> SerialisedEntry
serialise (Entry (Hash hash) (UTCTime day offset)) = SerialisedEntry
  $ intercalate [separator]
  [ show hash, Time.showGregorian day, show (Time.diffTimeToPicoseconds offset) ]

separator :: Char
separator = '-'

deserialise :: SerialisedEntry -> Maybe Entry
deserialise (SerialisedEntry str) =
  case mapMaybe readMaybe $ splitBy separator str of
  [h, year, month, day, offset] ->
    let days = Time.fromGregorian (toInteger year) month day
        diff = Time.picosecondsToDiffTime $ toInteger offset
    in
    return $ Entry (Hash h) (UTCTime days diff)
  _ -> Nothing
  where
    splitBy _ [] = []
    splitBy x xs = case drop 1 <$> break (== x) xs of
      ([]  , rest) -> splitBy x rest
      (part, rest) -> part : splitBy x rest

-- | Whether an entry an a serialised entry point to the same content.
matches :: SerialisedEntry -> SerialisedEntry -> Bool
matches (SerialisedEntry a) (SerialisedEntry b) =
  takeWhile (/= separator) a `isPrefixOf` b

retrieve :: MonadIO m => Cache -> Entry -> m (Maybe ByteString)
retrieve Cache{..} entry@(Entry hash _) = liftIO $ do
  inFlight <- fromInFlight
  case inFlight of
    Just res -> return $ Just res
    Nothing -> fromStorage
  where
    fromInFlight = do
      inFlight <- MVar.readMVar cache_inFlight
      case Map.lookup hash inFlight of
        Just (_, mvar) -> Just <$> MVar.readMVar mvar
        Nothing        -> return Nothing

    fromStorage = case cache_eviction of
      Evict _ _ path -> readFrom path
      NoStorage      -> return Nothing

    readFrom store = do
      stored <- readEntriesFrom store
      for (find (matches $ serialise entry) stored) $ \found ->
        ByteString.readFile (fileName store found)

readEntriesFrom :: MonadIO m => Store -> m [SerialisedEntry]
readEntriesFrom (Store path) =
  fmap (fmap SerialisedEntry) $ liftIO $ listDirectory path