packages feed

polling-cache-0.0.1.0: src/Data/Cache/Polling.hs

{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# OPTIONS_GHC -Wno-unused-record-wildcards #-}

-- | A cache implementation that periodically (and asynchronously) polls an external action for updated values.
module Data.Cache.Polling
  ( -- * Entry-point types and typeclasses
    MonadCache (..),
    PollingCache,

    -- * Types for working with cached results
    CacheMiss (..),
    CacheHit,
    CacheResult,

    -- * Types for cache creation
    FailureMode (..),
    DelayMode (..),
    ThreadDelay,
    CacheOptions (delayMode, failureMode, delayFuzzing),

    -- * Functions for creating and interacting with caches
    basicOptions,
    newPollingCache,
    cachedValues,
    stopPolling,
  )
where

import Control.Concurrent
import Control.Monad (unless)
import qualified Control.Monad.Catch as Exc
import Data.Cache.Internal
import Data.Functor ((<&>))
import Data.Time.Clock
import UnliftIO

-- | The supported "empty" states for a 'PollingCache'.
--
-- See 'CacheResult' for a more in-depth explanation of why this is necessary.
data CacheMiss
  = -- | A value has never been loaded into the cache.
    NotYetLoaded
  | -- | The external action used to populate the cache threw an exception at some point in time.
    LoadFailed UTCTime
  | -- | The cache has been shut down and can no longer be used.
    Stopped
  deriving (Eq, Show)

-- | A successfully cached value with the time at which it was generated.
type CacheHit a = (a, UTCTime)

-- | The result of reading a value from a 'PollingCache', including the possibility of failure.
--
-- Due to the asynchronous (and likely effectful) nature of executing external actions to populate
-- the cache, it's possible for the cache to be "empty" at any point in time. The possible empty
-- states are controlled by the 'FailureMode' selected by the user when creating the 'PollingCache'
-- instance.
type CacheResult a = Either CacheMiss (CacheHit a)

type CachePayload a = TVar (CacheResult a)

-- | An opaque type containing the internals necessary for background polling and caching.
--
-- Library functions will allow the user to create and interact with a 'PollingCache', but
-- the raw data is not exposed to users so that the library can maintain invariants.
data PollingCache a = PollingCache
  { mostRecentValues :: CachePayload a,
    threadId :: ThreadId
  }

-- | The minimum amount of time (in microseconds) that should pass before a cache reload is attempted.
type ThreadDelay = Int

isFailed :: CacheResult a -> Bool
isFailed (Left (LoadFailed _)) = True
isFailed _ = False

writeCacheFailure :: MonadCache m => CachePayload a -> UTCTime -> m ()
writeCacheFailure payload = atomically . writeTVar payload . Left . LoadFailed

handleFailure :: MonadCache m => FailureMode -> CachePayload a -> m ()
handleFailure Ignore _ = return ()
handleFailure EvictImmediately payload = do
  now <- currentTime
  current <- readTVarIO payload
  unless (isFailed current) $ writeCacheFailure payload now
handleFailure (EvictAfterTime limit) payload = do
  previousResult <- readTVarIO payload
  now <- currentTime
  let failed = previousResult <&> snd <&> (\prev -> diffUTCTime now prev >= limit)
  case failed of
    Right True -> writeCacheFailure payload now
    _ -> return ()

clamp :: Int -> Int -> Int -> Int
clamp mn mx val
  | val < mn = mn
  | val > mx = mx
  | otherwise = val

handleDelay :: MonadCache m => DelayMode a -> Maybe Int -> Either SomeException a -> m ()
handleDelay mode (Just fuzz) res = do
  fuzzDelay <- randomize (0, fuzz)
  delay fuzzDelay
  handleDelay' mode res
handleDelay mode Nothing res = handleDelay' mode res

handleDelay' :: MonadCache m => DelayMode a -> Either SomeException a -> m ()
handleDelay' (DelayForMicroseconds mics) _ = delay mics
handleDelay' (DelayDynamically f) r = delay $ f r
handleDelay' (DelayDynamicallyWithBounds (mn, mx) f) r =
  delay . clamp mn mx $ f r

-- | Create a 'CacheOptions' with basic functionality enabled.
--
-- Record update syntax can be use to further customize options created using this function:
--
-- > basicOpts = basicOptions (DelayForMicroseconds 60000000) EvictImmediately
-- > customOpts = basicOpts { delayFuzzing = Just 100 }
basicOptions :: DelayMode a -> FailureMode -> CacheOptions a
basicOptions d f = CacheOptions d f Nothing

-- | Creates a new 'PollingCache'.
--
-- The supplied action is used to generate values that are stored in the cache. The action is executed in the background
-- with its delay, failure, and fuzzing behavior controlled by the provided 'CacheOptions'.
newPollingCache :: forall a m. MonadCache m => CacheOptions a -> m a -> m (PollingCache a)
newPollingCache CacheOptions {..} generator = do
  tvar <- newTVarIO $ Left NotYetLoaded
  tid <- newThread $ cacheThread tvar
  return $ PollingCache tvar tid
  where
    cacheThread :: CachePayload a -> m ()
    cacheThread tvar = repeatedly $ do
      (result :: Either SomeException a) <- Exc.try generator
      case result of
        Left _ -> handleFailure failureMode tvar
        Right value -> do
          now <- currentTime
          atomically . writeTVar tvar $ Right (value, now)
      handleDelay delayMode delayFuzzing result

-- | Retrieve the current values from a 'PollingCache'.
cachedValues :: MonadCache m => PollingCache a -> m (CacheResult a)
cachedValues = readTVarIO . mostRecentValues

-- | Stops the background processing thread associated with a 'PollingCache'.
--
-- Calling this function will place the 'Stopped' value into the cache after stopping the processing thread,
-- ensuring that a 'PollingCache' that has been stopped can no longer be used to query stale values.
stopPolling :: MonadCache m => PollingCache a -> m ()
stopPolling PollingCache {..} = do
  killCache threadId
  atomically . writeTVar mostRecentValues $ Left Stopped