packages feed

glue-core-0.7.0: src/Glue/DogpileProtection.hs

{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module supporting the dogpile protection of a service,
-- see <https://en.wikipedia.org/wiki/Cache_stampede>.
module Glue.DogpileProtection
  ( DogpileOptions (..)
  , defaultDogpileOptions
  , dogpileProtectWith
  , dogpileProtect
  ) where

import           Control.Monad       (void)
import           Data.Hashable       (Hashable)
import qualified Data.HashMap.Strict as M
import           Glue.Types
import           UnliftIO            (MonadIO, MonadUnliftIO, SomeException,
                                      liftIO)
import           UnliftIO.Concurrent (forkIO, threadDelay)
import           UnliftIO.Exception  (onException, throwIO, toException, tryAny)
import           UnliftIO.IORef      (IORef, atomicModifyIORef', newIORef)
import           UnliftIO.MVar       (MVar, newEmptyMVar, putMVar, readMVar,
                                      tryPutMVar)

-- | Options for configuring dogpile protection.
newtype DogpileOptions = DogpileOptions
  { dogpileCacheTimeMs :: Int
  -- ^ How long (in milliseconds) to retain a completed result for subsequent callers.
  }
  deriving (Eq, Show)

-- | Default options for dogpile protection, caching completed values for 5 seconds (5000ms).
defaultDogpileOptions :: DogpileOptions
defaultDogpileOptions =
  DogpileOptions
    { dogpileCacheTimeMs = 5000
    }

data DogpileResult b
  = CachedValue (Either SomeException b)
  | RequestInProgress (MVar (Either SomeException b))

type ResultMap a b = M.HashMap a (DogpileResult b)
type ResultRef a b = IORef (ResultMap a b)

getDogpileResult :: MonadIO m => DogpileResult b -> m b
getDogpileResult (RequestInProgress mvar) = readMVar mvar >>= either throwIO pure
getDogpileResult (CachedValue result)     = either throwIO pure result

-- | Dogpile protection of a service with configurable options.
dogpileProtectWith
  :: forall m n a b.
     (MonadUnliftIO m, MonadIO n, Hashable a)
  => DogpileOptions
  -- ^ Options configuring dogpile protection.
  -> BasicService m a b
  -- ^ The service to protect.
  -> n (BasicService m a b)
dogpileProtectWith options service = do
  mapRef <- newIORef M.empty :: n (ResultRef a b)
  let ttlMs = dogpileCacheTimeMs options
      protectedService request = do
        newVar <- newEmptyMVar
        actionOrExisting <- atomicModifyIORef' mapRef $ \resultMap ->
          case M.lookup request resultMap of
            Just existing -> (resultMap, Right existing)
            Nothing       -> (M.insert request (RequestInProgress newVar) resultMap, Left newVar)
        case actionOrExisting of
          Right existing -> getDogpileResult existing
          Left mvar -> do
            let cleanupOnAbort = do
                  atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))
                  void $ tryPutMVar mvar (Left (toException (userError "Dogpile leader aborted")))
            result <- tryAny (service request) `onException` cleanupOnAbort
            putMVar mvar result
            if ttlMs > 0
              then do
                atomicModifyIORef' mapRef (\m -> (M.insert request (CachedValue result) m, ()))
                void $ liftIO $ forkIO $ do
                  threadDelay (ttlMs * 1000)
                  atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))
              else
                atomicModifyIORef' mapRef (\m -> (M.delete request m, ()))
            either throwIO pure result
  pure protectedService

-- | Dogpile protection of a service using 'defaultDogpileOptions',
-- preventing multiple concurrent calls for the same value from being submitted.
dogpileProtect
  :: (MonadUnliftIO m, MonadIO n, Hashable a)
  => BasicService m a b
  -- ^ The service to protect.
  -> n (BasicService m a b)
dogpileProtect = dogpileProtectWith defaultDogpileOptions